Browse Source

Event Search revamped

master
silberengel 8 months ago
parent
commit
212563aae5
  1. 422
      src/lib/components/CommentBox.svelte
  2. 27
      src/lib/components/EventDetails.svelte
  3. 30
      src/lib/components/EventInput.svelte
  4. 916
      src/lib/components/EventSearch.svelte
  5. 57
      src/lib/components/LoginModal.svelte
  6. 12
      src/lib/components/Modal.svelte
  7. 67
      src/lib/components/PublicationFeed.svelte
  8. 2
      src/lib/components/PublicationHeader.svelte
  9. 10
      src/lib/components/PublicationSection.svelte
  10. 27
      src/lib/components/RelayActions.svelte
  11. 45
      src/lib/components/cards/ProfileHeader.svelte
  12. 114
      src/lib/components/util/CardActions.svelte
  13. 17
      src/lib/components/util/Profile.svelte
  14. 80
      src/lib/components/util/ViewPublicationLink.svelte
  15. 3
      src/lib/consts.ts
  16. 2
      src/lib/ndk.ts
  17. 27
      src/lib/snippets/UserSnippets.svelte
  18. 0
      src/lib/stores/authStore.Svelte.ts
  19. 65
      src/lib/utils/community_checker.ts
  20. 3
      src/lib/utils/event_input_utils.ts
  21. 143
      src/lib/utils/event_search.ts
  22. 132
      src/lib/utils/indexEventCache.ts
  23. 1
      src/lib/utils/markup/asciidoctorPostProcessor.ts
  24. 9
      src/lib/utils/mime.ts
  25. 11
      src/lib/utils/nostrEventService.ts
  26. 40
      src/lib/utils/nostrUtils.ts
  27. 233
      src/lib/utils/profile_search.ts
  28. 3
      src/lib/utils/relayDiagnostics.ts
  29. 105
      src/lib/utils/searchCache.ts
  30. 121
      src/lib/utils/search_constants.ts
  31. 69
      src/lib/utils/search_types.ts
  32. 25
      src/lib/utils/search_utility.ts
  33. 104
      src/lib/utils/search_utils.ts
  34. 651
      src/lib/utils/subscription_search.ts
  35. 405
      src/routes/events/+page.svelte

422
src/lib/components/CommentBox.svelte

@ -1,24 +1,11 @@ @@ -1,24 +1,11 @@
<script lang="ts">
import { Button, Textarea, Alert } from "flowbite-svelte";
import { Button, Textarea, Alert, Modal, Input } from "flowbite-svelte";
import { parseBasicmarkup } from "$lib/utils/markup/basicMarkupParser";
import { nip19 } from "nostr-tools";
import {
getUserMetadata,
toNpub,
} from "$lib/utils/nostrUtils";
// Extend NostrProfile locally to include pubkey for mention search results
type NostrProfile = {
name?: string;
displayName?: string;
nip05?: string;
picture?: string;
about?: string;
banner?: string;
website?: string;
lud16?: string;
pubkey?: string;
};
import { toNpub, getUserMetadata } from "$lib/utils/nostrUtils";
import { searchProfiles } from "$lib/utils/search_utility";
import type { NostrProfile } from "$lib/utils/search_utility";
import { activePubkey } from '$lib/ndk';
import type { NDKEvent } from "$lib/utils/nostrUtils";
import {
@ -35,6 +22,8 @@ @@ -35,6 +22,8 @@
import { NDKRelaySet } from '@nostr-dev-kit/ndk';
import { NDKRelay } from '@nostr-dev-kit/ndk';
import { communityRelay } from '$lib/consts';
import { tick } from 'svelte';
import { goto } from "$app/navigation";
const props = $props<{
event: NDKEvent;
@ -59,70 +48,24 @@ @@ -59,70 +48,24 @@
let wikilinkTarget = $state('');
let wikilinkLabel = $state('');
let mentionSearchTimeout: ReturnType<typeof setTimeout> | null = null;
let nip05Search = $state('');
let nip05Results = $state<NostrProfile[]>([]);
let nip05Loading = $state(false);
// Add a cache for pubkeys with kind 1 events on communityRelay
const forestCache: Record<string, boolean> = {};
async function checkForest(pubkey: string): Promise<boolean> {
if (forestCache[pubkey] !== undefined) {
return forestCache[pubkey];
}
// Query the communityRelay for kind 1 events by this pubkey
try {
const relayUrl = communityRelay[0];
const ws = new WebSocket(relayUrl);
return await new Promise((resolve) => {
ws.onopen = () => {
// NIP-01 filter for kind 1 events by pubkey
ws.send(JSON.stringify([
'REQ', 'alexandria-forest', { kinds: [1], authors: [pubkey], limit: 1 }
]));
};
ws.onmessage = (event) => {
const data = JSON.parse(event.data);
if (data[0] === 'EVENT' && data[2]?.kind === 1) {
forestCache[pubkey] = true;
ws.close();
resolve(true);
} else if (data[0] === 'EOSE') {
forestCache[pubkey] = false;
ws.close();
resolve(false);
}
};
ws.onerror = () => {
forestCache[pubkey] = false;
ws.close();
resolve(false);
};
});
} catch {
forestCache[pubkey] = false;
return false;
}
}
// Track which pubkeys have forest status loaded
let forestStatus: Record<string, boolean> = $state({});
let mentionSearchInput: HTMLInputElement | undefined;
// Reset modal state when it opens/closes
$effect(() => {
// When mentionResults change, check forest status for each
for (const profile of mentionResults) {
if (profile.pubkey && forestStatus[profile.pubkey] === undefined) {
checkForest(profile.pubkey).then((hasForest) => {
forestStatus = { ...forestStatus, [profile.pubkey!]: hasForest };
});
}
}
});
$effect(() => {
if (!activePubkey) {
userProfile = null;
error = null;
if (showMentionModal) {
// Reset search when modal opens
mentionSearch = '';
mentionResults = [];
mentionLoading = false;
// Focus the search input after a brief delay to ensure modal is rendered
setTimeout(() => {
mentionSearchInput?.focus();
}, 100);
} else {
// Reset search when modal closes
mentionSearch = '';
mentionResults = [];
mentionLoading = false;
}
});
@ -198,7 +141,6 @@ @@ -198,7 +141,6 @@
content = "";
preview = "";
error = null;
success = null;
showOtherRelays = false;
showFallbackRelays = false;
}
@ -208,7 +150,7 @@ @@ -208,7 +150,7 @@
.replace(/\*\*(.*?)\*\*/g, "$1")
.replace(/_(.*?)_/g, "$1")
.replace(/~~(.*?)~~/g, "$1")
.replace(/\[(.*?)\]\(.*?\)/g, "$1")
.replace(/\[([^\]]+)\]\([^)]+\)/g, "$1")
.replace(/!\[(.*?)\]\(.*?\)/g, "$1")
.replace(/^>\s*/gm, "")
.replace(/^[-*]\s*/gm, "")
@ -271,175 +213,101 @@ @@ -271,175 +213,101 @@
showFallbackRelays = true;
error = "Failed to publish to other relays. Would you like to try the fallback relays?";
} else {
error = result.error || "Failed to publish to any relays. Please try again later.";
error = "Failed to publish comment. Please try again later.";
}
}
} catch (e) {
error = e instanceof Error ? e.message : "An error occurred";
} catch (e: unknown) {
console.error('Error publishing comment:', e);
error = e instanceof Error ? e.message : 'An unexpected error occurred';
} finally {
isSubmitting = false;
}
}
// Insert at cursor helper
function insertAtCursor(text: string) {
const textarea = document.querySelector('textarea');
// Add a helper to shorten npub
function shortenNpub(npub: string | undefined) {
if (!npub) return '';
return npub.slice(0, 8) + '…' + npub.slice(-4);
}
async function insertAtCursor(text: string) {
const textarea = document.querySelector("textarea");
if (!textarea) return;
const start = textarea.selectionStart;
const end = textarea.selectionEnd;
content = content.substring(0, start) + text + content.substring(end);
updatePreview();
setTimeout(() => {
// Wait for DOM updates to complete
await tick();
textarea.focus();
textarea.selectionStart = textarea.selectionEnd = start + text.length;
}, 0);
}
// Real Nostr profile search logic
// Add mention search functionality using centralized search utility
let communityStatus: Record<string, boolean> = $state({});
let isSearching = $state(false);
async function searchMentions() {
mentionLoading = true;
if (!mentionSearch.trim()) {
mentionResults = [];
const searchTerm = mentionSearch.trim();
if (!searchTerm) {
mentionLoading = false;
communityStatus = {};
return;
}
// NIP-05 pattern: user@domain
if (/^[a-z0-9._-]+@[a-z0-9.-]+$/i.test(searchTerm)) {
try {
const [name, domain] = searchTerm.split('@');
const res = await fetch(`https://${domain}/.well-known/nostr.json?name=${name}`);
const data = await res.json();
const pubkey = data.names?.[name];
if (pubkey) {
// Fetch kind:0 event for pubkey from theforest first
const ndk: NDK = get(ndkInstance);
if (!ndk) {
mentionLoading = false;
// Prevent multiple concurrent searches
if (isSearching) {
return;
}
// Try theforest relay first
const { communityRelay } = await import('$lib/consts');
const forestRelays = communityRelay.map(url => ndk.pool.relays.get(url) ?? ndk.pool.getRelay(url));
let events = await ndk.fetchEvents({ kinds: [0], authors: [pubkey] }, { closeOnEose: true }, new NDKRelaySet(new Set(forestRelays), ndk));
let eventArr = Array.from(events);
if (eventArr.length === 0) {
// Fallback to all relays
const relaySet = new NDKRelaySet(new Set(Array.from(ndk.pool.relays.values())), ndk);
events = await ndk.fetchEvents({ kinds: [0], authors: [pubkey] }, { closeOnEose: true }, relaySet);
eventArr = Array.from(events);
}
if (eventArr.length > 0) {
// Set loading state
mentionLoading = true;
isSearching = true;
try {
const event = eventArr[0];
const profileData = JSON.parse(event.content);
mentionResults = [{ ...profileData, pubkey }];
} catch {
const result = await searchProfiles(mentionSearch.trim());
mentionResults = result.profiles;
communityStatus = result.Status;
} catch (error) {
console.error('Error searching mentions:', error);
mentionResults = [];
}
} else {
mentionResults = [];
}
} else {
mentionResults = [];
}
} catch {
mentionResults = [];
}
mentionLoading = false;
return;
}
// Fallback: search by display name or name
const ndk: NDK = get(ndkInstance);
if (!ndk) {
mentionLoading = false;
return;
}
// Try theforest relay first
const { communityRelay } = await import('$lib/consts');
const forestRelays = communityRelay.map(url => ndk.pool.relays.get(url) ?? ndk.pool.getRelay(url));
let foundProfiles: Record<string, { profile: NostrProfile; created_at: number }> = {};
let relaySet = new NDKRelaySet(new Set(forestRelays), ndk);
let filter = { kinds: [0] };
let sub = ndk.subscribe(filter, { closeOnEose: true }, relaySet);
sub.on('event', (event: any) => {
try {
if (!event.content) return;
const profileData = JSON.parse(event.content);
const displayName = profileData.display_name || profileData.displayName || '';
const name = profileData.name || '';
const searchLower = searchTerm.toLowerCase();
if (
displayName.toLowerCase().includes(searchLower) ||
name.toLowerCase().includes(searchLower)
) {
// Deduplicate by pubkey, keep only newest
const pubkey = event.pubkey;
const created_at = event.created_at || 0;
if (!foundProfiles[pubkey] || foundProfiles[pubkey].created_at < created_at) {
foundProfiles[pubkey] = {
profile: { ...profileData, pubkey },
created_at,
};
}
}
} catch {}
});
sub.on('eose', async () => {
const forestResults = Object.values(foundProfiles).map(x => x.profile);
if (forestResults.length > 0) {
mentionResults = forestResults;
communityStatus = {};
} finally {
mentionLoading = false;
return;
}
// Fallback to all relays
foundProfiles = {};
const allRelays: NDKRelay[] = Array.from(ndk.pool.relays.values());
relaySet = new NDKRelaySet(new Set(allRelays), ndk);
sub = ndk.subscribe(filter, { closeOnEose: true }, relaySet);
sub.on('event', (event: any) => {
try {
if (!event.content) return;
const profileData = JSON.parse(event.content);
const displayName = profileData.display_name || profileData.displayName || '';
const name = profileData.name || '';
const searchLower = searchTerm.toLowerCase();
if (
displayName.toLowerCase().includes(searchLower) ||
name.toLowerCase().includes(searchLower)
) {
// Deduplicate by pubkey, keep only newest
const pubkey = event.pubkey;
const created_at = event.created_at || 0;
if (!foundProfiles[pubkey] || foundProfiles[pubkey].created_at < created_at) {
foundProfiles[pubkey] = {
profile: { ...profileData, pubkey },
created_at,
};
isSearching = false;
}
}
} catch {}
});
sub.on('eose', () => {
mentionResults = Object.values(foundProfiles).map(x => x.profile);
mentionLoading = false;
});
});
}
function selectMention(profile: NostrProfile) {
// Always insert nostr:npub... for the selected profile
let mention = '';
if (profile.pubkey) {
try {
const npub = toNpub(profile.pubkey);
if (profile && npub) {
insertAtCursor(`nostr:${npub}`);
if (npub) {
mention = `nostr:${npub}`;
} else {
// If toNpub fails, fallback to pubkey
mention = `nostr:${profile.pubkey}`;
}
} catch (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');
mention = `@${profile.displayName || profile.name}`;
}
insertAtCursor(mention);
showMentionModal = false;
mentionSearch = '';
mentionResults = [];
}
function insertWikilink() {
if (!wikilinkTarget.trim()) return;
let markup = '';
if (wikilinkLabel.trim()) {
markup = `[[${wikilinkTarget}|${wikilinkLabel}]]`;
@ -452,10 +320,11 @@ @@ -452,10 +320,11 @@
wikilinkLabel = '';
}
// Add a helper to shorten npub
function shortenNpub(npub: string | undefined) {
if (!npub) return '';
return npub.slice(0, 8) + '…' + npub.slice(-4);
function handleViewComment() {
if (success?.eventId) {
const nevent = nip19.neventEncode({ id: success.eventId });
goto(`/events?id=${encodeURIComponent(nevent)}`);
}
}
</script>
@ -471,32 +340,70 @@ @@ -471,32 +340,70 @@
</div>
<!-- Mention Modal -->
{#if showMentionModal}
<div class="fixed inset-0 z-50 flex items-center justify-center bg-black bg-opacity-40">
<div class="bg-white dark:bg-gray-900 rounded-lg shadow-lg p-6 w-full max-w-md">
<h3 class="text-lg font-semibold mb-2">Mention User</h3>
<Modal
class="modal-leather"
title="Mention User"
bind:open={showMentionModal}
autoclose
outsideclose
size="sm"
>
<div class="space-y-4">
<div class="flex gap-2">
<input
type="text"
class="w-full border rounded p-2 mb-2"
placeholder="Search display name or npub..."
placeholder="Search display name, name, NIP-05, or npub..."
bind:value={mentionSearch}
bind:this={mentionSearchInput}
onkeydown={(e) => {
if (e.key === 'Enter' && mentionSearch.trim() && !isSearching) {
searchMentions();
}
}}
class="flex-1 rounded-lg border border-gray-300 bg-gray-50 text-gray-900 text-sm focus:border-primary-500 focus:ring-primary-500 dark:border-gray-600 dark:bg-gray-700 dark:text-white dark:placeholder-gray-400 dark:focus:border-primary-500 dark:focus:ring-primary-500 p-2.5"
/>
<Button size="xs" color="primary" class="mb-2" onclick={searchMentions} disabled={mentionLoading || !mentionSearch.trim()}>Search</Button>
<Button
size="xs"
color="primary"
onclick={(e: Event) => {
e.preventDefault();
e.stopPropagation();
searchMentions();
}}
disabled={isSearching || !mentionSearch.trim()}
>
{#if isSearching}
Searching...
{:else}
Search
{/if}
</Button>
</div>
{#if mentionLoading}
<div>Searching...</div>
<div class="text-center py-4">Searching...</div>
{:else if mentionResults.length > 0}
<ul>
<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 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"/>
</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" />
<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>
{/if}
<div class="flex flex-col text-left">
<span class="font-semibold flex items-center gap-1">
<div class="flex flex-col text-left min-w-0 flex-1">
<span class="font-semibold truncate">
{profile.displayName || profile.name || mentionSearch}
{#if profile.pubkey && forestStatus[profile.pubkey]}
<span title="Has posted to the forest">🌲</span>
{/if}
</span>
{#if profile.nip05}
<span class="text-xs text-gray-500 flex items-center gap-1">
@ -504,47 +411,48 @@ @@ -504,47 +411,48 @@
{profile.nip05}
</span>
{/if}
<span class="text-xs text-gray-400 font-mono">{shortenNpub(profile.pubkey)}</span>
<span class="text-xs text-gray-400 font-mono truncate">{shortenNpub(profile.pubkey)}</span>
</div>
</button>
{/each}
</ul>
</div>
{:else if mentionSearch.trim()}
<div class="text-center py-4 text-gray-500">No results found</div>
{:else}
<div>No results</div>
<div class="text-center py-4 text-gray-500">Enter a search term to find users</div>
{/if}
<div class="flex justify-end mt-4">
<Button size="xs" color="alternative" onclick={() => { showMentionModal = false; }}>Cancel</Button>
</div>
</div>
</div>
{/if}
</Modal>
<!-- Wikilink Modal -->
{#if showWikilinkModal}
<div class="fixed inset-0 z-50 flex items-center justify-center bg-black bg-opacity-40">
<div class="bg-white dark:bg-gray-900 rounded-lg shadow-lg p-6 w-full max-w-md">
<h3 class="text-lg font-semibold mb-2">Insert Wikilink</h3>
<input
<Modal
class="modal-leather"
title="Insert Wikilink"
bind:open={showWikilinkModal}
autoclose
outsideclose
size="sm"
>
<Input
type="text"
class="w-full border rounded p-2 mb-2"
placeholder="Target page (e.g. target page or target-page)"
bind:value={wikilinkTarget}
class="mb-2"
/>
<input
<Input
type="text"
class="w-full border rounded p-2 mb-2"
placeholder="Display text (optional)"
bind:value={wikilinkLabel}
class="mb-4"
/>
<div class="flex justify-end gap-2 mt-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>
</div>
</div>
</div>
{/if}
</Modal>
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
<div class="space-y-4">
<div>
<Textarea
bind:value={content}
@ -581,12 +489,12 @@ @@ -581,12 +489,12 @@
<Alert color="green" dismissable>
Comment published successfully to {success.relay}!<br/>
Event ID: <span class="font-mono">{success.eventId}</span>
<a
href="/events?id={nip19.neventEncode({ id: success.eventId })}"
<button
onclick={handleViewComment}
class="text-primary-600 dark:text-primary-500 hover:underline ml-2"
>
View your comment
</a>
</button>
</Alert>
{/if}

27
src/lib/components/EventDetails.svelte

@ -87,10 +87,14 @@ @@ -87,10 +87,14 @@
gotoValue?: string;
} {
if (tag[0] === "a" && tag.length > 1) {
const [kind, pubkey, d] = tag[1].split(":");
// Parse the a-tag: kind:pubkey:d
const parts = tag[1].split(":");
if (parts.length >= 3) {
const [kind, pubkey, d] = parts;
try {
const naddr = naddrEncode(
{
kind: +kind,
kind: parseInt(kind),
pubkey,
tags: [["d", d]],
content: "",
@ -99,7 +103,14 @@ @@ -99,7 +103,14 @@
} as any,
standardRelays,
);
console.log("Converted a-tag to naddr:", tag[1], "->", naddr);
return { text: `a:${tag[1]}`, gotoValue: naddr };
} catch (error) {
console.error("Error encoding a-tag to naddr:", error);
return { text: `a:${tag[1]}`, gotoValue: tag[1] };
}
}
return { text: `a:${tag[1]}`, gotoValue: tag[1] };
}
if (tag[0] === "e" && tag.length > 1) {
const nevent = neventEncode(
@ -118,6 +129,14 @@ @@ -118,6 +129,14 @@
return { text: "" };
}
function navigateToEvent(gotoValue: string) {
console.log("Navigating to event:", gotoValue);
// Add a small delay to ensure the current search state is cleared
setTimeout(() => {
goto(`/events?id=${encodeURIComponent(gotoValue)}`);
}, 10);
}
$effect(() => {
if (event && event.kind !== 0 && event.content) {
parseBasicmarkup(event.content).then((html) => {
@ -280,8 +299,8 @@ @@ -280,8 +299,8 @@
{#if tagInfo.text && tagInfo.gotoValue}
<button
onclick={() =>
goto(`/events?id=${encodeURIComponent(tagInfo.gotoValue!)}`)}
class="underline text-primary-700 dark:text-primary-300 cursor-pointer bg-transparent border-none p-0 text-left hover:text-primary-900 dark:hover:text-primary-100"
navigateToEvent(tagInfo.gotoValue!)}
class="text-primary-700 dark:text-primary-300 cursor-pointer bg-transparent border-none p-0 text-left hover:text-primary-900 dark:hover:text-primary-100"
>
{tagInfo.text}
</button>

30
src/lib/components/EventInput.svelte

@ -2,11 +2,14 @@ @@ -2,11 +2,14 @@
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';
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][]>([]);
@ -17,16 +20,12 @@ @@ -17,16 +20,12 @@
let success = $state<string | null>(null);
let publishedRelays = $state<string[]>([]);
let pubkey = $state<string | null>(null);
let title = $state('');
let dTag = $state('');
let titleManuallyEdited = $state(false);
let dTagManuallyEdited = $state(false);
let dTagError = $state('');
let lastPublishedEventId = $state<string | null>(null);
$effect(() => {
pubkey = get(userPubkey);
});
/**
* Extracts the first Markdown/AsciiDoc header as the title.
@ -81,7 +80,9 @@ @@ -81,7 +80,9 @@
}
function validate(): { valid: boolean; reason?: string } {
if (!pubkey) return { valid: false, reason: 'Not logged in.' };
const currentUserPubkey = get(userPubkey as any);
if (!currentUserPubkey) return { valid: false, reason: 'Not logged in.' };
const pubkey = String(currentUserPubkey);
if (!content.trim()) return { valid: false, reason: 'Content required.' };
if (kind === 30023) {
const v = validateNotAsciidoc(content);
@ -117,11 +118,13 @@ @@ -117,11 +118,13 @@
try {
const ndk = get(ndkInstance);
if (!ndk || !pubkey) {
const currentUserPubkey = get(userPubkey as any);
if (!ndk || !currentUserPubkey) {
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.';
@ -343,6 +346,12 @@ @@ -343,6 +346,12 @@
}
return analysis;
}
function viewPublishedEvent() {
if (lastPublishedEventId) {
goto(`/events?id=${encodeURIComponent(lastPublishedEventId)}`);
}
}
</script>
<div class='w-full max-w-2xl mx-auto my-8 p-6 bg-white dark:bg-gray-900 rounded-lg shadow-lg'>
@ -429,12 +438,9 @@ @@ -429,12 +438,9 @@
{#if lastPublishedEventId}
<div class='mt-2 text-green-700'>
Event ID: <span class='font-mono'>{lastPublishedEventId}</span>
<a
href={'/events?id=' + lastPublishedEventId}
class='text-primary-600 dark:text-primary-500 hover:underline ml-2'
>
<Button onclick={viewPublishedEvent} class='text-primary-600 dark:text-primary-500 hover:underline ml-2'>
View your event
</a>
</Button>
</div>
{/if}
{/if}

916
src/lib/components/EventSearch.svelte

File diff suppressed because it is too large Load Diff

57
src/lib/components/LoginModal.svelte

@ -1,5 +1,5 @@ @@ -1,5 +1,5 @@
<script lang="ts">
import { Button } from "flowbite-svelte";
import { Button, Modal } from "flowbite-svelte";
import { loginWithExtension, ndkSignedIn } from "$lib/ndk";
const {
@ -14,6 +14,11 @@ @@ -14,6 +14,11 @@
let signInFailed = $state<boolean>(false);
let errorMessage = $state<string>("");
let modalOpen = $state(show);
$effect(() => {
modalOpen = show;
});
$effect(() => {
if ($ndkSignedIn && show) {
@ -22,6 +27,12 @@ @@ -22,6 +27,12 @@
}
});
$effect(() => {
if (!modalOpen) {
onClose();
}
});
async function handleSignInClick() {
try {
signInFailed = false;
@ -40,37 +51,15 @@ @@ -40,37 +51,15 @@
}
</script>
{#if show}
<div
class="fixed inset-0 z-50 flex items-center justify-center overflow-x-hidden overflow-y-auto outline-none focus:outline-none bg-gray-900 bg-opacity-50"
>
<div class="relative w-auto my-6 mx-auto max-w-3xl">
<div
class="border-0 rounded-lg shadow-lg relative flex flex-col w-full bg-white dark:bg-gray-800 outline-none focus:outline-none"
>
<!-- Header -->
<div
class="flex items-start justify-between p-5 border-b border-solid border-gray-300 dark:border-gray-600 rounded-t"
>
<h3 class="text-xl font-medium text-gray-900 dark:text-gray-100">
Login Required
</h3>
<button
class="ml-auto bg-transparent border-0 text-gray-600 float-right text-3xl leading-none font-semibold outline-none focus:outline-none"
onclick={onClose}
>
<span
class="bg-transparent text-gray-700 dark:text-gray-300 h-6 w-6 text-2xl block outline-none focus:outline-none"
</span
>
</button>
</div>
<!-- Body -->
<div class="relative p-6 flex-auto">
<p
class="text-base leading-relaxed text-gray-700 dark:text-gray-300 mb-6"
<Modal
class="modal-leather"
title="Login Required"
bind:open={modalOpen}
autoclose
outsideclose
size="sm"
>
<p class="text-base leading-relaxed text-gray-700 dark:text-gray-300 mb-6">
You need to be logged in to submit an issue. Your form data will be
preserved.
</p>
@ -88,8 +77,4 @@ @@ -88,8 +77,4 @@
</div>
{/if}
</div>
</div>
</div>
</div>
</div>
{/if}
</Modal>

12
src/lib/components/Modal.svelte

@ -1,12 +0,0 @@ @@ -1,12 +0,0 @@
<script lang="ts">
import type { NDKEvent } from "@nostr-dev-kit/ndk";
export let showModal;
export let event: NDKEvent;
// let str: string = JSON.stringify(event);
</script>
{#if showModal}
<div class="backdrop">
<div class="Modal">{event.id}</div>
</div>
{/if}

67
src/lib/components/PublicationFeed.svelte

@ -11,6 +11,10 @@ @@ -11,6 +11,10 @@
type NDKEvent,
type NDKRelaySet,
} from "$lib/utils/nostrUtils";
import { searchCache } from "$lib/utils/searchCache";
import { indexEventCache } from "$lib/utils/indexEventCache";
import { feedType } from "$lib/stores";
import { isValidNip05Address } from "$lib/utils/search_utility";
let {
relays,
@ -45,6 +49,18 @@ @@ -45,6 +49,18 @@
(r: string) => !primaryRelays.includes(r),
);
const allRelays = [...primaryRelays, ...fallback];
// Check cache first
const cachedEvents = indexEventCache.get(allRelays);
if (cachedEvents) {
console.log(`[PublicationFeed] Using cached index events (${cachedEvents.length} events)`);
allIndexEvents = cachedEvents;
eventsInView = allIndexEvents.slice(0, 30);
endOfFeed = allIndexEvents.length <= 30;
loading = false;
return;
}
relayStatuses = Object.fromEntries(
allRelays.map((r: string) => [r, "pending"]),
);
@ -91,6 +107,10 @@ @@ -91,6 +107,10 @@
allIndexEvents = Array.from(eventMap.values());
// Sort by created_at descending
allIndexEvents.sort((a, b) => b.created_at! - a.created_at!);
// Cache the fetched events
indexEventCache.set(allRelays, allIndexEvents);
// Initially show first page
eventsInView = allIndexEvents.slice(0, 30);
endOfFeed = allIndexEvents.length <= 30;
@ -108,8 +128,15 @@ @@ -108,8 +128,15 @@
events.length,
);
// Check cache first for publication search
const cachedResult = searchCache.get('publication', query);
if (cachedResult) {
console.log(`[PublicationFeed] Using cached results for publication search: ${query}`);
return cachedResult.events;
}
// Check if the query is a NIP-05 address
const isNip05Query = /^[a-z0-9._-]+@[a-z0-9.-]+$/i.test(query);
const isNip05Query = isValidNip05Address(query);
console.debug("[PublicationFeed] Is NIP-05 query:", isNip05Query);
const filtered = events.filter((event) => {
@ -151,6 +178,19 @@ @@ -151,6 +178,19 @@
}
return matches;
});
// Cache the filtered results
const result = {
events: filtered,
secondOrder: [],
tTagEvents: [],
eventIds: new Set<string>(),
addresses: new Set<string>(),
searchType: 'publication',
searchTerm: query
};
searchCache.set('publication', query, result);
console.debug("[PublicationFeed] Events after filtering:", filtered.length);
return filtered;
};
@ -197,6 +237,30 @@ @@ -197,6 +237,30 @@
return skeletonIds;
}
function getCacheStats(): string {
const indexStats = indexEventCache.getStats();
const searchStats = searchCache.size();
return `Index: ${indexStats.size} entries (${indexStats.totalEvents} events), Search: ${searchStats} entries`;
}
// Track previous feed type to avoid infinite loops
let previousFeedType = $state($feedType);
// Watch for changes in feed type and relay configuration
$effect(() => {
if (previousFeedType !== $feedType) {
console.log(`[PublicationFeed] Feed type changed from ${previousFeedType} to ${$feedType}`);
previousFeedType = $feedType;
// Clear cache when feed type changes (different relay sets)
indexEventCache.clear();
searchCache.clear();
// Refetch events with new relay configuration
fetchAllIndexEventsFromRelays();
}
});
onMount(async () => {
await fetchAllIndexEventsFromRelays();
});
@ -217,6 +281,7 @@ @@ -217,6 +281,7 @@
</div>
{/if}
</div>
{#if !loadingMore && !endOfFeed}
<div class="flex justify-center mt-4 mb-8">
<Button

2
src/lib/components/PublicationHeader.svelte

@ -51,8 +51,6 @@ @@ -51,8 +51,6 @@
authorDisplayName = undefined;
}
});
console.log("PublicationHeader event:", event);
</script>
{#if title != null && href != null}

10
src/lib/components/PublicationSection.svelte

@ -1,5 +1,4 @@ @@ -1,5 +1,4 @@
<script lang="ts">
console.log("PublicationSection loaded");
import type { PublicationTree } from "$lib/data_structures/publication_tree";
import {
contentParagraph,
@ -126,11 +125,6 @@ @@ -126,11 +125,6 @@
ref(sectionRef);
});
$effect(() => {
if (leafContent) {
console.log("leafContent HTML:", leafContent.toString());
}
});
</script>
<section
@ -142,10 +136,6 @@ @@ -142,10 +136,6 @@
<TextPlaceholder size="xxl" />
{:then [leafTitle, leafContent, leafHierarchy, publicationType, divergingBranches]}
{@const contentString = leafContent.toString()}
{@const _ = (() => {
console.log("leafContent HTML:", contentString);
return null;
})()}
{#each divergingBranches as [branch, depth]}
{@render sectionHeading(
getMatchingTags(branch, "title")[0]?.[1] ?? "",

27
src/lib/components/RelayActions.svelte

@ -1,5 +1,5 @@ @@ -1,5 +1,5 @@
<script lang="ts">
import { Button } from "flowbite-svelte";
import { Button, Modal } from "flowbite-svelte";
import { ndkInstance } from "$lib/ndk";
import { get } from "svelte/store";
import type { NDKEvent } from "$lib/utils/nostrUtils";
@ -99,18 +99,14 @@ @@ -99,18 +99,14 @@
</div>
</div>
{#if showRelayModal}
<div
class="fixed inset-0 bg-black bg-opacity-40 z-50 flex items-center justify-center"
<Modal
class="modal-leather"
title="Relay Search Results"
bind:open={showRelayModal}
autoclose
outsideclose
size="lg"
>
<div
class="bg-white dark:bg-gray-900 rounded-lg shadow-lg p-6 w-full max-w-lg relative"
>
<button
class="absolute top-2 right-2 text-gray-700 hover:text-gray-900 dark:text-gray-300 dark:hover:text-gray-100"
onclick={closeRelayModal}>&times;</button
>
<h2 class="text-lg font-semibold mb-4">Relay Search Results</h2>
<div class="flex flex-col gap-4 max-h-96 overflow-y-auto">
{#each Object.entries( { "Standard Relays": standardRelays, "User Relays": Array.from($ndkInstance?.pool?.relays.values() || []).map((r) => r.url), "Fallback Relays": fallbackRelays }, ) as [groupName, groupRelays]}
{#if groupRelays.length > 0}
@ -131,9 +127,4 @@ @@ -131,9 +127,4 @@
{/if}
{/each}
</div>
<div class="mt-4 flex justify-end">
<Button onclick={closeRelayModal}>Close</Button>
</div>
</div>
</div>
{/if}
</Modal>

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

@ -5,9 +5,11 @@ @@ -5,9 +5,11 @@
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";
// @ts-ignore
import { bech32 } from "https://esm.sh/bech32";
import type { NDKEvent } from "@nostr-dev-kit/ndk";
import { goto } from "$app/navigation";
const {
event,
@ -21,13 +23,14 @@ @@ -21,13 +23,14 @@
let lnModalOpen = $state(false);
let lnurl = $state<string | null>(null);
let communityStatus = $state<boolean | null>(null);
onMount(async () => {
if (profile?.lud16) {
try {
// Convert LN address to LNURL
const [name, domain] = profile?.lud16.split("@");
const url = `https://${domain}/.well-known/lnurlp/${name}`;
const url = lnurlpWellKnownUrl(domain, name);
const words = bech32.toWords(new TextEncoder().encode(url));
lnurl = bech32.encode("lnurl", words);
} catch {
@ -35,6 +38,20 @@ @@ -35,6 +38,20 @@
}
}
});
$effect(() => {
if (event?.pubkey) {
checkCommunity(event.pubkey).then((status) => {
communityStatus = status;
}).catch(() => {
communityStatus = false;
});
}
});
function navigateToIdentifier(link: string) {
goto(link);
}
</script>
{#if profile}
@ -63,6 +80,7 @@ @@ -63,6 +80,7 @@
}}
/>
{/if}
<div class="flex items-center gap-2">
{@render userBadge(
toNpub(event.pubkey) as string,
profile.displayName ||
@ -70,6 +88,16 @@ @@ -70,6 +88,16 @@
profile.name ||
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"/>
</svg>
</div>
{:else if communityStatus === false}
<div class="flex-shrink-0 w-4 h-4"></div>
{/if}
</div>
</div>
<div>
<div class="mt-2 flex flex-col gap-4">
@ -127,11 +155,16 @@ @@ -127,11 +155,16 @@
<div class="flex gap-2">
<dt class="font-semibold min-w-[120px]">{id.label}:</dt>
<dd class="break-all">
{#if id.link}<a
href={id.link}
class="underline text-primary-700 dark:text-primary-200 break-all"
>{id.value}</a
>{:else}{id.value}{/if}
{#if id.link}
<Button
class="text-primary-700 dark:text-primary-200"
onclick={() => navigateToIdentifier(id.link)}
>
{id.value}
</Button>
{:else}
{id.value}
{/if}
</dd>
</div>
{/each}

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

@ -1,56 +1,47 @@ @@ -1,56 +1,47 @@
<script lang="ts">
import { Button, Modal, Popover } from "flowbite-svelte";
import {
ClipboardCleanOutline,
DotsVerticalOutline,
EyeOutline,
ShareNodesOutline,
ClipboardCleanOutline,
} from "flowbite-svelte-icons";
import { Button, Modal, Popover } from "flowbite-svelte";
import { standardRelays, FeedType } from "$lib/consts";
import { neventEncode, naddrEncode } from "$lib/utils";
import CopyToClipboard from "$components/util/CopyToClipboard.svelte";
import { userBadge } from "$lib/snippets/UserSnippets.svelte";
import { neventEncode, naddrEncode } from "$lib/utils";
import { standardRelays, fallbackRelays } from "$lib/consts";
import { ndkSignedIn, inboxRelays } from "$lib/ndk";
import { feedType } from "$lib/stores";
import { inboxRelays, ndkSignedIn } from "$lib/ndk";
import type { NDKEvent } from "@nostr-dev-kit/ndk";
import CopyToClipboard from "$components/util/CopyToClipboard.svelte";
// Component props
let { event } = $props<{ event: NDKEvent }>();
import { FeedType } from "$lib/consts";
import { goto } from "$app/navigation";
import type { NDKEvent } from "$lib/utils/nostrUtils";
// 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,
);
const {
event,
title,
author,
originalAuthor,
summary,
image,
version,
source,
type,
language,
publisher,
identifier,
} = $props<{
event: NDKEvent;
title?: string;
author?: string;
originalAuthor?: string;
summary?: string;
image?: string;
version?: string;
source?: string;
type?: string;
language?: string;
publisher?: string;
identifier?: string;
}>();
// UI state
let detailsModalOpen: boolean = $state(false);
@ -83,7 +74,6 @@ @@ -83,7 +74,6 @@
* Opens the actions popover menu
*/
function openPopover() {
console.debug("[CardActions] Opening menu", { eventId: event.id });
isOpen = true;
}
@ -91,7 +81,6 @@ @@ -91,7 +81,6 @@
* Closes the actions popover menu and removes focus
*/
function closePopover() {
console.debug("[CardActions] Closing menu", { eventId: event.id });
isOpen = false;
const menu = document.getElementById("dots-" + event.id);
if (menu) menu.blur();
@ -105,10 +94,6 @@ @@ -105,10 +94,6 @@
function getIdentifier(type: "nevent" | "naddr"): string {
const encodeFn = type === "nevent" ? neventEncode : naddrEncode;
const identifier = encodeFn(event, activeRelays);
console.debug(
"[CardActions] ${type} identifier for event ${event.id}:",
identifier,
);
return identifier;
}
@ -116,22 +101,17 @@ @@ -116,22 +101,17 @@
* Opens the event details modal
*/
function viewDetails() {
console.debug("[CardActions] Opening details modal", {
eventId: event.id,
title: event.title,
author: event.author,
});
detailsModalOpen = true;
}
// Log component initialization
console.debug("[CardActions] Initialized", {
eventId: event.id,
kind: event.kind,
pubkey: event.pubkey,
title: event.title,
author: event.author,
});
/**
* Navigates to the event details page
*/
function viewEventDetails() {
const nevent = getIdentifier('nevent');
goto(`/events?id=${encodeURIComponent(nevent)}`);
}
</script>
<div
@ -265,12 +245,12 @@ @@ -265,12 +245,12 @@
{#if identifier}
<h5 class="text-sm">Identifier: {identifier}</h5>
{/if}
<a
href="/events?id={getIdentifier('nevent')}"
<button
class="mt-4 btn-leather text-center text-primary-700 hover:text-primary-800 dark:text-primary-400 dark:hover:text-primary-300 font-semibold"
onclick={viewEventDetails}
>
View Event Details
</a>
</button>
</div>
</Modal>
</div>

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

@ -8,8 +8,7 @@ @@ -8,8 +8,7 @@
} from "flowbite-svelte-icons";
import { Avatar, Popover } from "flowbite-svelte";
import type { NDKUserProfile } from "@nostr-dev-kit/ndk";
const externalProfileDestination = "./events?id=";
import { goto } from "$app/navigation";
let { pubkey, isNav = false } = $props();
@ -34,6 +33,12 @@ @@ -34,6 +33,12 @@
profile = null;
}
function handleViewProfile() {
if (npub) {
goto(`/events?id=${encodeURIComponent(npub)}`);
}
}
function shortenNpub(long: string | undefined) {
if (!long) return "";
return long.slice(0, 8) + "…" + long.slice(-4);
@ -71,14 +76,14 @@ @@ -71,14 +76,14 @@
/>
</li>
<li>
<a
class="hover:text-primary-400 dark:hover:text-primary-500 text-nowrap mt-3 m-0"
href="{externalProfileDestination}{npub}"
<button
class="hover:text-primary-400 dark:hover:text-primary-500 text-nowrap mt-3 m-0 text-left"
onclick={handleViewProfile}
>
<UserOutline
class="mr-1 !h-6 !w-6 inline !fill-none dark:!fill-none"
/><span class="underline">View profile</span>
</a>
</button>
</li>
{#if isNav}
<li>

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

@ -0,0 +1,80 @@ @@ -0,0 +1,80 @@
<script lang="ts">
import type { NDKEvent } from "$lib/utils/nostrUtils";
import { getMatchingTags } from "$lib/utils/nostrUtils";
import { naddrEncode } from "$lib/utils";
import { getEventType } from "$lib/utils/mime";
import { standardRelays } from "$lib/consts";
import { goto } from "$app/navigation";
let { event, className = "" } = $props<{
event: NDKEvent;
className?: string;
}>();
function getDeferralNaddr(event: NDKEvent): string | undefined {
// Look for a 'deferral' tag, e.g. ['deferral', 'naddr1...']
return getMatchingTags(event, "deferral")[0]?.[1];
}
function isAddressableEvent(event: NDKEvent): boolean {
return getEventType(event.kind || 0) === "addressable";
}
function getNaddrAddress(event: NDKEvent): string | null {
if (!isAddressableEvent(event)) {
return null;
}
try {
return naddrEncode(event, standardRelays);
} catch {
return null;
}
}
function getViewPublicationNaddr(event: NDKEvent): string | null {
// First, check for a-tags with 'defer' - these indicate the event is deferring to someone else's version
const aTags = getMatchingTags(event, "a");
for (const tag of aTags) {
if (tag.length >= 2 && tag.includes("defer")) {
// This is a deferral to someone else's addressable event
return tag[1]; // Return the addressable event address
}
}
// For deferred events with deferral tag, use the deferral naddr instead of the event's own naddr
const deferralNaddr = getDeferralNaddr(event);
if (deferralNaddr) {
return deferralNaddr;
}
// Otherwise, use the event's own naddr if it's addressable
return getNaddrAddress(event);
}
function navigateToPublication() {
const naddrAddress = getViewPublicationNaddr(event);
console.log("ViewPublicationLink: navigateToPublication called", {
eventKind: event.kind,
naddrAddress,
isAddressable: isAddressableEvent(event)
});
if (naddrAddress) {
console.log("ViewPublicationLink: Navigating to publication:", naddrAddress);
goto(`/publication?id=${encodeURIComponent(naddrAddress)}`);
} else {
console.log("ViewPublicationLink: No naddr address found for event");
}
}
let naddrAddress = $derived(getViewPublicationNaddr(event));
</script>
{#if naddrAddress}
<button
class="inline-flex items-center px-3 py-2 text-sm font-medium text-white bg-primary-600 hover:bg-primary-700 focus:bg-primary-700 focus:outline-none focus:ring-2 focus:ring-primary-500 rounded-lg transition-colors {className}"
onclick={navigateToPublication}
tabindex="0"
>
View Publication
</button>
{/if}

3
src/lib/consts.ts

@ -1,7 +1,8 @@ @@ -1,7 +1,8 @@
export const wikiKind = 30818;
export const indexKind = 30040;
export const zettelKinds = [30041, 30818];
export const communityRelay = ["wss://theforest.nostr1.com"];
export const communityRelay = "wss://theforest.nostr1.com";
export const profileRelay = "wss://profiles.nostr1.com";
export const standardRelays = [
"wss://thecitadel.nostr1.com",
"wss://theforest.nostr1.com",

2
src/lib/ndk.ts

@ -15,7 +15,7 @@ import { @@ -15,7 +15,7 @@ import {
anonymousRelays,
} from "./consts";
import { feedType } from "./stores";
import { userPubkey } from '$lib/stores/authStore';
import { userPubkey } from '$lib/stores/authStore.Svelte';
export const ndkInstance: Writable<NDK> = writable();

27
src/lib/snippets/UserSnippets.svelte

@ -1,22 +1,31 @@ @@ -1,22 +1,31 @@
<script module lang="ts">
import {
createProfileLink,
createProfileLinkWithVerification,
toNpub,
} from "$lib/utils/nostrUtils";
import { goto } from "$app/navigation";
export { userBadge };
</script>
{#snippet userBadge(identifier: string, displayText: string | undefined)}
{#if toNpub(identifier)}
{#await createProfileLinkWithVerification(toNpub(identifier) as string, displayText)}
{@html createProfileLink(toNpub(identifier) as string, displayText)}
{:then html}
{@html html}
{:catch}
{@html createProfileLink(toNpub(identifier) as string, displayText)}
{/await}
{@const npub = toNpub(identifier) as string}
{@const cleanId = npub.replace(/^nostr:/, "")}
{@const defaultText = `${cleanId.slice(0, 8)}...${cleanId.slice(-4)}`}
{@const displayTextFinal = displayText || defaultText}
<button
class="npub-badge hover:underline"
onclick={() => goto(`/events?id=${encodeURIComponent(cleanId)}`)}
onkeydown={(e) => {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault();
goto(`/events?id=${encodeURIComponent(cleanId)}`);
}
}}
>
@{displayTextFinal}
</button>
{:else}
{displayText ?? ""}
{/if}

0
src/lib/stores/authStore.ts → src/lib/stores/authStore.Svelte.ts

65
src/lib/utils/community_checker.ts

@ -0,0 +1,65 @@ @@ -0,0 +1,65 @@
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>();
/**
* Check if a pubkey has posted to the community relay
*/
export async function checkCommunity(pubkey: string): Promise<boolean> {
if (communityCache.has(pubkey)) {
return communityCache.get(pubkey)!;
}
try {
const relayUrl = communityRelay;
const ws = new WebSocket(relayUrl);
return await new Promise((resolve) => {
ws.onopen = () => {
ws.send(JSON.stringify([
'REQ', RELAY_CONSTANTS.COMMUNITY_REQUEST_ID, {
kinds: RELAY_CONSTANTS.COMMUNITY_REQUEST_KINDS,
authors: [pubkey],
limit: SEARCH_LIMITS.COMMUNITY_CHECK
}
]));
};
ws.onmessage = (event) => {
const data = JSON.parse(event.data);
if (data[0] === 'EVENT' && data[2]?.kind === 1) {
communityCache.set(pubkey, true);
ws.close();
resolve(true);
} else if (data[0] === 'EOSE') {
communityCache.set(pubkey, false);
ws.close();
resolve(false);
}
};
ws.onerror = () => {
communityCache.set(pubkey, false);
ws.close();
resolve(false);
};
});
} catch {
communityCache.set(pubkey, false);
return false;
}
}
/**
* Check community status for multiple profiles
*/
export async function checkCommunityStatus(profiles: Array<{ pubkey?: string }>): Promise<Record<string, boolean>> {
const communityStatus: Record<string, boolean> = {};
for (const profile of profiles) {
if (profile.pubkey) {
communityStatus[profile.pubkey] = await checkCommunity(profile.pubkey);
}
}
return communityStatus;
}

3
src/lib/utils/event_input_utils.ts

@ -2,6 +2,7 @@ import type { NDKEvent } from './nostrUtils'; @@ -2,6 +2,7 @@ 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
@ -11,7 +12,7 @@ import { NDKEvent as NDKEventClass } from '@nostr-dev-kit/ndk'; @@ -11,7 +12,7 @@ import { NDKEvent as NDKEventClass } from '@nostr-dev-kit/ndk';
* Returns true if the event kind requires a d-tag (kinds 30000-39999).
*/
export function requiresDTag(kind: number): boolean {
return kind >= 30000 && kind <= 39999;
return kind >= EVENT_KINDS.ADDRESSABLE.MIN && kind <= EVENT_KINDS.ADDRESSABLE.MAX;
}
/**

143
src/lib/utils/event_search.ts

@ -0,0 +1,143 @@ @@ -0,0 +1,143 @@
import { ndkInstance } from '$lib/ndk';
import { fetchEventWithFallback } from '$lib/utils/nostrUtils';
import { nip19 } from '$lib/utils/nostrUtils';
import { NDKEvent } from '@nostr-dev-kit/ndk';
import { get } from 'svelte/store';
import { wellKnownUrl, isValidNip05Address } from './search_utils';
import { TIMEOUTS, VALIDATION } from './search_constants';
/**
* Search for a single event by ID or filter
*/
export async function searchEvent(query: string): Promise<NDKEvent | null> {
// Clean the query and normalize to lowercase
let cleanedQuery = query.replace(/^nostr:/, "").toLowerCase();
let filterOrId: any = cleanedQuery;
// If it's a valid hex string, try as event id first, then as pubkey (profile)
if (new RegExp(`^[a-f0-9]{${VALIDATION.HEX_LENGTH}}$`, 'i').test(cleanedQuery)) {
// Try as event id
filterOrId = cleanedQuery;
const eventResult = await fetchEventWithFallback(
get(ndkInstance),
filterOrId,
TIMEOUTS.EVENT_FETCH,
);
// Always try as pubkey (profile event) as well
const profileFilter = { kinds: [0], authors: [cleanedQuery] };
const profileEvent = await fetchEventWithFallback(
get(ndkInstance),
profileFilter,
TIMEOUTS.EVENT_FETCH,
);
// Prefer profile if found and pubkey matches query
if (
profileEvent &&
profileEvent.pubkey.toLowerCase() === cleanedQuery.toLowerCase()
) {
return profileEvent;
} else if (eventResult) {
return eventResult;
}
} else if (
new RegExp(`^(nevent|note|naddr|npub|nprofile)[a-z0-9]{${VALIDATION.MIN_NOSTR_IDENTIFIER_LENGTH},}$`, 'i').test(cleanedQuery)
) {
try {
const decoded = nip19.decode(cleanedQuery);
if (!decoded) throw new Error("Invalid identifier");
switch (decoded.type) {
case "nevent":
filterOrId = decoded.data.id;
break;
case "note":
filterOrId = decoded.data;
break;
case "naddr":
filterOrId = {
kinds: [decoded.data.kind],
authors: [decoded.data.pubkey],
"#d": [decoded.data.identifier],
};
break;
case "nprofile":
filterOrId = {
kinds: [0],
authors: [decoded.data.pubkey],
};
break;
case "npub":
filterOrId = {
kinds: [0],
authors: [decoded.data],
};
break;
default:
filterOrId = cleanedQuery;
}
} catch (e) {
console.error("[Search] Invalid Nostr identifier:", cleanedQuery, e);
throw new Error("Invalid Nostr identifier.");
}
}
try {
const event = await fetchEventWithFallback(
get(ndkInstance),
filterOrId,
TIMEOUTS.EVENT_FETCH,
);
if (!event) {
console.warn("[Search] Event not found for filterOrId:", filterOrId);
return null;
} else {
return event;
}
} catch (err) {
console.error("[Search] Error fetching event:", err, "Query:", query);
throw new Error("Error fetching event. Please check the ID and try again.");
}
}
/**
* Search for NIP-05 address
*/
export async function searchNip05(nip05Address: string): Promise<NDKEvent | null> {
// NIP-05 address pattern: user@domain
if (!isValidNip05Address(nip05Address)) {
throw new Error("Invalid NIP-05 address format. Expected: user@domain");
}
try {
const [name, domain] = nip05Address.split("@");
const res = await fetch(wellKnownUrl(domain, name));
if (!res.ok) {
throw new Error(`HTTP ${res.status}: ${res.statusText}`);
}
const data = await res.json();
const pubkey = data.names?.[name];
if (pubkey) {
const profileFilter = { kinds: [0], authors: [pubkey] };
const profileEvent = await fetchEventWithFallback(
get(ndkInstance),
profileFilter,
TIMEOUTS.EVENT_FETCH,
);
if (profileEvent) {
return profileEvent;
} else {
throw new Error(`No profile found for ${name}@${domain} (pubkey: ${pubkey})`);
}
} else {
throw new Error(`NIP-05 address not found: ${name}@${domain}`);
}
} catch (e) {
console.error(`[Search] Error resolving NIP-05 address ${nip05Address}:`, e);
const errorMessage = e instanceof Error ? e.message : String(e);
throw new Error(`Error resolving NIP-05 address: ${errorMessage}`);
}
}

132
src/lib/utils/indexEventCache.ts

@ -0,0 +1,132 @@ @@ -0,0 +1,132 @@
import type { NDKEvent } from "./nostrUtils";
import { CACHE_DURATIONS, TIMEOUTS } from './search_constants';
export interface IndexEventCacheEntry {
events: NDKEvent[];
timestamp: number;
relayUrls: string[];
}
class IndexEventCache {
private cache: Map<string, IndexEventCacheEntry> = new Map();
private readonly CACHE_DURATION = CACHE_DURATIONS.INDEX_EVENT_CACHE;
private readonly MAX_CACHE_SIZE = 50; // Maximum number of cached relay combinations
/**
* Generate a cache key based on relay URLs
*/
private generateKey(relayUrls: string[]): string {
return relayUrls.sort().join('|');
}
/**
* Check if a cached entry is still valid
*/
private isExpired(entry: IndexEventCacheEntry): boolean {
return Date.now() - entry.timestamp > this.CACHE_DURATION;
}
/**
* Get cached index events for a set of relays
*/
get(relayUrls: string[]): NDKEvent[] | null {
const key = this.generateKey(relayUrls);
const entry = this.cache.get(key);
if (!entry || this.isExpired(entry)) {
if (entry) {
this.cache.delete(key);
}
return null;
}
console.log(`[IndexEventCache] Using cached index events for ${relayUrls.length} relays`);
return entry.events;
}
/**
* Store index events in cache
*/
set(relayUrls: string[], events: NDKEvent[]): void {
const key = this.generateKey(relayUrls);
// Implement LRU eviction if cache is full
if (this.cache.size >= this.MAX_CACHE_SIZE) {
const oldestKey = this.cache.keys().next().value;
if (oldestKey) {
this.cache.delete(oldestKey);
}
}
this.cache.set(key, {
events,
timestamp: Date.now(),
relayUrls: [...relayUrls]
});
console.log(`[IndexEventCache] Cached ${events.length} index events for ${relayUrls.length} relays`);
}
/**
* Check if index events are cached for a set of relays
*/
has(relayUrls: string[]): boolean {
const key = this.generateKey(relayUrls);
const entry = this.cache.get(key);
return entry !== undefined && !this.isExpired(entry);
}
/**
* Clear expired entries from cache
*/
cleanup(): void {
const now = Date.now();
for (const [key, entry] of this.cache.entries()) {
if (this.isExpired(entry)) {
this.cache.delete(key);
}
}
}
/**
* Clear all cache entries
*/
clear(): void {
this.cache.clear();
}
/**
* Get cache size
*/
size(): number {
return this.cache.size;
}
/**
* Get cache statistics
*/
getStats(): { size: number; totalEvents: number; oldestEntry: number | null } {
let totalEvents = 0;
let oldestTimestamp: number | null = null;
for (const entry of this.cache.values()) {
totalEvents += entry.events.length;
if (oldestTimestamp === null || entry.timestamp < oldestTimestamp) {
oldestTimestamp = entry.timestamp;
}
}
return {
size: this.cache.size,
totalEvents,
oldestEntry: oldestTimestamp
};
}
}
export const indexEventCache = new IndexEventCache();
// Clean up expired entries periodically
setInterval(() => {
indexEventCache.cleanup();
}, TIMEOUTS.CACHE_CLEANUP); // Check every minute

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

@ -120,7 +120,6 @@ export async function postProcessAsciidoctorHtml( @@ -120,7 +120,6 @@ export async function postProcessAsciidoctorHtml(
if (!html) return html;
try {
console.log('HTML before replaceWikilinks:', html);
// First process AsciiDoctor-generated anchors
let processedHtml = replaceAsciiDocAnchors(html);
// Then process wikilinks in [[...]] format (if any remain)

9
src/lib/utils/mime.ts

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

11
src/lib/utils/nostrEventService.ts

@ -5,6 +5,7 @@ import { userRelays } from "$lib/stores/relayStore"; @@ -5,6 +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';
export interface RootEventInfo {
rootId: string;
@ -178,8 +179,8 @@ export function buildReplyTags( @@ -178,8 +179,8 @@ export function buildReplyTags(
): string[][] {
const tags: string[][] = [];
const isParentReplaceable = parentInfo.parentKind >= 30000 && parentInfo.parentKind < 40000;
const isParentComment = parentInfo.parentKind === 1111;
const isParentReplaceable = parentInfo.parentKind >= EVENT_KINDS.ADDRESSABLE.MIN && parentInfo.parentKind < EVENT_KINDS.ADDRESSABLE.MAX;
const isParentComment = parentInfo.parentKind === EVENT_KINDS.COMMENT;
const isReplyToComment = isParentComment && rootInfo.rootId !== parent.id;
if (kind === 1) {
@ -199,7 +200,7 @@ export function buildReplyTags( @@ -199,7 +200,7 @@ export function buildReplyTags(
}
}
} else {
// Kind 1111 uses NIP-22 threading format
// Kind 1111 (comment) uses NIP-22 threading format
if (isParentReplaceable) {
const dTag = getTagValue(parent.tags || [], 'd');
if (dTag) {
@ -292,7 +293,7 @@ export async function createSignedEvent( @@ -292,7 +293,7 @@ export async function createSignedEvent(
const eventToSign = {
kind: Number(kind),
created_at: Number(Math.floor(Date.now() / 1000)),
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,
@ -329,7 +330,7 @@ async function publishToRelay(relayUrl: string, signedEvent: any): Promise<void> @@ -329,7 +330,7 @@ async function publishToRelay(relayUrl: string, signedEvent: any): Promise<void>
const timeout = setTimeout(() => {
ws.close();
reject(new Error("Timeout"));
}, 5000);
}, TIMEOUTS.GENERAL);
ws.onopen = () => {
ws.send(JSON.stringify(["EVENT", signedEvent]));

40
src/lib/utils/nostrUtils.ts

@ -9,6 +9,8 @@ import { NDKRelaySet as NDKRelaySetFromNDK } from "@nostr-dev-kit/ndk"; @@ -9,6 +9,8 @@ import { NDKRelaySet as NDKRelaySetFromNDK } from "@nostr-dev-kit/ndk";
import { sha256 } from "@noble/hashes/sha256";
import { schnorr } from "@noble/curves/secp256k1";
import { bytesToHex } from "@noble/hashes/utils";
import { wellKnownUrl } from "./search_utility";
import { TIMEOUTS, VALIDATION } from './search_constants';
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>';
@ -264,19 +266,35 @@ export async function processNostrIdentifiers( @@ -264,19 +266,35 @@ export async function processNostrIdentifiers(
export async function getNpubFromNip05(nip05: string): Promise<string | null> {
try {
const ndk = get(ndkInstance);
if (!ndk) {
console.error("NDK not initialized");
// Parse the NIP-05 address
const [name, domain] = nip05.split('@');
if (!name || !domain) {
console.error('[getNpubFromNip05] Invalid NIP-05 format:', nip05);
return null;
}
// Fetch the well-known.json file
const url = wellKnownUrl(domain, name);
const response = await fetch(url);
if (!response.ok) {
console.error('[getNpubFromNip05] HTTP error:', response.status, response.statusText);
return null;
}
const user = await ndk.getUser({ nip05 });
if (!user || !user.npub) {
const data = await response.json();
const pubkey = data.names?.[name];
if (!pubkey) {
console.error('[getNpubFromNip05] No pubkey found for name:', name);
return null;
}
return user.npub;
// Convert pubkey to npub
const npub = nip19.npubEncode(pubkey);
return npub;
} catch (error) {
console.error("Error getting npub from nip05:", error);
console.error("[getNpubFromNip05] Error getting npub from nip05:", error);
return null;
}
}
@ -284,8 +302,8 @@ export async function getNpubFromNip05(nip05: string): Promise<string | null> { @@ -284,8 +302,8 @@ export async function getNpubFromNip05(nip05: string): Promise<string | null> {
/**
* Generic utility function to add a timeout to any promise
* Can be used in two ways:
* 1. Method style: promise.withTimeout(5000)
* 2. Function style: withTimeout(promise, 5000)
* 1. Method style: promise.withTimeout(TIMEOUTS.GENERAL)
* 2. Function style: withTimeout(promise, TIMEOUTS.GENERAL)
*
* @param thisOrPromise Either the promise to timeout (function style) or the 'this' context (method style)
* @param timeoutMsOrPromise Timeout duration in milliseconds (function style) or the promise (method style)
@ -376,7 +394,7 @@ export async function fetchEventWithFallback( @@ -376,7 +394,7 @@ export async function fetchEventWithFallback(
if (
typeof filterOrId === "string" &&
/^[0-9a-f]{64}$/i.test(filterOrId)
new RegExp(`^[0-9a-f]{${VALIDATION.HEX_LENGTH}}$`, 'i').test(filterOrId)
) {
return await ndk
.fetchEvent({ ids: [filterOrId] }, undefined, relaySet)
@ -446,7 +464,7 @@ export async function fetchEventWithFallback( @@ -446,7 +464,7 @@ export async function fetchEventWithFallback(
export function toNpub(pubkey: string | undefined): string | null {
if (!pubkey) return null;
try {
if (/^[a-f0-9]{64}$/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;

233
src/lib/utils/profile_search.ts

@ -0,0 +1,233 @@ @@ -0,0 +1,233 @@
import { ndkInstance } from '$lib/ndk';
import { getUserMetadata, getNpubFromNip05 } from '$lib/utils/nostrUtils';
import { NDKRelaySet, NDKEvent } from '@nostr-dev-kit/ndk';
import { searchCache } from '$lib/utils/searchCache';
import { communityRelay, profileRelay } from '$lib/consts';
import { get } from 'svelte/store';
import type { NostrProfile, ProfileSearchResult } from './search_types';
import { fieldMatches, nip05Matches, normalizeSearchTerm, COMMON_DOMAINS, createProfileFromEvent } from './search_utils';
import { checkCommunityStatus } from './community_checker';
import { TIMEOUTS } from './search_constants';
/**
* Search for profiles by various criteria (display name, name, NIP-05, npub)
*/
export async function searchProfiles(searchTerm: string): Promise<ProfileSearchResult> {
const normalizedSearchTerm = searchTerm.toLowerCase().trim();
// Check cache first
const cachedResult = searchCache.get('profile', normalizedSearchTerm);
if (cachedResult) {
const profiles = cachedResult.events.map(event => {
try {
const profileData = JSON.parse(event.content);
return createProfileFromEvent(event, profileData);
} catch {
return null;
}
}).filter(Boolean) as NostrProfile[];
const communityStatus = await checkCommunityStatus(profiles);
return { profiles, Status: communityStatus };
}
const ndk = get(ndkInstance);
if (!ndk) {
throw new Error('NDK not initialized');
}
let foundProfiles: NostrProfile[] = [];
let timeoutId: ReturnType<typeof setTimeout> | null = null;
// Set a timeout to force completion after profile search timeout
timeoutId = setTimeout(() => {
if (foundProfiles.length === 0) {
// Timeout reached, but no need to log this
}
}, TIMEOUTS.PROFILE_SEARCH);
try {
// Check if it's a valid npub/nprofile first
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);
}
} else if (normalizedSearchTerm.includes('@')) {
// Check if it's a NIP-05 address
try {
const npub = await getNpubFromNip05(normalizedSearchTerm);
if (npub) {
const metadata = await getUserMetadata(npub);
const profile: NostrProfile = {
...metadata,
pubkey: npub
};
foundProfiles = [profile];
}
} catch (e) {
console.error('[Search] NIP-05 lookup failed:', e);
// If NIP-05 lookup fails, continue with regular search
}
} else {
// Try searching for NIP-05 addresses that match the search term
foundProfiles = await searchNip05Domains(normalizedSearchTerm, ndk);
// If no NIP-05 results found, search for profiles across relays
if (foundProfiles.length === 0) {
foundProfiles = await searchProfilesAcrossRelays(normalizedSearchTerm, ndk);
}
}
// Wait for search to complete or timeout
await new Promise<void>((resolve) => {
const checkComplete = () => {
if (timeoutId === null || foundProfiles.length > 0) {
resolve();
} else {
setTimeout(checkComplete, 100);
}
};
checkComplete();
});
// Cache the results
if (foundProfiles.length > 0) {
const events = foundProfiles.map(profile => {
const event = new NDKEvent(ndk);
event.content = JSON.stringify(profile);
event.pubkey = profile.pubkey || '';
return event;
});
const result = {
events,
secondOrder: [],
tTagEvents: [],
eventIds: new Set<string>(),
addresses: new Set<string>(),
searchType: 'profile',
searchTerm: normalizedSearchTerm
};
searchCache.set('profile', normalizedSearchTerm, result);
}
// Check community status for all profiles
const communityStatus = await checkCommunityStatus(foundProfiles);
return { profiles: foundProfiles, Status: communityStatus };
} catch (error) {
console.error('Error searching profiles:', error);
return { profiles: [], Status: {} };
} finally {
if (timeoutId) {
clearTimeout(timeoutId);
}
}
}
/**
* Search for NIP-05 addresses across common domains
*/
async function searchNip05Domains(searchTerm: string, ndk: any): Promise<NostrProfile[]> {
try {
for (const domain of COMMON_DOMAINS) {
const nip05Address = `${searchTerm}@${domain}`;
try {
const npub = await getNpubFromNip05(nip05Address);
if (npub) {
const metadata = await getUserMetadata(npub);
const profile: NostrProfile = {
...metadata,
pubkey: npub
};
return [profile];
}
} catch (e) {
// Continue to next domain
}
}
} catch (e) {
console.error('[Search] NIP-05 domain search failed:', e);
}
return [];
}
/**
* Search for profiles across relays
*/
async function searchProfilesAcrossRelays(searchTerm: string, ndk: any): Promise<NostrProfile[]> {
const foundProfiles: NostrProfile[] = [];
// Prioritize community relays for better search results
const allRelays = Array.from(ndk.pool.relays.values()) as any[];
const prioritizedRelays = new Set([
...allRelays.filter((relay: any) => relay.url === communityRelay),
...allRelays.filter((relay: any) => relay.url !== communityRelay)
]);
const relaySet = new NDKRelaySet(prioritizedRelays as any, ndk);
// Subscribe to profile events
const sub = ndk.subscribe(
{ kinds: [0] },
{ closeOnEose: true },
relaySet
);
return new Promise((resolve) => {
sub.on('event', (event: NDKEvent) => {
try {
if (!event.content) return;
const profileData = JSON.parse(event.content);
const displayName = profileData.displayName || profileData.display_name || '';
const display_name = profileData.display_name || '';
const name = profileData.name || '';
const nip05 = profileData.nip05 || '';
const about = profileData.about || '';
// Check if any field matches the search term
const matchesDisplayName = fieldMatches(displayName, searchTerm);
const matchesDisplay_name = fieldMatches(display_name, searchTerm);
const matchesName = fieldMatches(name, searchTerm);
const matchesNip05 = nip05Matches(nip05, searchTerm);
const matchesAbout = fieldMatches(about, searchTerm);
if (matchesDisplayName || matchesDisplay_name || matchesName || matchesNip05 || matchesAbout) {
const profile = createProfileFromEvent(event, profileData);
// Check if we already have this profile
const existingIndex = foundProfiles.findIndex(p => p.pubkey === event.pubkey);
if (existingIndex === -1) {
foundProfiles.push(profile);
}
}
} catch (e) {
// Invalid JSON or other error, skip
}
});
sub.on('eose', () => {
if (foundProfiles.length > 0) {
// Deduplicate by pubkey, keep only newest
const deduped: Record<string, { profile: NostrProfile; created_at: number }> = {};
for (const profile of foundProfiles) {
const pubkey = profile.pubkey;
if (pubkey) {
// We don't have created_at from getUserMetadata, so just keep the first one
if (!deduped[pubkey]) {
deduped[pubkey] = { profile, created_at: 0 };
}
}
}
const dedupedProfiles = Object.values(deduped).map(x => x.profile);
resolve(dedupedProfiles);
} else {
resolve([]);
}
});
});
}

3
src/lib/utils/relayDiagnostics.ts

@ -1,5 +1,6 @@ @@ -1,5 +1,6 @@
import { standardRelays, anonymousRelays, fallbackRelays } from '$lib/consts';
import NDK from '@nostr-dev-kit/ndk';
import { TIMEOUTS } from './search_constants';
export interface RelayDiagnostic {
url: string;
@ -31,7 +32,7 @@ export async function testRelay(url: string): Promise<RelayDiagnostic> { @@ -31,7 +32,7 @@ export async function testRelay(url: string): Promise<RelayDiagnostic> {
responseTime: Date.now() - startTime,
});
}
}, 5000);
}, TIMEOUTS.RELAY_DIAGNOSTICS);
ws.onopen = () => {
if (!resolved) {

105
src/lib/utils/searchCache.ts

@ -0,0 +1,105 @@ @@ -0,0 +1,105 @@
import type { NDKEvent } from "./nostrUtils";
import { CACHE_DURATIONS, TIMEOUTS } from './search_constants';
export interface SearchResult {
events: NDKEvent[];
secondOrder: NDKEvent[];
tTagEvents: NDKEvent[];
eventIds: Set<string>;
addresses: Set<string>;
searchType: string;
searchTerm: string;
timestamp: number;
}
class SearchCache {
private cache: Map<string, SearchResult> = new Map();
private readonly CACHE_DURATION = CACHE_DURATIONS.SEARCH_CACHE;
/**
* Generate a cache key for a search
*/
private generateKey(searchType: string, searchTerm: string): string {
if (!searchTerm) {
return `${searchType}:`;
}
return `${searchType}:${searchTerm.toLowerCase().trim()}`;
}
/**
* Check if a cached result is still valid
*/
private isExpired(result: SearchResult): boolean {
return Date.now() - result.timestamp > this.CACHE_DURATION;
}
/**
* Get cached search results
*/
get(searchType: string, searchTerm: string): SearchResult | null {
const key = this.generateKey(searchType, searchTerm);
const result = this.cache.get(key);
if (!result || this.isExpired(result)) {
if (result) {
this.cache.delete(key);
}
return null;
}
return result;
}
/**
* Store search results in cache
*/
set(searchType: string, searchTerm: string, result: Omit<SearchResult, 'timestamp'>): void {
const key = this.generateKey(searchType, searchTerm);
this.cache.set(key, {
...result,
timestamp: Date.now()
});
}
/**
* Check if a search result is cached and valid
*/
has(searchType: string, searchTerm: string): boolean {
const key = this.generateKey(searchType, searchTerm);
const result = this.cache.get(key);
return result !== undefined && !this.isExpired(result);
}
/**
* Clear expired entries from cache
*/
cleanup(): void {
const now = Date.now();
for (const [key, result] of this.cache.entries()) {
if (this.isExpired(result)) {
this.cache.delete(key);
}
}
}
/**
* Clear all cache entries
*/
clear(): void {
this.cache.clear();
}
/**
* Get cache size
*/
size(): number {
return this.cache.size;
}
}
export const searchCache = new SearchCache();
// Clean up expired entries periodically
setInterval(() => {
searchCache.cleanup();
}, TIMEOUTS.CACHE_CLEANUP); // Check every minute

121
src/lib/utils/search_constants.ts

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

69
src/lib/utils/search_types.ts

@ -0,0 +1,69 @@ @@ -0,0 +1,69 @@
import { NDKEvent } from '@nostr-dev-kit/ndk';
/**
* Extended NostrProfile interface for search results
*/
export interface NostrProfile {
name?: string;
displayName?: string;
nip05?: string;
picture?: string;
about?: string;
banner?: string;
website?: string;
lud16?: string;
pubkey?: string;
}
/**
* Search result interface for subscription-based searches
*/
export interface SearchResult {
events: NDKEvent[];
secondOrder: NDKEvent[];
tTagEvents: NDKEvent[];
eventIds: Set<string>;
addresses: Set<string>;
searchType: string;
searchTerm: string;
}
/**
* Profile search result interface
*/
export interface ProfileSearchResult {
profiles: NostrProfile[];
Status: Record<string, boolean>;
}
/**
* Search subscription type
*/
export type SearchSubscriptionType = 'd' | 't' | 'n';
/**
* Search filter configuration
*/
export interface SearchFilter {
filter: any;
subscriptionType: string;
}
/**
* Second-order search parameters
*/
export interface SecondOrderSearchParams {
searchType: 'n' | 'd';
firstOrderEvents: NDKEvent[];
eventIds?: Set<string>;
addresses?: Set<string>;
targetPubkey?: string;
}
/**
* Search callback functions
*/
export interface SearchCallbacks {
onSecondOrderUpdate?: (result: SearchResult) => void;
onSubscriptionCreated?: (sub: any) => void;
}

25
src/lib/utils/search_utility.ts

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

104
src/lib/utils/search_utils.ts

@ -0,0 +1,104 @@ @@ -0,0 +1,104 @@
/**
* Generate well-known NIP-05 URL
*/
export function wellKnownUrl(domain: string, name: string): string {
return `https://${domain}/.well-known/nostr.json?name=${name}`;
}
/**
* Generate well-known LNURLp URL for Lightning Network addresses
*/
export function lnurlpWellKnownUrl(domain: string, name: string): string {
return `https://${domain}/.well-known/lnurlp/${name}`;
}
/**
* Validate NIP-05 address format
*/
export function isValidNip05Address(address: string): boolean {
return /^[a-z0-9._-]+@[a-z0-9.-]+$/i.test(address);
}
/**
* Helper function to normalize search terms
*/
export function normalizeSearchTerm(term: string): string {
return term.toLowerCase().replace(/\s+/g, '');
}
/**
* Helper function to check if a profile field matches the search term
*/
export function fieldMatches(field: string, searchTerm: string): boolean {
if (!field) return false;
const fieldLower = field.toLowerCase();
const fieldNormalized = fieldLower.replace(/\s+/g, '');
const searchTermLower = searchTerm.toLowerCase();
const normalizedSearchTerm = normalizeSearchTerm(searchTerm);
// Check exact match
if (fieldLower === searchTermLower) return true;
if (fieldNormalized === normalizedSearchTerm) return true;
// Check if field contains the search term
if (fieldLower.includes(searchTermLower)) return true;
if (fieldNormalized.includes(normalizedSearchTerm)) return true;
// Check individual words (handle spaces in display names)
const words = fieldLower.split(/\s+/);
return words.some(word => word.includes(searchTermLower));
}
/**
* Helper function to check if NIP-05 address matches the search term
*/
export function nip05Matches(nip05: string, searchTerm: string): boolean {
if (!nip05) return false;
const nip05Lower = nip05.toLowerCase();
const searchTermLower = searchTerm.toLowerCase();
const normalizedSearchTerm = normalizeSearchTerm(searchTerm);
// Check if the part before @ contains the search term
const atIndex = nip05Lower.indexOf('@');
if (atIndex !== -1) {
const localPart = nip05Lower.substring(0, atIndex);
const localPartNormalized = localPart.replace(/\s+/g, '');
return localPart.includes(searchTermLower) || localPartNormalized.includes(normalizedSearchTerm);
}
return false;
}
/**
* Common domains for NIP-05 lookups
*/
export const COMMON_DOMAINS = [
'gitcitadel.com',
'theforest.nostr1.com',
'nostr1.com',
'nostr.land',
'sovbit.host'
] as const;
/**
* Check if an event is an emoji reaction (kind 7)
*/
export function isEmojiReaction(event: any): boolean {
return event.kind === 7;
}
/**
* Create a profile object from event data
*/
export function createProfileFromEvent(event: any, profileData: any): any {
return {
name: profileData.name,
displayName: profileData.displayName || profileData.display_name,
nip05: profileData.nip05,
picture: profileData.picture,
about: profileData.about,
banner: profileData.banner,
website: profileData.website,
lud16: profileData.lud16,
pubkey: event.pubkey
};
}

651
src/lib/utils/subscription_search.ts

@ -0,0 +1,651 @@ @@ -0,0 +1,651 @@
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, profileRelay } 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)
*/
export async function searchBySubscription(
searchType: SearchSubscriptionType,
searchTerm: string,
callbacks?: SearchCallbacks,
abortSignal?: AbortSignal
): Promise<SearchResult> {
const normalizedSearchTerm = searchTerm.toLowerCase().trim();
console.log("subscription_search: Starting search:", { searchType, searchTerm, normalizedSearchTerm });
// Check cache first
const cachedResult = searchCache.get(searchType, normalizedSearchTerm);
if (cachedResult) {
console.log("subscription_search: Found cached result:", cachedResult);
return cachedResult;
}
const ndk = get(ndkInstance);
if (!ndk) {
console.error("subscription_search: NDK not initialized");
throw new Error('NDK not initialized');
}
console.log("subscription_search: NDK initialized, creating search state");
const searchState = createSearchState();
const cleanup = createCleanupFunction(searchState);
// Set a timeout to force completion after subscription search timeout
searchState.timeoutId = setTimeout(() => {
console.log("subscription_search: Search timeout reached");
cleanup();
}, TIMEOUTS.SUBSCRIPTION_SEARCH);
// Check for abort signal
if (abortSignal?.aborted) {
console.log("subscription_search: Search aborted");
cleanup();
throw new Error('Search cancelled');
}
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");
// Phase 1: Search primary relay
if (primaryRelaySet.relays.size > 0) {
try {
console.log("subscription_search: Searching primary relay with filter:", searchFilter.filter);
const primaryEvents = await ndk.fetchEvents(
searchFilter.filter,
{ closeOnEose: true },
primaryRelaySet
);
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);
searchCache.set(searchType, normalizedSearchTerm, immediateResult);
// Start Phase 2 in background for additional results
searchOtherRelaysInBackground(searchType, searchFilter, searchState, callbacks, abortSignal, cleanup);
return immediateResult;
} else {
console.log("subscription_search: No results from primary relay, continuing to Phase 2");
}
} catch (error) {
console.error(`subscription_search: Error searching primary relay:`, error);
}
} else {
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);
}
/**
* Create search state object
*/
function createSearchState() {
return {
timeoutId: null as ReturnType<typeof setTimeout> | null,
firstOrderEvents: [] as NDKEvent[],
secondOrderEvents: [] as NDKEvent[],
tTagEvents: [] as NDKEvent[],
eventIds: new Set<string>(),
eventAddresses: new Set<string>(),
foundProfiles: [] as NDKEvent[],
isCompleted: false,
currentSubscription: null as any
};
}
/**
* Create cleanup function
*/
function createCleanupFunction(searchState: any) {
return () => {
if (searchState.timeoutId) {
clearTimeout(searchState.timeoutId);
searchState.timeoutId = null;
}
if (searchState.currentSubscription) {
try {
searchState.currentSubscription.stop();
} catch (e) {
console.warn('Error stopping subscription:', e);
}
searchState.currentSubscription = null;
}
};
}
/**
* 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 });
switch (searchType) {
case 'd':
const dFilter = {
filter: { "#d": [normalizedSearchTerm] },
subscriptionType: 'd-tag'
};
console.log("subscription_search: Created d-tag filter:", dFilter);
return dFilter;
case 't':
const tFilter = {
filter: { "#t": [normalizedSearchTerm] },
subscriptionType: 't-tag'
};
console.log("subscription_search: Created t-tag filter:", tFilter);
return tFilter;
case 'n':
const nFilter = await createProfileSearchFilter(normalizedSearchTerm);
console.log("subscription_search: Created profile filter:", nFilter);
return nFilter;
default:
throw new Error(`Unknown search type: ${searchType}`);
}
}
/**
* Create profile search filter
*/
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') {
return {
filter: { kinds: [0], authors: [decoded.data], limit: SEARCH_LIMITS.SPECIFIC_PROFILE },
subscriptionType: 'npub-specific'
};
}
} catch (e) {
// Not a valid npub, continue with other strategies
}
// Try NIP-05 lookup first
try {
for (const domain of COMMON_DOMAINS) {
const nip05Address = `${normalizedSearchTerm}@${domain}`;
try {
const npub = await getNpubFromNip05(nip05Address);
if (npub) {
return {
filter: { kinds: [0], authors: [npub], limit: SEARCH_LIMITS.SPECIFIC_PROFILE },
subscriptionType: 'nip05-found'
};
}
} catch (e) {
// Continue to next domain
}
}
} catch (e) {
// Fallback to reasonable profile search
}
return {
filter: { kinds: [0], limit: SEARCH_LIMITS.GENERAL_PROFILE },
subscriptionType: 'profile'
};
}
/**
* Create primary relay set based on search type
*/
function createPrimaryRelaySet(searchType: SearchSubscriptionType, ndk: any): NDKRelaySet {
if (searchType === 'n') {
// For profile searches, use profile relay first
const profileRelays = Array.from(ndk.pool.relays.values()).filter((relay: any) =>
relay.url === profileRelay || relay.url === profileRelay + '/'
);
return new NDKRelaySet(new Set(profileRelays) as any, ndk);
} else {
// For other searches, use community relay first
const communityRelays = Array.from(ndk.pool.relays.values()).filter((relay: any) =>
relay.url === communityRelay || relay.url === communityRelay + '/'
);
return new NDKRelaySet(new Set(communityRelays) as any, ndk);
}
}
/**
* Process primary relay results
*/
function processPrimaryRelayResults(
events: Set<NDKEvent>,
searchType: SearchSubscriptionType,
subscriptionType: string,
normalizedSearchTerm: string,
searchState: any,
abortSignal?: AbortSignal,
cleanup?: () => void
) {
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');
}
try {
if (searchType === 'n') {
processProfileEvent(event, subscriptionType, normalizedSearchTerm, searchState);
} else {
processContentEvent(event, searchType, searchState);
}
} catch (e) {
console.warn("subscription_search: Error processing event:", e);
// Invalid JSON or other error, skip
}
}
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) {
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') {
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 matchesDisplayName = fieldMatches(displayName, normalizedSearchTerm);
const matchesName = fieldMatches(name, normalizedSearchTerm);
const matchesNip05 = nip05Matches(nip05, normalizedSearchTerm);
const matchesUsername = fieldMatches(username, normalizedSearchTerm);
const matchesAbout = fieldMatches(about, normalizedSearchTerm);
const matchesBio = fieldMatches(bio, normalizedSearchTerm);
const matchesDescription = fieldMatches(description, normalizedSearchTerm);
if (matchesDisplayName || matchesName || matchesNip05 || matchesUsername || matchesAbout || matchesBio || matchesDescription) {
searchState.foundProfiles.push(event);
}
}
/**
* Process content event
*/
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 });
searchState.firstOrderEvents.push(event);
// Collect event IDs and addresses for second-order search
if (event.id) {
searchState.eventIds.add(event.id);
}
const aTags = getMatchingTags(event, "a");
aTags.forEach((tag: string[]) => {
if (tag[1]) {
searchState.eventAddresses.add(tag[1]);
}
});
} else if (searchType === 't') {
searchState.tTagEvents.push(event);
}
}
/**
* Check if search state has results
*/
function hasResults(searchState: any, searchType: SearchSubscriptionType): boolean {
if (searchType === 'n') {
return searchState.foundProfiles.length > 0;
} else if (searchType === 'd') {
return searchState.firstOrderEvents.length > 0;
} else if (searchType === 't') {
return searchState.tTagEvents.length > 0;
}
return false;
}
/**
* Create search result from state
*/
function createSearchResult(searchState: any, searchType: SearchSubscriptionType, normalizedSearchTerm: string): SearchResult {
return {
events: searchType === 'n' ? searchState.foundProfiles : searchState.firstOrderEvents,
secondOrder: [],
tTagEvents: searchType === 't' ? searchState.tTagEvents : [],
eventIds: searchState.eventIds,
addresses: searchState.eventAddresses,
searchType: searchType,
searchTerm: normalizedSearchTerm
};
}
/**
* Search other relays in background
*/
async function searchOtherRelaysInBackground(
searchType: SearchSubscriptionType,
searchFilter: SearchFilter,
searchState: any,
callbacks?: SearchCallbacks,
abortSignal?: AbortSignal,
cleanup?: () => void
): Promise<SearchResult> {
const ndk = get(ndkInstance);
const otherRelays = new NDKRelaySet(
new Set(Array.from(ndk.pool.relays.values()).filter((relay: any) => {
if (searchType === 'n') {
// For profile searches, exclude profile relay from fallback search
return relay.url !== profileRelay && relay.url !== profileRelay + '/';
} else {
// For other searches, exclude community relay from fallback search
return relay.url !== communityRelay && relay.url !== communityRelay + '/';
}
})),
ndk
);
// Subscribe to events from other relays
const sub = ndk.subscribe(
searchFilter.filter,
{ closeOnEose: true },
otherRelays
);
// Store the subscription for cleanup
searchState.currentSubscription = sub;
// Notify the component about the subscription for cleanup
if (callbacks?.onSubscriptionCreated) {
callbacks.onSubscriptionCreated(sub);
}
sub.on('event', (event: NDKEvent) => {
try {
if (searchType === 'n') {
processProfileEvent(event, searchFilter.subscriptionType, searchState.normalizedSearchTerm, searchState);
} else {
processContentEvent(event, searchType, searchState);
}
} catch (e) {
// Invalid JSON or other error, skip
}
});
return new Promise<SearchResult>((resolve) => {
sub.on('eose', () => {
const result = processEoseResults(searchType, searchState, searchFilter, callbacks);
searchCache.set(searchType, searchState.normalizedSearchTerm, result);
cleanup?.();
resolve(result);
});
});
}
/**
* Process EOSE results
*/
function processEoseResults(
searchType: SearchSubscriptionType,
searchState: any,
searchFilter: SearchFilter,
callbacks?: SearchCallbacks
): SearchResult {
if (searchType === 'n') {
return processProfileEoseResults(searchState, searchFilter, callbacks);
} else if (searchType === 'd') {
return processContentEoseResults(searchState, searchType);
} else if (searchType === 't') {
return processTTagEoseResults(searchState);
}
return createEmptySearchResult(searchType, searchState.normalizedSearchTerm);
}
/**
* Process profile EOSE results
*/
function processProfileEoseResults(searchState: any, searchFilter: SearchFilter, callbacks?: SearchCallbacks): SearchResult {
if (searchState.foundProfiles.length === 0) {
return createEmptySearchResult('n', searchState.normalizedSearchTerm);
}
// Deduplicate by pubkey, keep only newest
const deduped: Record<string, { event: NDKEvent; created_at: number }> = {};
for (const event of searchState.foundProfiles) {
const pubkey = event.pubkey;
const created_at = event.created_at || 0;
if (!deduped[pubkey] || deduped[pubkey].created_at < created_at) {
deduped[pubkey] = { event, created_at };
}
}
// Sort by creation time (newest first) and take only the most recent profiles
const dedupedProfiles = Object.values(deduped)
.sort((a, b) => b.created_at - a.created_at)
.map(x => x.event);
// Perform second-order search for npub searches
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);
}
} 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);
}
}
}
return {
events: dedupedProfiles,
secondOrder: [],
tTagEvents: [],
eventIds: new Set(dedupedProfiles.map(p => p.id)),
addresses: new Set(),
searchType: 'n',
searchTerm: searchState.normalizedSearchTerm
};
}
/**
* Process content EOSE results
*/
function processContentEoseResults(searchState: any, searchType: SearchSubscriptionType): SearchResult {
if (searchState.firstOrderEvents.length === 0) {
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 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);
// Perform second-order search for d-tag searches
if (dedupedEvents.length > 0) {
performSecondOrderSearchInBackground('d', dedupedEvents, searchState.eventIds, searchState.eventAddresses);
}
return {
events: dedupedEvents,
secondOrder: [],
tTagEvents: [],
eventIds: searchState.eventIds,
addresses: searchState.eventAddresses,
searchType: searchType,
searchTerm: searchState.normalizedSearchTerm
};
}
/**
* Process t-tag EOSE results
*/
function processTTagEoseResults(searchState: any): SearchResult {
if (searchState.tTagEvents.length === 0) {
return createEmptySearchResult('t', searchState.normalizedSearchTerm);
}
return {
events: [],
secondOrder: [],
tTagEvents: searchState.tTagEvents,
eventIds: new Set(),
addresses: new Set(),
searchType: 't',
searchTerm: searchState.normalizedSearchTerm
};
}
/**
* Create empty search result
*/
function createEmptySearchResult(searchType: SearchSubscriptionType, searchTerm: string): SearchResult {
return {
events: [],
secondOrder: [],
tTagEvents: [],
eventIds: new Set(),
addresses: new Set(),
searchType: searchType,
searchTerm: searchTerm
};
}
/**
* Perform second-order search in background
*/
async function performSecondOrderSearchInBackground(
searchType: 'n' | 'd',
firstOrderEvents: NDKEvent[],
eventIds: Set<string> = new Set(),
addresses: Set<string> = new Set(),
targetPubkey?: string,
callbacks?: SearchCallbacks
) {
try {
const ndk = get(ndkInstance);
let allSecondOrderEvents: NDKEvent[] = [];
if (searchType === 'n' && targetPubkey) {
// Search for events that mention this pubkey via p-tags
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));
allSecondOrderEvents = [...allSecondOrderEvents, ...filteredEvents];
} else if (searchType === 'd') {
// Search for events that reference the original events via e-tags and a-tags
// Search for events that reference the original events via e-tags
if (eventIds.size > 0) {
const eTagFilter = { "#e": Array.from(eventIds) };
const eTagEvents = await ndk.fetchEvents(
eTagFilter,
{ closeOnEose: true },
new NDKRelaySet(new Set(Array.from(ndk.pool.relays.values())), ndk),
);
// Filter out emoji reactions
const filteredETagEvents = Array.from(eTagEvents).filter(event => !isEmojiReaction(event));
allSecondOrderEvents = [...allSecondOrderEvents, ...filteredETagEvents];
}
// Search for events that reference the original events via a-tags
if (addresses.size > 0) {
const aTagFilter = { "#a": Array.from(addresses) };
const aTagEvents = await ndk.fetchEvents(
aTagFilter,
{ closeOnEose: true },
new NDKRelaySet(new Set(Array.from(ndk.pool.relays.values())), ndk),
);
// Filter out emoji reactions
const filteredATagEvents = Array.from(aTagEvents).filter(event => !isEmojiReaction(event));
allSecondOrderEvents = [...allSecondOrderEvents, ...filteredATagEvents];
}
}
// Deduplicate by event ID
const uniqueSecondOrder = new Map<string, NDKEvent>();
allSecondOrderEvents.forEach(event => {
if (event.id) {
uniqueSecondOrder.set(event.id, event);
}
});
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));
// Sort by creation date (newest first) and limit to newest results
const sortedSecondOrder = deduplicatedSecondOrder
.sort((a, b) => (b.created_at || 0) - (a.created_at || 0))
.slice(0, SEARCH_LIMITS.SECOND_ORDER_RESULTS);
// Update the search results with second-order events
const result: SearchResult = {
events: firstOrderEvents,
secondOrder: sortedSecondOrder,
tTagEvents: [],
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
};
// Notify UI of updated results
if (callbacks?.onSecondOrderUpdate) {
callbacks.onSecondOrderUpdate(result);
}
} catch (err) {
console.error(`[Search] Error in second-order ${searchType}-tag search:`, err);
}
}

405
src/routes/events/+page.svelte

@ -11,12 +11,14 @@ @@ -11,12 +11,14 @@
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';
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);
@ -28,6 +30,8 @@ @@ -28,6 +30,8 @@
let tTagResults = $state<NDKEvent[]>([]);
let originalEventIds = $state<Set<string>>(new Set());
let originalAddresses = $state<Set<string>>(new Set());
let searchType = $state<string | null>(null);
let searchTerm = $state<string | null>(null);
let profile = $state<{
name?: string;
display_name?: string;
@ -39,14 +43,25 @@ @@ -39,14 +43,25 @@
nip05?: string;
} | null>(null);
let userRelayPreference = $state(false);
let showSidePanel = $state(false);
let searchInProgress = $state(false);
let secondOrderSearchMessage = $state<string | null>(null);
let communityStatus = $state<Record<string, boolean>>({});
function handleEventFound(newEvent: NDKEvent) {
event = newEvent;
showSidePanel = true;
// Clear search results when showing a single event
searchResults = [];
secondOrderResults = [];
tTagResults = [];
originalEventIds = new Set();
originalAddresses = new Set();
searchType = null;
searchTerm = null;
searchInProgress = false;
secondOrderSearchMessage = null;
if (newEvent.kind === 0) {
try {
profile = JSON.parse(newEvent.content);
@ -58,20 +73,72 @@ @@ -58,20 +73,72 @@
}
}
function handleSearchResults(results: NDKEvent[], secondOrder: NDKEvent[] = [], tTagEvents: NDKEvent[] = [], eventIds: Set<string> = new Set(), addresses: Set<string> = new Set()) {
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;
originalEventIds = eventIds;
originalAddresses = addresses;
event = null;
profile = null;
searchType = searchTypeParam || null;
searchTerm = searchTermParam || null;
// Track search progress
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') {
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') {
secondOrderSearchMessage = `Found ${results.length} event(s). Starting second-order search for events referencing these events...`;
} else if (secondOrder.length > 0) {
secondOrderSearchMessage = null;
}
// Check community status for all search results
if (results.length > 0) {
checkCommunityStatusForResults(results);
}
if (secondOrder.length > 0) {
checkCommunityStatusForResults(secondOrder);
}
if (tTagEvents.length > 0) {
checkCommunityStatusForResults(tTagEvents);
}
// Don't clear the current event - let the user continue viewing it
// event = null;
// profile = null;
}
function handleClear() {
searchType = null;
searchTerm = null;
searchResults = [];
secondOrderResults = [];
tTagResults = [];
originalEventIds = new Set();
originalAddresses = new Set();
event = null;
profile = null;
showSidePanel = false;
searchInProgress = false;
secondOrderSearchMessage = null;
communityStatus = {};
goto('/events', { replaceState: true });
}
function closeSidePanel() {
showSidePanel = false;
event = null;
profile = null;
searchInProgress = false;
secondOrderSearchMessage = null;
}
function navigateToPublication(dTag: string) {
goto(`/publications?d=${encodeURIComponent(dTag.toLowerCase())}`);
}
function getSummary(event: NDKEvent): string | undefined {
return getMatchingTags(event, "summary")[0]?.[1];
}
@ -138,6 +205,17 @@ @@ -138,6 +205,17 @@
}
}
function getViewPublicationNaddr(event: NDKEvent): string | null {
// For deferred events, use the deferral naddr instead of the event's own naddr
const deferralNaddr = getDeferralNaddr(event);
if (deferralNaddr) {
return deferralNaddr;
}
// Otherwise, use the event's own naddr if it's addressable
return getNaddrAddress(event);
}
function 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);
@ -145,21 +223,58 @@ @@ -145,21 +223,58 @@
function onLoadingChange(val: boolean) {
loading = val;
searchInProgress = val || (searchResults.length > 0 && secondOrderResults.length === 0);
}
$effect(() => {
/**
* Check community status for all search results
*/
async function checkCommunityStatusForResults(events: NDKEvent[]) {
const newCommunityStatus: Record<string, boolean> = {};
for (const event of events) {
if (event.pubkey && !communityStatus[event.pubkey]) {
try {
newCommunityStatus[event.pubkey] = await checkCommunity(event.pubkey);
} catch (error) {
console.error('Error checking community status for', event.pubkey, error);
newCommunityStatus[event.pubkey] = false;
}
} else if (event.pubkey) {
newCommunityStatus[event.pubkey] = communityStatus[event.pubkey];
}
}
communityStatus = { ...communityStatus, ...newCommunityStatus };
}
function updateSearchFromURL() {
const id = $page.url.searchParams.get("id");
const dTag = $page.url.searchParams.get("d");
console.log("Events page URL update:", { id, dTag, searchValue });
if (id !== searchValue) {
console.log("ID changed, updating searchValue:", { old: searchValue, new: id });
searchValue = id;
dTagValue = null;
// Only close side panel if we're clearing the search
if (!id) {
showSidePanel = false;
event = null;
profile = null;
}
}
if (dTag !== dTagValue) {
console.log("DTag changed, updating dTagValue:", { old: dTagValue, new: dTag });
// Normalize d-tag to lowercase for consistent searching
dTagValue = dTag ? dTag.toLowerCase() : null;
searchValue = null;
// For d-tag searches (which return multiple results), close side panel
showSidePanel = false;
event = null;
profile = null;
}
// Reset state if both id and dTag are absent
@ -167,7 +282,62 @@ @@ -167,7 +282,62 @@
event = null;
searchResults = [];
profile = null;
searchType = null;
searchTerm = null;
showSidePanel = false;
searchInProgress = false;
secondOrderSearchMessage = null;
}
}
// Force search when URL changes
function handleUrlChange() {
const id = $page.url.searchParams.get("id");
const dTag = $page.url.searchParams.get("d");
console.log("Events page URL change:", { id, dTag, currentSearchValue: searchValue, currentDTagValue: dTagValue });
// Handle ID parameter changes
if (id !== searchValue) {
console.log("ID parameter changed:", { old: searchValue, new: id });
searchValue = id;
dTagValue = null;
if (!id) {
showSidePanel = false;
event = null;
profile = null;
}
}
// Handle d-tag parameter changes
if (dTag !== dTagValue) {
console.log("d-tag parameter changed:", { old: dTagValue, new: dTag });
dTagValue = dTag ? dTag.toLowerCase() : null;
searchValue = null;
showSidePanel = false;
event = null;
profile = null;
}
// Reset state if both parameters are absent
if (!id && !dTag) {
console.log("Both ID and d-tag parameters absent, resetting state");
event = null;
searchResults = [];
profile = null;
searchType = null;
searchTerm = null;
showSidePanel = false;
searchInProgress = false;
secondOrderSearchMessage = null;
searchValue = null;
dTagValue = null;
}
}
// Listen for URL changes
$effect(() => {
handleUrlChange();
});
onMount(() => {
@ -179,9 +349,20 @@ @@ -179,9 +349,20 @@
</script>
<div class="w-full flex justify-center">
<main class="main-leather flex flex-col space-y-6 max-w-2xl w-full my-6 px-4 mx-auto">
<div class="flex w-full max-w-7xl my-6 px-4 mx-auto gap-6">
<!-- Left Panel: Search and Results -->
<div class={showSidePanel ? "w-80 min-w-80" : "flex-1 max-w-4xl mx-auto"}>
<div class="main-leather flex flex-col space-y-6">
<div class="flex justify-between items-center">
<Heading tag="h1" class="h-leather mb-2">Events</Heading>
{#if showSidePanel}
<button
class="text-sm text-gray-600 dark:text-gray-400 hover:text-gray-800 dark:hover:text-gray-200"
onclick={closeSidePanel}
>
Close Details
</button>
{/if}
</div>
<P class="mb-3">
@ -202,43 +383,22 @@ @@ -202,43 +383,22 @@
onLoadingChange={onLoadingChange}
/>
{#if event}
{#if event.kind !== 0}
<div class="flex flex-col gap-2 mb-4 break-all">
<CopyToClipboard
displayText={shortenAddress(getNeventAddress(event))}
copyText={getNeventAddress(event)}
/>
{#if isAddressableEvent(event)}
{@const naddrAddress = getNaddrAddress(event)}
{#if naddrAddress}
<CopyToClipboard
displayText={shortenAddress(naddrAddress)}
copyText={naddrAddress}
/>
{/if}
{/if}
{#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">
{secondOrderSearchMessage}
</div>
{/if}
<EventDetails {event} {profile} {searchValue} />
<RelayActions {event} />
{#if $isLoggedIn && $userPubkey}
<div class="mt-8">
<Heading tag="h2" class="h-leather mb-4">Add Comment</Heading>
<CommentBox {event} {userRelayPreference} />
</div>
{:else}
<div class="mt-8 p-4 bg-gray-200 dark:bg-gray-700 rounded-lg">
<P>Please sign in to add comments.</P>
</div>
{/if}
{/if}
{#if searchResults.length > 0}
<div class="mt-8">
<Heading tag="h2" class="h-leather mb-4">
Search Results for d-tag: "{dTagValue?.toLowerCase()}" ({searchResults.length}
events)
{#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}
Search Results for d-tag: "{searchTerm || dTagValue?.toLowerCase()}" ({searchResults.length} events)
{/if}
</Heading>
<div class="space-y-4">
{#each searchResults as result, index}
@ -249,11 +409,20 @@ @@ -249,11 +409,20 @@
<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"
>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"/>
</svg>
</div>
{:else}
<div class="flex-shrink-0 w-4 h-4"></div>
{/if}
<span class="text-xs text-gray-600 dark:text-gray-400">
{@render userBadge(
toNpub(result.pubkey) as string,
@ -280,15 +449,29 @@ @@ -280,15 +449,29 @@
class="text-xs text-primary-800 dark:text-primary-300 mb-1"
>
Read
<a
class="underline text-primary-700 dark:text-primary-400 hover:text-primary-900 dark:hover:text-primary-200 break-all"
href={"/publications?d=" +
encodeURIComponent((dTagValue || "").toLowerCase())}
onclick={(e) => e.stopPropagation()}
<span
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) || '');
}}
onkeydown={(e) => {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault();
e.stopPropagation();
navigateToPublication(getDeferralNaddr(result) || '');
}
}}
tabindex="0"
role="button"
>
{getDeferralNaddr(result)}
</a>
</span>
</div>
{/if}
{#if isAddressableEvent(result)}
<div class="text-xs text-blue-600 dark:text-blue-400 mb-1">
<ViewPublicationLink event={result} />
</div>
{/if}
{#if result.content}
@ -313,6 +496,11 @@ @@ -313,6 +496,11 @@
Second-Order Events (References, Replies, Quotes) ({secondOrderResults.length}
events)
</Heading>
{#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.
</P>
@ -330,6 +518,15 @@ @@ -330,6 +518,15 @@
<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"/>
</svg>
</div>
{:else}
<div class="flex-shrink-0 w-4 h-4"></div>
{/if}
<span class="text-xs text-gray-600 dark:text-gray-400">
{@render userBadge(
toNpub(result.pubkey) as string,
@ -359,15 +556,29 @@ @@ -359,15 +556,29 @@
class="text-xs text-primary-800 dark:text-primary-300 mb-1"
>
Read
<a
class="underline text-primary-700 dark:text-primary-400 hover:text-primary-900 dark:hover:text-primary-200 break-all"
href={"/publications?d=" +
encodeURIComponent((dTagValue || "").toLowerCase())}
onclick={(e) => e.stopPropagation()}
<span
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) || '');
}}
onkeydown={(e) => {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault();
e.stopPropagation();
navigateToPublication(getDeferralNaddr(result) || '');
}
}}
tabindex="0"
role="button"
>
{getDeferralNaddr(result)}
</a>
</span>
</div>
{/if}
{#if isAddressableEvent(result)}
<div class="text-xs text-blue-600 dark:text-blue-400 mb-1">
<ViewPublicationLink event={result} />
</div>
{/if}
{#if result.content}
@ -389,8 +600,7 @@ @@ -389,8 +600,7 @@
{#if tTagResults.length > 0}
<div class="mt-8">
<Heading tag="h2" class="h-leather mb-4">
Search Results for t-tag: "{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.
@ -409,6 +619,15 @@ @@ -409,6 +619,15 @@
<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"/>
</svg>
</div>
{:else}
<div class="flex-shrink-0 w-4 h-4"></div>
{/if}
<span class="text-xs text-gray-600 dark:text-gray-400">
{@render userBadge(
toNpub(result.pubkey) as string,
@ -435,15 +654,29 @@ @@ -435,15 +654,29 @@
class="text-xs text-primary-800 dark:text-primary-300 mb-1"
>
Read
<a
class="underline text-primary-700 dark:text-primary-400 hover:text-primary-900 dark:hover:text-primary-200 break-all"
href={"/publications?d=" +
encodeURIComponent((dTagValue || "").toLowerCase())}
onclick={(e) => e.stopPropagation()}
<span
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) || '');
}}
onkeydown={(e) => {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault();
e.stopPropagation();
navigateToPublication(getDeferralNaddr(result) || '');
}
}}
tabindex="0"
role="button"
>
{getDeferralNaddr(result)}
</a>
</span>
</div>
{/if}
{#if isAddressableEvent(result)}
<div class="text-xs text-blue-600 dark:text-blue-400 mb-1">
<ViewPublicationLink event={result} />
</div>
{/if}
{#if result.content}
@ -462,10 +695,62 @@ @@ -462,10 +695,62 @@
</div>
{/if}
{#if !event && searchResults.length === 0 && secondOrderResults.length === 0 && tTagResults.length === 0 && !searchValue && !dTagValue}
{#if !event && searchResults.length === 0 && secondOrderResults.length === 0 && tTagResults.length === 0 && !searchValue && !dTagValue && !searchInProgress}
<div class="mt-8">
<EventInput />
</div>
{/if}
</main>
</div>
</div>
<!-- Right Panel: Event Details -->
{#if showSidePanel && event}
<div class="flex-1 min-w-0 main-leather flex flex-col space-y-6">
<div class="flex justify-between items-center">
<Heading tag="h2" class="h-leather mb-2">Event Details</Heading>
<button
class="text-sm text-gray-600 dark:text-gray-400 hover:text-gray-800 dark:hover:text-gray-200"
onclick={closeSidePanel}
>
</button>
</div>
{#if event.kind !== 0}
<div class="flex flex-col gap-2 mb-4 break-all">
<CopyToClipboard
displayText={shortenAddress(getNeventAddress(event))}
copyText={getNeventAddress(event)}
/>
{#if isAddressableEvent(event)}
{@const naddrAddress = getViewPublicationNaddr(event)}
{#if naddrAddress}
<CopyToClipboard
displayText={shortenAddress(naddrAddress)}
copyText={naddrAddress}
/>
<div class="mt-2">
<ViewPublicationLink {event} />
</div>
{/if}
{/if}
</div>
{/if}
<EventDetails {event} {profile} {searchValue} />
<RelayActions {event} />
{#if isLoggedIn && userPubkey}
<div class="mt-8">
<Heading tag="h3" class="h-leather mb-4">Add Comment</Heading>
<CommentBox {event} {userRelayPreference} />
</div>
{:else}
<div class="mt-8 p-4 bg-gray-200 dark:bg-gray-700 rounded-lg">
<P>Please sign in to add comments.</P>
</div>
{/if}
</div>
{/if}
</div>
</div>

Loading…
Cancel
Save