Browse Source

Fixed everything broken after merge. Corrected some bugs. Added 30040 traceback links.

master
silberengel 8 months ago
parent
commit
1c0f1bbdce
  1. 21
      src/lib/components/CommentBox.svelte
  2. 205
      src/lib/components/EventDetails.svelte
  3. 441
      src/lib/components/EventSearch.svelte
  4. 40
      src/lib/components/PublicationFeed.svelte
  5. 96
      src/lib/components/PublicationHeader.svelte
  6. 49
      src/lib/components/PublicationSection.svelte
  7. 3
      src/lib/components/cards/BlogHeader.svelte
  8. 6
      src/lib/components/cards/ProfileHeader.svelte
  9. 105
      src/lib/components/util/ContainingIndexes.svelte
  10. 7
      src/lib/components/util/Details.svelte
  11. 2
      src/lib/consts.ts
  12. 22
      src/lib/navigator/EventNetwork/utils/networkBuilder.ts
  13. 30
      src/lib/ndk.ts
  14. 27
      src/lib/utils/community_checker.ts
  15. 105
      src/lib/utils/event_search.ts
  16. 10
      src/lib/utils/nostrEventService.ts
  17. 57
      src/lib/utils/nostrUtils.ts
  18. 255
      src/lib/utils/profile_search.ts
  19. 5
      src/lib/utils/search_constants.ts
  20. 91
      src/lib/utils/subscription_search.ts
  21. 63
      src/routes/+page.svelte
  22. 105
      src/routes/events/+page.svelte
  23. 1
      src/routes/publication/+page.svelte
  24. 11
      src/routes/visualize/+page.svelte

21
src/lib/components/CommentBox.svelte

@ -4,7 +4,7 @@ @@ -4,7 +4,7 @@
import { nip19 } from "nostr-tools";
import { toNpub, getUserMetadata } from "$lib/utils/nostrUtils";
import { searchProfiles } from "$lib/utils/search_utility";
import type { NostrProfile } from "$lib/utils/search_utility";
import type { NostrProfile, ProfileSearchResult } from "$lib/utils/search_utility";
import { userPubkey } from '$lib/stores/authStore.Svelte';
import type { NDKEvent } from "$lib/utils/nostrUtils";
@ -16,12 +16,6 @@ @@ -16,12 +16,6 @@
publishEvent,
navigateToEvent,
} from "$lib/utils/nostrEventService";
import { get } from 'svelte/store';
import { ndkInstance } from '$lib/ndk';
import type NDK from '@nostr-dev-kit/ndk';
import { NDKRelaySet } from '@nostr-dev-kit/ndk';
import { NDKRelay } from '@nostr-dev-kit/ndk';
import { communityRelay } from '$lib/consts';
import { tick } from 'svelte';
import { goto } from "$app/navigation";
@ -263,14 +257,25 @@ @@ -263,14 +257,25 @@
return;
}
console.log('Starting search for:', mentionSearch.trim());
// Set loading state
mentionLoading = true;
isSearching = true;
try {
console.log('Search promise created, waiting for result...');
const result = await searchProfiles(mentionSearch.trim());
console.log('Search completed, found profiles:', result.profiles.length);
console.log('Profile details:', result.profiles);
console.log('Community status:', result.Status);
// Update state
mentionResults = result.profiles;
communityStatus = result.Status;
console.log('State updated - mentionResults length:', mentionResults.length);
console.log('State updated - communityStatus keys:', Object.keys(communityStatus));
} catch (error) {
console.error('Error searching mentions:', error);
mentionResults = [];
@ -278,6 +283,7 @@ @@ -278,6 +283,7 @@
} finally {
mentionLoading = false;
isSearching = false;
console.log('Search finished - loading:', mentionLoading, 'searching:', isSearching);
}
}
@ -383,6 +389,7 @@ @@ -383,6 +389,7 @@
{#if mentionLoading}
<div class="text-center py-4">Searching...</div>
{:else if mentionResults.length > 0}
<div class="text-center py-2 text-xs text-gray-500">Found {mentionResults.length} results</div>
<div class="max-h-64 overflow-y-auto border border-gray-200 dark:border-gray-700 rounded-lg">
<ul class="space-y-1 p-2">
{#each mentionResults as profile}

205
src/lib/components/EventDetails.svelte

@ -13,6 +13,7 @@ @@ -13,6 +13,7 @@
import { getUserMetadata } from "$lib/utils/nostrUtils";
import CopyToClipboard from "$lib/components/util/CopyToClipboard.svelte";
import { navigateToEvent } from "$lib/utils/nostrEventService";
import ContainingIndexes from "$lib/components/util/ContainingIndexes.svelte";
const {
event,
@ -86,10 +87,81 @@ @@ -86,10 +87,81 @@
function renderTag(tag: string[]): string {
if (tag[0] === 'a' && tag.length > 1) {
const [kind, pubkey, d] = tag[1].split(':');
return `<a href='/events?id=${naddrEncode({kind: +kind, pubkey, tags: [['d', d]], content: '', id: '', sig: ''} as any, standardRelays)}' class='underline text-primary-700'>a:${tag[1]}</a>`;
const parts = tag[1].split(':');
if (parts.length >= 3) {
const [kind, pubkey, d] = parts;
// Validate that pubkey is a valid hex string
if (pubkey && /^[0-9a-fA-F]{64}$/.test(pubkey)) {
try {
const mockEvent = {
kind: +kind,
pubkey,
tags: [['d', d]],
content: '',
id: '',
sig: ''
} as any;
const naddr = naddrEncode(mockEvent, standardRelays);
return `<a href='/events?id=${naddr}' class='underline text-primary-700'>a:${tag[1]}</a>`;
} catch (error) {
console.warn('Failed to encode naddr for a tag in renderTag:', tag[1], error);
return `<span class='bg-primary-50 text-primary-800 px-2 py-1 rounded text-xs font-mono'>a:${tag[1]}</span>`;
}
} else {
console.warn('Invalid pubkey in a tag in renderTag:', pubkey);
return `<span class='bg-primary-50 text-primary-800 px-2 py-1 rounded text-xs font-mono'>a:${tag[1]}</span>`;
}
} else {
console.warn('Invalid a tag format in renderTag:', tag[1]);
return `<span class='bg-primary-50 text-primary-800 px-2 py-1 rounded text-xs font-mono'>a:${tag[1]}</span>`;
}
} else if (tag[0] === 'e' && tag.length > 1) {
return `<a href='/events?id=${neventEncode({id: tag[1], kind: 1, content: '', tags: [], pubkey: '', sig: ''} as any, standardRelays)}' class='underline text-primary-700'>e:${tag[1]}</a>`;
// Validate that event ID is a valid hex string
if (/^[0-9a-fA-F]{64}$/.test(tag[1])) {
try {
const mockEvent = {
id: tag[1],
kind: 1,
content: '',
tags: [],
pubkey: '',
sig: ''
} as any;
const nevent = neventEncode(mockEvent, standardRelays);
return `<a href='/events?id=${nevent}' class='underline text-primary-700'>e:${tag[1]}</a>`;
} catch (error) {
console.warn('Failed to encode nevent for e tag in renderTag:', tag[1], error);
return `<span class='bg-primary-50 text-primary-800 px-2 py-1 rounded text-xs font-mono'>e:${tag[1]}</span>`;
}
} else {
console.warn('Invalid event ID in e tag in renderTag:', tag[1]);
return `<span class='bg-primary-50 text-primary-800 px-2 py-1 rounded text-xs font-mono'>e:${tag[1]}</span>`;
}
} else if (tag[0] === 'note' && tag.length > 1) {
// 'note' tags are the same as 'e' tags but with different prefix
if (/^[0-9a-fA-F]{64}$/.test(tag[1])) {
try {
const mockEvent = {
id: tag[1],
kind: 1,
content: '',
tags: [],
pubkey: '',
sig: ''
} as any;
const nevent = neventEncode(mockEvent, standardRelays);
return `<a href='/events?id=${nevent}' class='underline text-primary-700'>note:${tag[1]}</a>`;
} catch (error) {
console.warn('Failed to encode nevent for note tag in renderTag:', tag[1], error);
return `<span class='bg-primary-50 text-primary-800 px-2 py-1 rounded text-xs font-mono'>note:${tag[1]}</span>`;
}
} else {
console.warn('Invalid event ID in note tag in renderTag:', tag[1]);
return `<span class='bg-primary-50 text-primary-800 px-2 py-1 rounded text-xs font-mono'>note:${tag[1]}</span>`;
}
} else if (tag[0] === 'd' && tag.length > 1) {
// 'd' tags are used for identifiers in addressable events
return `<a href='/events?d=${encodeURIComponent(tag[1])}' class='underline text-primary-700'>d:${tag[1]}</a>`;
} else {
return `<span class='bg-primary-50 text-primary-800 px-2 py-1 rounded text-xs font-mono'>${tag[0]}:${tag[1]}</span>`;
}
@ -100,21 +172,104 @@ @@ -100,21 +172,104 @@
gotoValue?: string;
} {
if (tag[0] === 'a' && tag.length > 1) {
const [kind, pubkey, d] = tag[1].split(':');
const parts = tag[1].split(':');
if (parts.length >= 3) {
const [kind, pubkey, d] = parts;
// Validate that pubkey is a valid hex string
if (pubkey && /^[0-9a-fA-F]{64}$/.test(pubkey)) {
try {
const mockEvent = {
kind: +kind,
pubkey,
tags: [['d', d]],
content: '',
id: '',
sig: ''
} as any;
const naddr = naddrEncode(mockEvent, standardRelays);
return {
text: `a:${tag[1]}`,
gotoValue: naddrEncode({kind: +kind, pubkey, tags: [['d', d]], content: '', id: '', sig: ''} as any, standardRelays)
gotoValue: naddr
};
} catch (error) {
console.warn('Failed to encode naddr for a tag:', tag[1], error);
return { text: `a:${tag[1]}` };
}
} else {
console.warn('Invalid pubkey in a tag:', pubkey);
return { text: `a:${tag[1]}` };
}
} else {
console.warn('Invalid a tag format:', tag[1]);
return { text: `a:${tag[1]}` };
}
} else if (tag[0] === 'e' && tag.length > 1) {
// Validate that event ID is a valid hex string
if (/^[0-9a-fA-F]{64}$/.test(tag[1])) {
try {
const mockEvent = {
id: tag[1],
kind: 1,
content: '',
tags: [],
pubkey: '',
sig: ''
} as any;
const nevent = neventEncode(mockEvent, standardRelays);
return {
text: `e:${tag[1]}`,
gotoValue: neventEncode({id: tag[1], kind: 1, content: '', tags: [], pubkey: '', sig: ''} as any, standardRelays)
gotoValue: nevent
};
} catch (error) {
console.warn('Failed to encode nevent for e tag:', tag[1], error);
return { text: `e:${tag[1]}` };
}
} else {
console.warn('Invalid event ID in e tag:', tag[1]);
return { text: `e:${tag[1]}` };
}
} else if (tag[0] === 'p' && tag.length > 1) {
const npub = toNpub(tag[1]);
return {
text: `p:${npub || tag[1]}`,
gotoValue: npub ? `/events?id=${npub}` : undefined
gotoValue: npub ? npub : undefined
};
} else if (tag[0] === 'note' && tag.length > 1) {
// 'note' tags are the same as 'e' tags but with different prefix
if (/^[0-9a-fA-F]{64}$/.test(tag[1])) {
try {
const mockEvent = {
id: tag[1],
kind: 1,
content: '',
tags: [],
pubkey: '',
sig: ''
} as any;
const nevent = neventEncode(mockEvent, standardRelays);
return {
text: `note:${tag[1]}`,
gotoValue: nevent
};
} catch (error) {
console.warn('Failed to encode nevent for note tag:', tag[1], error);
return { text: `note:${tag[1]}` };
}
} else {
console.warn('Invalid event ID in note tag:', tag[1]);
return { text: `note:${tag[1]}` };
}
} else if (tag[0] === 'd' && tag.length > 1) {
// 'd' tags are used for identifiers in addressable events
return {
text: `d:${tag[1]}`,
gotoValue: `d:${tag[1]}`
};
} else if (tag[0] === 't' && tag.length > 1) {
// 't' tags are hashtags - navigate to t-tag search
return {
text: `t:${tag[1]}`,
gotoValue: `t:${tag[1]}`
};
}
return { text: `${tag[0]}:${tag[1]}` };
@ -246,15 +401,19 @@ @@ -246,15 +401,19 @@
<span class="text-gray-700 dark:text-gray-300">Tags:</span>
<div class="flex flex-wrap gap-2">
{#each getEventHashtags(event) as tag}
<span
class="px-2 py-1 rounded bg-primary-100 text-primary-800 text-sm font-medium"
>#{tag}</span
<button
onclick={() => goto(`/events?t=${encodeURIComponent(tag)}`)}
class="px-2 py-1 rounded bg-primary-100 text-primary-800 text-sm font-medium hover:bg-primary-200 cursor-pointer"
>#{tag}</button
>
{/each}
</div>
</div>
{/if}
<!-- Containing Publications -->
<ContainingIndexes {event} />
<!-- Content -->
<div class="flex flex-col space-y-1">
{#if event.kind !== 0}
@ -289,8 +448,30 @@ @@ -289,8 +448,30 @@
{@const tagInfo = getTagButtonInfo(tag)}
{#if tagInfo.text && tagInfo.gotoValue}
<button
onclick={() =>
navigateToEvent(tagInfo.gotoValue!)}
onclick={() => {
// Handle different types of gotoValue
if (tagInfo.gotoValue!.startsWith('naddr') || tagInfo.gotoValue!.startsWith('nevent') || tagInfo.gotoValue!.startsWith('npub') || tagInfo.gotoValue!.startsWith('nprofile') || tagInfo.gotoValue!.startsWith('note')) {
// For naddr, nevent, npub, nprofile, note - navigate directly
goto(`/events?id=${tagInfo.gotoValue!}`);
} else if (tagInfo.gotoValue!.startsWith('/')) {
// For relative URLs - navigate directly
goto(tagInfo.gotoValue!);
} else if (tagInfo.gotoValue!.startsWith('d:')) {
// For d-tag searches - navigate to d-tag search
const dTag = tagInfo.gotoValue!.substring(2);
goto(`/events?d=${encodeURIComponent(dTag)}`);
} else if (tagInfo.gotoValue!.startsWith('t:')) {
// For t-tag searches - navigate to t-tag search
const tTag = tagInfo.gotoValue!.substring(2);
goto(`/events?t=${encodeURIComponent(tTag)}`);
} else if (/^[0-9a-fA-F]{64}$/.test(tagInfo.gotoValue!)) {
// For hex event IDs - use navigateToEvent
navigateToEvent(tagInfo.gotoValue!);
} else {
// For other cases, try direct navigation
goto(`/events?id=${tagInfo.gotoValue!}`);
}
}}
class="text-primary-700 dark:text-primary-300 cursor-pointer bg-transparent border-none p-0 text-left hover:text-primary-900 dark:hover:text-primary-100"
>
{tagInfo.text}

441
src/lib/components/EventSearch.svelte

@ -5,8 +5,9 @@ @@ -5,8 +5,9 @@
import type { NDKEvent } from "$lib/utils/nostrUtils";
import RelayDisplay from "./RelayDisplay.svelte";
import { searchEvent, searchBySubscription, searchNip05 } from "$lib/utils/search_utility";
import { neventEncode, naddrEncode } from "$lib/utils";
import { neventEncode, naddrEncode, nprofileEncode } from "$lib/utils";
import { standardRelays } from "$lib/consts";
import { getMatchingTags, toNpub } from "$lib/utils/nostrUtils";
// Props definition
let {
@ -62,42 +63,200 @@ @@ -62,42 +63,200 @@
// Track last processed values to prevent loops
let lastProcessedSearchValue = $state<string | null>(null);
let lastProcessedDTagValue = $state<string | null>(null);
let isProcessingSearch = $state(false);
let currentProcessingSearchValue = $state<string | null>(null);
let lastSearchValue = $state<string | null>(null);
let isWaitingForSearchResult = $state(false);
let isUserEditing = $state(false);
// Simple effect to handle searchValue changes
// Move search handler functions above all $effect runes
async function handleNip05Search(query: string) {
try {
const foundEvent = await searchNip05(query);
if (foundEvent) {
handleFoundEvent(foundEvent);
updateSearchState(false, true, 1, 'nip05');
} else {
relayStatuses = {};
if (activeSub) { try { activeSub.stop(); } catch (e) { console.warn('Error stopping subscription:', e); } activeSub = null; }
if (currentAbortController) { currentAbortController.abort(); currentAbortController = null; }
updateSearchState(false, true, 0, 'nip05');
}
} catch (error) {
localError = error instanceof Error ? error.message : 'NIP-05 lookup failed';
relayStatuses = {};
if (activeSub) { try { activeSub.stop(); } catch (e) { console.warn('Error stopping subscription:', e); } activeSub = null; }
if (currentAbortController) { currentAbortController.abort(); currentAbortController = null; }
updateSearchState(false, false, null, null);
isProcessingSearch = false;
currentProcessingSearchValue = null;
lastSearchValue = null;
lastSearchValue = null;
}
}
async function handleEventSearch(query: string) {
try {
const foundEvent = await searchEvent(query);
if (!foundEvent) {
console.warn("[Events] Event not found for query:", query);
localError = "Event not found";
relayStatuses = {};
if (activeSub) { try { activeSub.stop(); } catch (e) { console.warn('Error stopping subscription:', e); } activeSub = null; }
if (currentAbortController) { currentAbortController.abort(); currentAbortController = null; }
updateSearchState(false, false, null, null);
} else {
console.log("[Events] Event found:", foundEvent);
handleFoundEvent(foundEvent);
updateSearchState(false, true, 1, 'event');
}
} catch (err) {
console.error("[Events] Error fetching event:", err, "Query:", query);
localError = "Error fetching event. Please check the ID and try again.";
relayStatuses = {};
if (activeSub) { try { activeSub.stop(); } catch (e) { console.warn('Error stopping subscription:', e); } activeSub = null; }
if (currentAbortController) { currentAbortController.abort(); currentAbortController = null; }
updateSearchState(false, false, null, null);
isProcessingSearch = false;
}
}
async function handleSearchEvent(clearInput: boolean = true, queryOverride?: string) {
if (searching) {
console.log("EventSearch: Already searching, skipping");
return;
}
resetSearchState();
localError = null;
updateSearchState(true);
isResetting = false;
isUserEditing = false; // Reset user editing flag when search starts
const query = (queryOverride !== undefined ? queryOverride : searchQuery).trim();
if (!query) {
updateSearchState(false, false, null, null);
return;
}
if (query.toLowerCase().startsWith("d:")) {
const dTag = query.slice(2).trim().toLowerCase();
if (dTag) {
console.log("EventSearch: Processing d-tag search:", dTag);
navigateToSearch(dTag, 'd');
updateSearchState(false, false, null, null);
return;
}
}
if (query.toLowerCase().startsWith("t:")) {
const searchTerm = query.slice(2).trim();
if (searchTerm) {
await handleSearchBySubscription('t', searchTerm);
return;
}
}
if (query.toLowerCase().startsWith("n:")) {
const searchTerm = query.slice(2).trim();
if (searchTerm) {
await handleSearchBySubscription('n', searchTerm);
return;
}
}
if (query.includes('@')) {
await handleNip05Search(query);
return;
}
if (clearInput) {
navigateToSearch(query, 'id');
// Don't clear searchQuery here - let the effect handle it
}
await handleEventSearch(query);
}
// Keep searchQuery in sync with searchValue and dTagValue props
$effect(() => {
if (searchValue && !searching && !isResetting && searchValue !== lastProcessedSearchValue) {
console.log("EventSearch: Processing searchValue:", searchValue);
// Only sync if we're not currently searching, resetting, or if the user is editing
if (searching || isResetting || isUserEditing) {
return;
}
if (dTagValue) {
// If dTagValue is set, show it as "d:tag" in the search bar
searchQuery = `d:${dTagValue}`;
} else if (searchValue) {
// searchValue should already be in the correct format (t:, n:, d:, etc.)
searchQuery = searchValue;
} else if (!searchQuery) {
// Only clear if searchQuery is empty to avoid clearing user input
searchQuery = "";
}
});
// Debounced effect to handle searchValue changes
$effect(() => {
if (!searchValue || searching || isResetting || isProcessingSearch || isWaitingForSearchResult) {
return;
}
// Check if we already have this event displayed
// If we already have the event for this searchValue, do nothing
if (foundEvent) {
const currentEventId = foundEvent.id;
let currentNaddr = null;
let currentNevent = null;
let currentNpub = null;
try {
currentNevent = neventEncode(foundEvent, standardRelays);
} catch (e) {
console.warn("Could not encode nevent for current event:", e);
}
} catch {}
try {
currentNaddr = naddrEncode(foundEvent, standardRelays);
} catch (e) {
console.warn("Could not encode naddr for current event:", e);
}
currentNaddr = getMatchingTags(foundEvent, 'd')[0]?.[1]
? naddrEncode(foundEvent, standardRelays)
: null;
} catch {}
try {
currentNpub = foundEvent.kind === 0 ? toNpub(foundEvent.pubkey) : null;
} catch {}
// If the search value matches any of our current event identifiers, skip the search
if (searchValue === currentEventId || searchValue === currentNaddr || searchValue === currentNevent) {
console.log("EventSearch: Search value matches current event, skipping search");
lastProcessedSearchValue = searchValue;
// Debug log for comparison
console.log('[EventSearch effect] searchValue:', searchValue, 'foundEvent.id:', currentEventId, 'foundEvent.pubkey:', foundEvent.pubkey, 'toNpub(pubkey):', currentNpub, 'foundEvent.kind:', foundEvent.kind, 'currentNaddr:', currentNaddr, 'currentNevent:', currentNevent);
// Also check if searchValue is an nprofile and matches the current event's pubkey
let currentNprofile = null;
if (searchValue && searchValue.startsWith('nprofile1') && foundEvent.kind === 0) {
try {
currentNprofile = nprofileEncode(foundEvent.pubkey, standardRelays);
} catch {}
}
if (
searchValue === currentEventId ||
(currentNaddr && searchValue === currentNaddr) ||
(currentNevent && searchValue === currentNevent) ||
(currentNpub && searchValue === currentNpub) ||
(currentNprofile && searchValue === currentNprofile)
) {
// Already displaying the event for this searchValue
return;
}
}
lastProcessedSearchValue = searchValue;
// Always search when searchValue changes, regardless of foundEvent
// Otherwise, trigger a search for the new value
if (searchTimeout) {
clearTimeout(searchTimeout);
}
searchTimeout = setTimeout(() => {
isProcessingSearch = true;
isWaitingForSearchResult = true;
handleSearchEvent(false, searchValue);
}, 300);
});
// Add debouncing to prevent rapid successive searches
let searchTimeout: ReturnType<typeof setTimeout> | null = null;
// Cleanup function to clear timeout when component is destroyed
$effect(() => {
return () => {
if (searchTimeout) {
clearTimeout(searchTimeout);
}
};
});
// Simple effect to handle dTagValue changes
@ -134,6 +293,9 @@ @@ -134,6 +293,9 @@
localError = null;
lastProcessedSearchValue = null;
lastProcessedDTagValue = null;
isProcessingSearch = false;
currentProcessingSearchValue = null;
lastSearchValue = null;
updateSearchState(false, false, null, null);
// Cancel ongoing search
@ -155,6 +317,12 @@ @@ -155,6 +317,12 @@
// Clear search results
onSearchResults([], [], [], new Set(), new Set());
// Clear any pending timeout
if (searchTimeout) {
clearTimeout(searchTimeout);
searchTimeout = null;
}
// Reset the flag after a short delay to allow effects to settle
setTimeout(() => {
isResetting = false;
@ -187,6 +355,17 @@ @@ -187,6 +355,17 @@
searchResultCount = 1;
searchResultType = 'event';
// Update last processed search value to prevent re-processing
if (searchValue) {
lastProcessedSearchValue = searchValue;
lastSearchValue = searchValue;
}
// Reset processing flag
isProcessingSearch = false;
currentProcessingSearchValue = null;
isWaitingForSearchResult = false;
onEventFound(event);
}
@ -205,15 +384,12 @@ @@ -205,15 +384,12 @@
isResetting = false; // Allow effects to run for new searches
localError = null;
updateSearchState(true);
try {
// Cancel existing search
if (currentAbortController) {
currentAbortController.abort();
}
currentAbortController = new AbortController();
try {
const result = await searchBySubscription(
searchType,
searchTerm,
@ -240,7 +416,6 @@ @@ -240,7 +416,6 @@
},
currentAbortController.signal
);
console.log("EventSearch: Search completed:", result);
onSearchResults(
result.events,
@ -251,10 +426,8 @@ @@ -251,10 +426,8 @@
result.searchType,
result.searchTerm
);
const totalCount = result.events.length + result.secondOrder.length + result.tTagEvents.length;
relayStatuses = {}; // Clear relay statuses when search completes
// Stop any ongoing subscription
if (activeSub) {
try {
@ -264,21 +437,24 @@ @@ -264,21 +437,24 @@
}
activeSub = null;
}
// Abort any ongoing fetch
if (currentAbortController) {
currentAbortController.abort();
currentAbortController = null;
}
updateSearchState(false, true, totalCount, searchType);
isProcessingSearch = false;
currentProcessingSearchValue = null;
isWaitingForSearchResult = false;
} catch (error) {
if (error instanceof Error && error.message === 'Search cancelled') {
isProcessingSearch = false;
currentProcessingSearchValue = null;
isWaitingForSearchResult = false;
return;
}
console.error("EventSearch: Search failed:", error);
localError = error instanceof Error ? error.message : 'Search failed';
// Provide more specific error messages for different failure types
if (error instanceof Error) {
if (error.message.includes('timeout') || error.message.includes('connection')) {
@ -289,9 +465,7 @@ @@ -289,9 +465,7 @@
localError = `Search failed: ${error.message}`;
}
}
relayStatuses = {}; // Clear relay statuses when search fails
// Stop any ongoing subscription
if (activeSub) {
try {
@ -301,187 +475,31 @@ @@ -301,187 +475,31 @@
}
activeSub = null;
}
// Abort any ongoing fetch
if (currentAbortController) {
currentAbortController.abort();
currentAbortController = null;
}
updateSearchState(false, false, null, null);
isProcessingSearch = false;
currentProcessingSearchValue = null;
isWaitingForSearchResult = false;
}
}
async function handleNip05Search(query: string) {
try {
const foundEvent = await searchNip05(query);
if (foundEvent) {
handleFoundEvent(foundEvent);
updateSearchState(false, true, 1, 'nip05');
} else {
relayStatuses = {}; // Clear relay statuses when search completes
// Stop any ongoing subscription
if (activeSub) {
try {
activeSub.stop();
} catch (e) {
console.warn('Error stopping subscription:', e);
}
activeSub = null;
}
// Abort any ongoing fetch
if (currentAbortController) {
currentAbortController.abort();
currentAbortController = null;
}
updateSearchState(false, true, 0, 'nip05');
}
} catch (error) {
localError = error instanceof Error ? error.message : 'NIP-05 lookup failed';
relayStatuses = {}; // Clear relay statuses when search fails
// Stop any ongoing subscription
if (activeSub) {
try {
activeSub.stop();
} catch (e) {
console.warn('Error stopping subscription:', e);
}
activeSub = null;
}
// Abort any ongoing fetch
if (currentAbortController) {
currentAbortController.abort();
currentAbortController = null;
}
updateSearchState(false, false, null, null);
}
}
async function handleEventSearch(query: string) {
try {
const foundEvent = await searchEvent(query);
if (!foundEvent) {
console.warn("[Events] Event not found for query:", query);
localError = "Event not found";
relayStatuses = {}; // Clear relay statuses when search completes
// Stop any ongoing subscription
if (activeSub) {
try {
activeSub.stop();
} catch (e) {
console.warn('Error stopping subscription:', e);
}
activeSub = null;
}
// Abort any ongoing fetch
if (currentAbortController) {
currentAbortController.abort();
currentAbortController = null;
}
updateSearchState(false, false, null, null);
} else {
console.log("[Events] Event found:", foundEvent);
handleFoundEvent(foundEvent);
updateSearchState(false, true, 1, 'event');
}
} catch (err) {
console.error("[Events] Error fetching event:", err, "Query:", query);
localError = "Error fetching event. Please check the ID and try again.";
relayStatuses = {}; // Clear relay statuses when search fails
// Stop any ongoing subscription
if (activeSub) {
try {
activeSub.stop();
} catch (e) {
console.warn('Error stopping subscription:', e);
}
activeSub = null;
}
// Abort any ongoing fetch
if (currentAbortController) {
currentAbortController.abort();
currentAbortController = null;
}
updateSearchState(false, false, null, null);
}
}
async function handleSearchEvent(clearInput: boolean = true, queryOverride?: string) {
// Prevent multiple simultaneous searches
if (searching) {
console.log("EventSearch: Already searching, skipping");
return;
}
resetSearchState();
localError = null;
updateSearchState(true);
isResetting = false; // Allow effects to run for new searches
const query = (queryOverride !== undefined ? queryOverride : searchQuery).trim();
if (!query) {
updateSearchState(false, false, null, null);
return;
}
// Handle different search types
if (query.toLowerCase().startsWith("d:")) {
const dTag = query.slice(2).trim().toLowerCase();
if (dTag) {
console.log("EventSearch: Processing d-tag search:", dTag);
navigateToSearch(dTag, 'd');
updateSearchState(false, false, null, null);
return;
}
}
if (query.toLowerCase().startsWith("t:")) {
const searchTerm = query.slice(2).trim();
if (searchTerm) {
await handleSearchBySubscription('t', searchTerm);
return;
}
}
if (query.toLowerCase().startsWith("n:")) {
const searchTerm = query.slice(2).trim();
if (searchTerm) {
await handleSearchBySubscription('n', searchTerm);
return;
}
}
if (query.includes('@')) {
await handleNip05Search(query);
return;
}
// Handle regular event search
if (clearInput) {
navigateToSearch(query, 'id');
searchQuery = "";
}
await handleEventSearch(query);
}
function handleClear() {
isResetting = true;
searchQuery = '';
isUserEditing = false; // Reset user editing flag
resetSearchState();
// Clear URL parameters to reset the page
goto('', {
replaceState: true,
keepFocus: true,
noScroll: true,
});
// Ensure all search state is cleared
searching = false;
searchCompleted = false;
@ -490,6 +508,16 @@ @@ -490,6 +508,16 @@
foundEvent = null;
relayStatuses = {};
localError = null;
isProcessingSearch = false;
currentProcessingSearchValue = null;
lastSearchValue = null;
isWaitingForSearchResult = false;
// Clear any pending timeout
if (searchTimeout) {
clearTimeout(searchTimeout);
searchTimeout = null;
}
if (onClear) {
onClear();
@ -524,6 +552,8 @@ @@ -524,6 +552,8 @@
placeholder="Enter event ID, nevent, naddr, d:tag-name, t:topic, or n:username..."
class="flex-grow"
onkeydown={(e: KeyboardEvent) => e.key === "Enter" && handleSearchEvent(true)}
oninput={() => isUserEditing = true}
onblur={() => isUserEditing = false}
/>
<Button onclick={() => handleSearchEvent(true)} disabled={loading}>
{#if searching}
@ -545,19 +575,6 @@ @@ -545,19 +575,6 @@
{#if showError}
<div class="p-4 mb-4 text-sm text-red-700 bg-red-100 rounded-lg" role="alert">
{localError || error}
{#if searchQuery.trim()}
<div class="mt-2">
You can also try viewing this event on
<a
class="underline text-primary-700"
href={"https://njump.me/" + encodeURIComponent(searchQuery.trim())}
target="_blank"
rel="noopener"
>
Njump
</a>.
</div>
{/if}
</div>
{/if}

40
src/lib/components/PublicationFeed.svelte

@ -2,7 +2,7 @@ @@ -2,7 +2,7 @@
import { indexKind } from "$lib/consts";
import { ndkInstance } from "$lib/ndk";
import { filterValidIndexEvents, debounce } from "$lib/utils";
import { Button, P, Skeleton, Spinner } from "flowbite-svelte";
import { Button, P, Skeleton, Spinner, Checkbox } from "flowbite-svelte";
import ArticleHeader from "./PublicationHeader.svelte";
import { onMount } from "svelte";
import {
@ -13,17 +13,18 @@ @@ -13,17 +13,18 @@
} 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,
fallbackRelays,
searchQuery = "",
userRelays = [],
} = $props<{
relays: string[];
fallbackRelays: string[];
searchQuery?: string;
userRelays?: string[];
}>();
let eventsInView: NDKEvent[] = $state([]);
@ -44,11 +45,14 @@ @@ -44,11 +45,14 @@
async function fetchAllIndexEventsFromRelays() {
loading = true;
const ndk = $ndkInstance;
const primaryRelays: string[] = relays;
const communityRelays: string[] = relays;
const userRelayList: string[] = userRelays || [];
const fallback: string[] = fallbackRelays.filter(
(r: string) => !primaryRelays.includes(r),
(r: string) => !communityRelays.includes(r) && !userRelayList.includes(r),
);
const allRelays = [...primaryRelays, ...fallback];
const allRelays = includeAllRelays
? [...communityRelays, ...userRelayList, ...fallback]
: [...communityRelays, ...userRelayList];
// Check cache first
const cachedEvents = indexEventCache.get(allRelays);
@ -243,22 +247,18 @@ @@ -243,22 +247,18 @@
return `Index: ${indexStats.size} entries (${indexStats.totalEvents} events), Search: ${searchStats} entries`;
}
// Track previous feed type to avoid infinite loops
let previousFeedType = $state($feedType);
// Include all relays checkbox state
let includeAllRelays = $state(false);
// Watch for changes in feed type and relay configuration
// Watch for changes in include all relays setting
$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)
console.log(`[PublicationFeed] Include all relays setting changed to: ${includeAllRelays}`);
// Clear cache when relay configuration changes
indexEventCache.clear();
searchCache.clear();
// Refetch events with new relay configuration
fetchAllIndexEventsFromRelays();
}
});
onMount(async () => {
@ -266,7 +266,16 @@ @@ -266,7 +266,16 @@
});
</script>
<div class="grid grid-cols-1 sm:grid-cols-2 md:grid-cols-3 lg:grid-cols-4 gap-4 w-full">
<div class="flex flex-col space-y-4">
<!-- Include all relays checkbox -->
<div class="flex items-center justify-center">
<Checkbox bind:checked={includeAllRelays} class="mr-2" />
<label for="include-all-relays" class="text-sm text-gray-700 dark:text-gray-300">
Include all relays (slower but more comprehensive search)
</label>
</div>
<div class="grid grid-cols-1 sm:grid-cols-2 md:grid-cols-3 lg:grid-cols-4 gap-4 w-full">
{#if loading && eventsInView.length === 0}
{#each getSkeletonIds() as id}
<Skeleton divClass="skeleton-leather w-full" size="lg" />
@ -308,3 +317,4 @@ @@ -308,3 +317,4 @@
>
</div>
{/if}
</div>

96
src/lib/components/PublicationHeader.svelte

@ -7,7 +7,7 @@ @@ -7,7 +7,7 @@
import CardActions from "$components/util/CardActions.svelte";
import { userBadge } from "$lib/snippets/UserSnippets.svelte";
import { goto } from '$app/navigation';
import { getUserMetadata, toNpub } from "$lib/utils/nostrUtils";
import { getUserMetadata, toNpub, getMatchingTags } from "$lib/utils/nostrUtils";
const { event } = $props<{ event: NDKEvent }>();
@ -32,14 +32,49 @@ @@ -32,14 +32,49 @@
let authorPubkey: string = $derived(
event.getMatchingTags("p")[0]?.[1] ?? null,
);
let hashtags: string[] = $derived(event.getMatchingTags('t').map((tag: string[]) => tag[1]));
// New: fetch profile display name for authorPubkey
let authorDisplayName = $state<string | undefined>(undefined);
let imageLoaded = $state(false);
let imageError = $state(false);
function isValidNostrPubkey(str: string): boolean {
return /^[a-f0-9]{64}$/i.test(str) || (str.startsWith('npub1') && str.length >= 59 && str.length <= 63);
}
function navigateToHashtagSearch(tag: string): void {
const encoded = encodeURIComponent(tag);
goto(`/events?t=${encoded}`, {
replaceState: false,
keepFocus: true,
noScroll: true,
});
}
function generatePastelColor(eventId: string): string {
// Use the first 6 characters of the event ID to generate a pastel color
const hash = eventId.substring(0, 6);
const r = parseInt(hash.substring(0, 2), 16);
const g = parseInt(hash.substring(2, 4), 16);
const b = parseInt(hash.substring(4, 6), 16);
// Convert to pastel by mixing with white (lightening the color)
const pastelR = Math.round((r + 255) / 2);
const pastelG = Math.round((g + 255) / 2);
const pastelB = Math.round((b + 255) / 2);
return `rgb(${pastelR}, ${pastelG}, ${pastelB})`;
}
function handleImageLoad() {
imageLoaded = true;
}
function handleImageError() {
imageError = true;
}
$effect(() => {
if (authorPubkey) {
getUserMetadata(toNpub(authorPubkey) as string).then((profile) => {
@ -57,21 +92,41 @@ @@ -57,21 +92,41 @@
{#if title != null && href != null}
<Card
class="ArticleBox card-leather max-w-md h-48 flex flex-row items-center space-x-2 relative overflow-hidden"
class="ArticleBox card-leather max-w-md h-64 flex flex-row overflow-hidden"
>
{#if image}
<div class="flex col justify-center align-middle max-h-36 max-w-24 overflow-hidden">
<Img src={image} class="rounded w-full h-full object-cover"/>
<!-- Index author badge over image -->
<div class="absolute top-2 left-2 z-10">
{@render userBadge(event.pubkey, '')}
</div>
<div class="w-24 h-full overflow-hidden flex-shrink-0">
{#if image && !imageError}
<div class="w-full h-full relative">
<!-- Pastel placeholder -->
<div
class="w-full h-full transition-opacity duration-300"
style="background-color: {generatePastelColor(event.id)}; opacity: {imageLoaded ? '0' : '1'}"
></div>
<!-- Image -->
<img
src={image}
class="absolute inset-0 w-full h-full object-cover transition-opacity duration-300"
style="opacity: {imageLoaded ? '1' : '0'}"
onload={handleImageLoad}
onerror={handleImageError}
loading="lazy"
alt="Publication cover"
/>
</div>
{:else}
<!-- Pastel placeholder when no image or image failed to load -->
<div
class="w-full h-full"
style="background-color: {generatePastelColor(event.id)}"
></div>
{/if}
<div class="col flex flex-row flex-grow space-x-4">
<div class="flex flex-col flex-grow">
</div>
<div class="flex flex-col flex-grow p-4 relative">
<div class="absolute top-2 right-2 z-10">
<CardActions {event} />
</div>
<button
class="flex flex-col space-y-2 text-left w-full bg-transparent border-none p-0 hover:underline"
class="flex flex-col space-y-2 text-left w-full bg-transparent border-none p-0 hover:underline pr-8"
onclick={() => goto(`/${href}`)}
>
<h2 class='text-lg font-bold line-clamp-2' title="{title}">{title}</h2>
@ -97,10 +152,21 @@ @@ -97,10 +152,21 @@
</h3>
{/if}
</button>
{#if hashtags.length > 0}
<div class="tags mt-auto pt-2 flex flex-wrap gap-1">
{#each hashtags as tag (tag)}
<button
class="text-xs text-primary-600 dark:text-primary-500 hover:text-primary-800 dark:hover:text-primary-300 hover:underline cursor-pointer"
onclick={(e: MouseEvent) => {
e.stopPropagation();
navigateToHashtagSearch(tag);
}}
>
#{tag}
</button>
{/each}
</div>
<div class="flex flex-col justify-start items-center">
<CardActions {event} />
</div>
{/if}
</div>
</Card>
{/if}

49
src/lib/components/PublicationSection.svelte

@ -10,6 +10,7 @@ @@ -10,6 +10,7 @@
import type { Asciidoctor, Document } from "asciidoctor";
import { getMatchingTags } from "$lib/utils/nostrUtils";
import { postProcessAdvancedAsciidoctorHtml } from "$lib/utils/markup/advancedAsciidoctorPostProcessor";
import { goto } from '$app/navigation';
let {
address,
@ -23,11 +24,20 @@ @@ -23,11 +24,20 @@
ref: (ref: HTMLElement) => void;
} = $props();
console.debug(`[PublicationSection] Received address: ${address}`);
console.debug(`[PublicationSection] Root address: ${rootAddress}`);
console.debug(`[PublicationSection] Leaves count: ${leaves.length}`);
const publicationTree: PublicationTree = getContext("publicationTree");
const asciidoctor: Asciidoctor = getContext("asciidoctor");
let leafEvent: Promise<NDKEvent | null> = $derived.by(
async () => await publicationTree.getEvent(address),
async () => {
console.debug(`[PublicationSection] Getting event for address: ${address}`);
const event = await publicationTree.getEvent(address);
console.debug(`[PublicationSection] Retrieved event: ${event?.id}`);
return event;
},
);
let rootEvent: Promise<NDKEvent | null> = $derived.by(
@ -52,6 +62,10 @@ @@ -52,6 +62,10 @@
return await postProcessAdvancedAsciidoctorHtml(asciidoctorHtml.toString());
});
let leafHashtags: Promise<string[]> = $derived.by(
async () => (await leafEvent)?.getMatchingTags("t").map((tag: string[]) => tag[1]) ?? [],
);
let previousLeafEvent: NDKEvent | null = $derived.by(() => {
let index: number;
let event: NDKEvent | null = null;
@ -117,6 +131,15 @@ @@ -117,6 +131,15 @@
let sectionRef: HTMLElement;
function navigateToHashtagSearch(tag: string): void {
const encoded = encodeURIComponent(tag);
goto(`/events?t=${encoded}`, {
replaceState: false,
keepFocus: true,
noScroll: true,
});
}
$effect(() => {
if (!sectionRef) {
return;
@ -125,6 +148,8 @@ @@ -125,6 +148,8 @@
ref(sectionRef);
});
</script>
<section
@ -132,10 +157,13 @@ @@ -132,10 +157,13 @@
bind:this={sectionRef}
class="publication-leather content-visibility-auto"
>
{#await Promise.all( [leafTitle, leafContent, leafHierarchy, publicationType, divergingBranches], )}
{#await Promise.all( [leafTitle, leafContent, leafHierarchy, publicationType, divergingBranches, leafEvent, leafHashtags], )}
<TextPlaceholder size="xxl" />
{:then [leafTitle, leafContent, leafHierarchy, publicationType, divergingBranches]}
{:then [leafTitle, leafContent, leafHierarchy, publicationType, divergingBranches, resolvedLeafEvent, hashtags]}
{@const contentString = leafContent.toString()}
{#each divergingBranches as [branch, depth]}
{@render sectionHeading(
getMatchingTags(branch, "title")[0]?.[1] ?? "",
@ -151,5 +179,20 @@ @@ -151,5 +179,20 @@
publicationType ?? "article",
false,
)}
{#if hashtags.length > 0}
<div class="tags my-2 flex flex-wrap gap-1">
{#each hashtags as tag (tag)}
<button
class="text-sm text-primary-600 dark:text-primary-500 hover:text-primary-800 dark:hover:text-primary-300 hover:underline cursor-pointer"
onclick={(e: MouseEvent) => {
e.stopPropagation();
navigateToHashtagSearch(tag);
}}
>
#{tag}
</button>
{/each}
</div>
{/if}
{/await}
</section>

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

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

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

@ -156,12 +156,12 @@ @@ -156,12 +156,12 @@
<dt class="font-semibold min-w-[120px]">{id.label}:</dt>
<dd class="break-all">
{#if id.link}
<Button
class="text-primary-700 dark:text-primary-200"
<button
class="text-xs text-gray-600 dark:text-gray-400 hover:text-gray-800 dark:hover:text-gray-200 underline hover:no-underline transition-colors"
onclick={() => navigateToIdentifier(id.link)}
>
{id.value}
</Button>
</button>
{:else}
{id.value}
{/if}

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

@ -0,0 +1,105 @@ @@ -0,0 +1,105 @@
<script lang="ts">
import { Button } from "flowbite-svelte";
import { goto } from "$app/navigation";
import type { NDKEvent } from "$lib/utils/nostrUtils";
import { findContainingIndexEvents } from "$lib/utils/event_search";
import { getMatchingTags } from "$lib/utils/nostrUtils";
import { naddrEncode } from "$lib/utils";
import { standardRelays } from "$lib/consts";
let { event } = $props<{
event: NDKEvent;
}>();
let containingIndexes = $state<NDKEvent[]>([]);
let loading = $state(false);
let error = $state<string | null>(null);
let lastEventId = $state<string | null>(null);
async function loadContainingIndexes() {
console.log("[ContainingIndexes] Loading containing indexes for event:", event.id);
loading = true;
error = null;
try {
containingIndexes = await findContainingIndexEvents(event);
console.log("[ContainingIndexes] Found containing indexes:", containingIndexes.length);
} catch (err) {
error =
err instanceof Error
? err.message
: "Failed to load containing indexes";
console.error(
"[ContainingIndexes] Error loading containing indexes:",
err,
);
} finally {
loading = false;
}
}
function navigateToIndex(indexEvent: NDKEvent) {
const dTag = getMatchingTags(indexEvent, "d")[0]?.[1];
if (dTag) {
goto(`/publication?d=${encodeURIComponent(dTag)}`);
} else {
// Fallback to naddr
try {
const naddr = naddrEncode(indexEvent, standardRelays);
goto(`/publication?id=${encodeURIComponent(naddr)}`);
} catch (err) {
console.error("[ContainingIndexes] Error creating naddr:", err);
}
}
}
$effect(() => {
// Only reload if the event ID has actually changed
if (event.id !== lastEventId) {
lastEventId = event.id;
loadContainingIndexes();
}
});
</script>
{#if containingIndexes.length > 0 || loading || error}
<div class="mb-4 p-3 bg-gray-50 dark:bg-gray-800 rounded-lg border">
<h4 class="text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
Containing Publications
</h4>
{#if loading}
<div class="text-sm text-gray-500 dark:text-gray-400">
Loading containing publications...
</div>
{:else if error}
<div class="text-sm text-red-600 dark:text-red-400">
{error}
</div>
{:else if containingIndexes.length > 0}
<div class="max-h-32 overflow-y-auto">
{#each containingIndexes.slice(0, 3) as indexEvent}
{@const title =
getMatchingTags(indexEvent, "title")[0]?.[1] || "Untitled"}
<Button
size="xs"
color="alternative"
class="mb-1 mr-1 text-xs"
onclick={() => navigateToIndex(indexEvent)}
>
{title}
</Button>
{/each}
{#if containingIndexes.length > 3}
<span class="text-xs text-gray-500 dark:text-gray-400">
+{containingIndexes.length - 3} more
</span>
{/if}
</div>
{:else}
<div class="text-sm text-gray-500 dark:text-gray-400">
No containing publications found
</div>
{/if}
</div>
{/if}

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

@ -4,6 +4,7 @@ @@ -4,6 +4,7 @@
import Interactions from "$components/util/Interactions.svelte";
import { P } from "flowbite-svelte";
import { getMatchingTags } from "$lib/utils/nostrUtils";
import { goto } from '$app/navigation';
// isModal
// - don't show interactions in modal view
@ -93,7 +94,11 @@ @@ -93,7 +94,11 @@
{#if hashtags.length}
<div class="tags my-2">
{#each hashtags as tag}
<span class="text-sm">#{tag}</span>
<button
onclick={() => goto(`/events?t=${encodeURIComponent(tag)}`)}
class="text-sm hover:text-primary-700 dark:hover:text-primary-300 cursor-pointer"
>#{tag}</button
>
{/each}
</div>
{/if}

2
src/lib/consts.ts

@ -2,7 +2,7 @@ export const wikiKind = 30818; @@ -2,7 +2,7 @@ export const wikiKind = 30818;
export const indexKind = 30040;
export const zettelKinds = [30041, 30818];
export const communityRelay = "wss://theforest.nostr1.com";
export const profileRelay = "wss://profiles.nostr1.com";
export const profileRelays = ["wss://profiles.nostr1.com", "wss://aggr.nostr.land", "wss://relay.noswhere.com"];
export const standardRelays = [
"wss://thecitadel.nostr1.com",
"wss://theforest.nostr1.com",

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

@ -163,13 +163,19 @@ export function initializeGraphState(events: NDKEvent[]): GraphState { @@ -163,13 +163,19 @@ export function initializeGraphState(events: NDKEvent[]): GraphState {
// Build set of referenced event IDs to identify root events
const referencedIds = new Set<string>();
events.forEach((event) => {
const aTags = getMatchingTags(event, "a");
debug("Processing a-tags for event", {
// Handle both "a" tags (NIP-62) and "e" tags (legacy)
let tags = getMatchingTags(event, "a");
if (tags.length === 0) {
tags = getMatchingTags(event, "e");
}
debug("Processing tags for event", {
eventId: event.id,
aTagCount: aTags.length,
tagCount: tags.length,
tagType: tags.length > 0 ? (getMatchingTags(event, "a").length > 0 ? "a" : "e") : "none"
});
aTags.forEach((tag) => {
tags.forEach((tag) => {
const id = extractEventIdFromATag(tag);
if (id) referencedIds.add(id);
});
@ -284,7 +290,13 @@ export function processIndexEvent( @@ -284,7 +290,13 @@ export function processIndexEvent(
if (level >= maxLevel) return;
// Extract the sequence of nodes referenced by this index
const sequence = getMatchingTags(indexEvent, "a")
// Handle both "a" tags (NIP-62) and "e" tags (legacy)
let tags = getMatchingTags(indexEvent, "a");
if (tags.length === 0) {
tags = getMatchingTags(indexEvent, "e");
}
const sequence = tags
.map((tag) => extractEventIdFromATag(tag))
.filter((id): id is string => id !== null)
.map((id) => state.nodeMap.get(id))

30
src/lib/ndk.ts

@ -429,9 +429,22 @@ function createRelayWithAuth(url: string, ndk: NDK): NDKRelay { @@ -429,9 +429,22 @@ function createRelayWithAuth(url: string, ndk: NDK): NDKRelay {
export function getActiveRelays(ndk: NDK): NDKRelaySet {
const user = get(userStore);
// Filter out problematic relays that are known to cause connection issues
const filterProblematicRelays = (relays: string[]) => {
return relays.filter(relay => {
// Filter out gitcitadel.nostr1.com which is causing connection issues
if (relay.includes('gitcitadel.nostr1.com')) {
console.warn(`[NDK.ts] Filtering out problematic relay: ${relay}`);
return false;
}
return true;
});
};
return get(feedType) === FeedType.UserRelays && user.signedIn
? new NDKRelaySet(
new Set(user.relays.inbox.map(relay => new NDKRelay(
new Set(filterProblematicRelays(user.relays.inbox).map(relay => new NDKRelay(
relay,
NDKRelayAuthPolicies.signIn({ ndk }),
ndk,
@ -439,7 +452,7 @@ export function getActiveRelays(ndk: NDK): NDKRelaySet { @@ -439,7 +452,7 @@ export function getActiveRelays(ndk: NDK): NDKRelaySet {
ndk
)
: new NDKRelaySet(
new Set(standardRelays.map(relay => new NDKRelay(
new Set(filterProblematicRelays(standardRelays).map(relay => new NDKRelay(
relay,
NDKRelayAuthPolicies.signIn({ ndk }),
ndk,
@ -589,15 +602,27 @@ export async function getUserPreferredRelays( @@ -589,15 +602,27 @@ export async function getUserPreferredRelays(
const inboxRelays = new Set<NDKRelay>();
const outboxRelays = new Set<NDKRelay>();
// Filter out problematic relays
const filterProblematicRelay = (url: string): boolean => {
if (url.includes('gitcitadel.nostr1.com')) {
console.warn(`[NDK.ts] Filtering out problematic relay from user preferences: ${url}`);
return false;
}
return true;
};
if (relayList == null) {
const relayMap = await window.nostr?.getRelays?.();
Object.entries(relayMap ?? {}).forEach(([url, relayType]) => {
if (filterProblematicRelay(url)) {
const relay = createRelayWithAuth(url, ndk);
if (relayType.read) inboxRelays.add(relay);
if (relayType.write) outboxRelays.add(relay);
}
});
} else {
relayList.tags.forEach((tag) => {
if (filterProblematicRelay(tag[1])) {
switch (tag[0]) {
case "r":
inboxRelays.add(createRelayWithAuth(tag[1], ndk));
@ -610,6 +635,7 @@ export async function getUserPreferredRelays( @@ -610,6 +635,7 @@ export async function getUserPreferredRelays(
outboxRelays.add(createRelayWithAuth(tag[1], ndk));
break;
}
}
});
}

27
src/lib/utils/community_checker.ts

@ -55,9 +55,30 @@ export async function checkCommunity(pubkey: string): Promise<boolean> { @@ -55,9 +55,30 @@ export async function checkCommunity(pubkey: string): Promise<boolean> {
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);
// Run all community checks in parallel with timeout
const checkPromises = profiles.map(async (profile) => {
if (!profile.pubkey) return { pubkey: '', status: false };
try {
const status = await Promise.race([
checkCommunity(profile.pubkey),
new Promise<boolean>((resolve) => {
setTimeout(() => resolve(false), 2000); // 2 second timeout per check
})
]);
return { pubkey: profile.pubkey, status };
} catch (error) {
console.warn('Community status check failed for', profile.pubkey, error);
return { pubkey: profile.pubkey, status: false };
}
});
// Wait for all checks to complete
const results = await Promise.allSettled(checkPromises);
for (const result of results) {
if (result.status === 'fulfilled' && result.value.pubkey) {
communityStatus[result.value.pubkey] = result.value.status;
}
}

105
src/lib/utils/event_search.ts

@ -1,10 +1,10 @@ @@ -1,10 +1,10 @@
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';
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
@ -15,7 +15,9 @@ export async function searchEvent(query: string): Promise<NDKEvent | null> { @@ -15,7 +15,9 @@ export async function searchEvent(query: string): Promise<NDKEvent | null> {
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)) {
if (
new RegExp(`^[a-f0-9]{${VALIDATION.HEX_LENGTH}}$`, "i").test(cleanedQuery)
) {
// Try as event id
filterOrId = cleanedQuery;
const eventResult = await fetchEventWithFallback(
@ -40,7 +42,10 @@ export async function searchEvent(query: string): Promise<NDKEvent | null> { @@ -40,7 +42,10 @@ export async function searchEvent(query: string): Promise<NDKEvent | null> {
return eventResult;
}
} else if (
new RegExp(`^(nevent|note|naddr|npub|nprofile)[a-z0-9]{${VALIDATION.MIN_NOSTR_IDENTIFIER_LENGTH},}$`, 'i').test(cleanedQuery)
new RegExp(
`^(nevent|note|naddr|npub|nprofile)[a-z0-9]{${VALIDATION.MIN_NOSTR_IDENTIFIER_LENGTH},}$`,
"i",
).test(cleanedQuery)
) {
try {
const decoded = nip19.decode(cleanedQuery);
@ -102,7 +107,9 @@ export async function searchEvent(query: string): Promise<NDKEvent | null> { @@ -102,7 +107,9 @@ export async function searchEvent(query: string): Promise<NDKEvent | null> {
/**
* Search for NIP-05 address
*/
export async function searchNip05(nip05Address: string): Promise<NDKEvent | null> {
export async function searchNip05(
nip05Address: string,
): Promise<NDKEvent | null> {
// NIP-05 address pattern: user@domain
if (!isValidNip05Address(nip05Address)) {
throw new Error("Invalid NIP-05 address format. Expected: user@domain");
@ -130,14 +137,88 @@ export async function searchNip05(nip05Address: string): Promise<NDKEvent | null @@ -130,14 +137,88 @@ export async function searchNip05(nip05Address: string): Promise<NDKEvent | null
if (profileEvent) {
return profileEvent;
} else {
throw new Error(`No profile found for ${name}@${domain} (pubkey: ${pubkey})`);
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);
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}`);
}
}
/**
* Find containing 30040 index events for a given content event
* @param contentEvent The content event to find containers for (30041, 30818, etc.)
* @returns Array of containing 30040 index events
*/
export async function findContainingIndexEvents(
contentEvent: NDKEvent,
): Promise<NDKEvent[]> {
// Support all content event kinds that can be contained in indexes
const contentEventKinds = [30041, 30818, 30040, 30023];
if (!contentEventKinds.includes(contentEvent.kind)) {
return [];
}
try {
const ndk = get(ndkInstance);
// Search for 30040 events that reference this content event
// We need to search for events that have an 'a' tag or 'e' tag referencing this event
const contentEventId = contentEvent.id;
const contentEventAddress = contentEvent.tagAddress();
// Search for index events that reference this content event
const indexEvents = await ndk.fetchEvents(
{
kinds: [30040],
"#a": [contentEventAddress],
},
{
groupable: true,
skipVerification: false,
skipValidation: false,
},
);
// Also search for events with 'e' tags (legacy format)
const indexEventsWithETags = await ndk.fetchEvents(
{
kinds: [30040],
"#e": [contentEventId],
},
{
groupable: true,
skipVerification: false,
skipValidation: false,
},
);
// Combine and deduplicate results
const allIndexEvents = new Set([...indexEvents, ...indexEventsWithETags]);
// Filter to only include valid index events
const validIndexEvents = Array.from(allIndexEvents).filter((event) => {
// Check if it's a valid index event (has title, d tag, and either a or e tags)
const hasTitle = event.getMatchingTags("title").length > 0;
const hasDTag = event.getMatchingTags("d").length > 0;
const hasATags = event.getMatchingTags("a").length > 0;
const hasETags = event.getMatchingTags("e").length > 0;
return hasTitle && hasDTag && (hasATags || hasETags);
});
return validIndexEvents;
} catch (error) {
console.error("[Search] Error finding containing index events:", error);
return [];
}
}

10
src/lib/utils/nostrEventService.ts

@ -400,8 +400,18 @@ export async function publishEvent( @@ -400,8 +400,18 @@ export async function publishEvent(
* Navigate to the published event
*/
export function navigateToEvent(eventId: string): void {
try {
// Validate that eventId is a valid hex string
if (!/^[0-9a-fA-F]{64}$/.test(eventId)) {
console.warn('Invalid event ID format:', eventId);
return;
}
const nevent = nip19.neventEncode({ id: eventId });
goto(`/events?id=${nevent}`);
} catch (error) {
console.error('Failed to encode event ID for navigation:', eventId, error);
}
}
// Helper functions to ensure relay and pubkey are always strings

57
src/lib/utils/nostrUtils.ts

@ -156,12 +156,26 @@ export async function createProfileLinkWithVerification( @@ -156,12 +156,26 @@ export async function createProfileLinkWithVerification(
const userRelays = Array.from(ndk.pool?.relays.values() || []).map(
(r) => r.url,
);
// Filter out problematic relays
const filterProblematicRelays = (relays: string[]) => {
return relays.filter(relay => {
if (relay.includes('gitcitadel.nostr1.com')) {
console.info(`[nostrUtils.ts] Filtering out problematic relay: ${relay}`);
return false;
}
return true;
});
};
const allRelays = [
...standardRelays,
...userRelays,
...fallbackRelays,
].filter((url, idx, arr) => arr.indexOf(url) === idx);
const relaySet = NDKRelaySetFromNDK.fromRelayUrls(allRelays, ndk);
const filteredRelays = filterProblematicRelays(allRelays);
const relaySet = NDKRelaySetFromNDK.fromRelayUrls(filteredRelays, ndk);
const profileEvent = await ndk.fetchEvent(
{ kinds: [0], authors: [user.pubkey] },
undefined,
@ -272,10 +286,23 @@ export async function getNpubFromNip05(nip05: string): Promise<string | null> { @@ -272,10 +286,23 @@ export async function getNpubFromNip05(nip05: string): Promise<string | null> {
return null;
}
// Fetch the well-known.json file
// Fetch the well-known.json file with timeout and CORS handling
const url = wellKnownUrl(domain, name);
const response = await fetch(url);
const controller = new AbortController();
const timeoutId = setTimeout(() => controller.abort(), 3000); // 3 second timeout
try {
const response = await fetch(url, {
signal: controller.signal,
mode: 'cors',
headers: {
'Accept': 'application/json'
}
});
clearTimeout(timeoutId);
if (!response.ok) {
console.error('[getNpubFromNip05] HTTP error:', response.status, response.statusText);
return null;
@ -283,7 +310,19 @@ export async function getNpubFromNip05(nip05: string): Promise<string | null> { @@ -283,7 +310,19 @@ export async function getNpubFromNip05(nip05: string): Promise<string | null> {
const data = await response.json();
const pubkey = data.names?.[name];
// Try exact match first
let pubkey = data.names?.[name];
// If not found, try case-insensitive search
if (!pubkey && data.names) {
const names = Object.keys(data.names);
const matchingName = names.find(n => n.toLowerCase() === name.toLowerCase());
if (matchingName) {
pubkey = data.names[matchingName];
console.log(`[getNpubFromNip05] Found case-insensitive match: ${name} -> ${matchingName}`);
}
}
if (!pubkey) {
console.error('[getNpubFromNip05] No pubkey found for name:', name);
return null;
@ -292,6 +331,15 @@ export async function getNpubFromNip05(nip05: string): Promise<string | null> { @@ -292,6 +331,15 @@ export async function getNpubFromNip05(nip05: string): Promise<string | null> {
// Convert pubkey to npub
const npub = nip19.npubEncode(pubkey);
return npub;
} catch (fetchError: unknown) {
clearTimeout(timeoutId);
if (fetchError instanceof Error && fetchError.name === 'AbortError') {
console.warn('[getNpubFromNip05] Request timeout for:', url);
} else {
console.warn('[getNpubFromNip05] CORS or network error for:', url);
}
return null;
}
} catch (error) {
console.error("[getNpubFromNip05] Error getting npub from nip05:", error);
return null;
@ -366,6 +414,7 @@ export async function fetchEventWithFallback( @@ -366,6 +414,7 @@ export async function fetchEventWithFallback(
? Array.from(ndk.pool?.relays.values() || [])
.filter((r) => r.status === 1) // Only use connected relays
.map((r) => r.url)
.filter(url => !url.includes('gitcitadel.nostr1.com')) // Filter out problematic relay
: [];
// Determine which relays to use based on user authentication status

255
src/lib/utils/profile_search.ts

@ -2,7 +2,7 @@ import { ndkInstance } from '$lib/ndk'; @@ -2,7 +2,7 @@ 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 { standardRelays, fallbackRelays } from '$lib/consts';
import { get } from 'svelte/store';
import type { NostrProfile, ProfileSearchResult } from './search_types';
import { fieldMatches, nip05Matches, normalizeSearchTerm, COMMON_DOMAINS, createProfileFromEvent } from './search_utils';
@ -13,11 +13,14 @@ import { TIMEOUTS } from './search_constants'; @@ -13,11 +13,14 @@ 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();
const normalizedSearchTerm = normalizeSearchTerm(searchTerm);
console.log('searchProfiles called with:', searchTerm, 'normalized:', normalizedSearchTerm);
// Check cache first
const cachedResult = searchCache.get('profile', normalizedSearchTerm);
if (cachedResult) {
console.log('Found cached result for:', normalizedSearchTerm);
const profiles = cachedResult.events.map(event => {
try {
const profileData = JSON.parse(event.content);
@ -27,24 +30,19 @@ export async function searchProfiles(searchTerm: string): Promise<ProfileSearchR @@ -27,24 +30,19 @@ export async function searchProfiles(searchTerm: string): Promise<ProfileSearchR
}
}).filter(Boolean) as NostrProfile[];
const communityStatus = await checkCommunityStatus(profiles);
return { profiles, Status: communityStatus };
console.log('Cached profiles found:', profiles.length);
return { profiles, Status: {} };
}
const ndk = get(ndkInstance);
if (!ndk) {
console.error('NDK not initialized');
throw new Error('NDK not initialized');
}
let foundProfiles: NostrProfile[] = [];
let timeoutId: ReturnType<typeof setTimeout> | null = null;
console.log('NDK initialized, starting search logic');
// 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);
let foundProfiles: NostrProfile[] = [];
try {
// Check if it's a valid npub/nprofile first
@ -58,9 +56,10 @@ export async function searchProfiles(searchTerm: string): Promise<ProfileSearchR @@ -58,9 +56,10 @@ export async function searchProfiles(searchTerm: string): Promise<ProfileSearchR
console.error('Error fetching metadata for npub:', error);
}
} else if (normalizedSearchTerm.includes('@')) {
// Check if it's a NIP-05 address
// Check if it's a NIP-05 address - normalize it properly
const normalizedNip05 = normalizedSearchTerm.toLowerCase();
try {
const npub = await getNpubFromNip05(normalizedSearchTerm);
const npub = await getNpubFromNip05(normalizedNip05);
if (npub) {
const metadata = await getUserMetadata(npub);
const profile: NostrProfile = {
@ -71,30 +70,21 @@ export async function searchProfiles(searchTerm: string): Promise<ProfileSearchR @@ -71,30 +70,21 @@ export async function searchProfiles(searchTerm: string): Promise<ProfileSearchR
}
} 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
// Try NIP-05 search first (faster than relay search)
console.log('Starting NIP-05 search for:', normalizedSearchTerm);
foundProfiles = await searchNip05Domains(normalizedSearchTerm, ndk);
console.log('NIP-05 search completed, found:', foundProfiles.length, 'profiles');
// If no NIP-05 results found, search for profiles across relays
// If no NIP-05 results, try quick relay search
if (foundProfiles.length === 0) {
foundProfiles = await searchProfilesAcrossRelays(normalizedSearchTerm, ndk);
console.log('No NIP-05 results, trying quick relay search');
foundProfiles = await quickRelaySearch(normalizedSearchTerm, ndk);
console.log('Quick relay search completed, found:', foundProfiles.length, 'profiles');
}
}
// Wait for search to complete or timeout
await new Promise<void>((resolve) => {
const checkComplete = () => {
if (timeoutId === null || foundProfiles.length > 0) {
resolve();
} else {
setTimeout(checkComplete, 100);
}
};
checkComplete();
});
// Cache the results
if (foundProfiles.length > 0) {
const events = foundProfiles.map(profile => {
@ -116,17 +106,12 @@ export async function searchProfiles(searchTerm: string): Promise<ProfileSearchR @@ -116,17 +106,12 @@ export async function searchProfiles(searchTerm: string): Promise<ProfileSearchR
searchCache.set('profile', normalizedSearchTerm, result);
}
// Check community status for all profiles
const communityStatus = await checkCommunityStatus(foundProfiles);
return { profiles: foundProfiles, Status: communityStatus };
console.log('Search completed, found profiles:', foundProfiles.length);
return { profiles: foundProfiles, Status: {} };
} catch (error) {
console.error('Error searching profiles:', error);
return { profiles: [], Status: {} };
} finally {
if (timeoutId) {
clearTimeout(timeoutId);
}
}
}
@ -134,52 +119,143 @@ export async function searchProfiles(searchTerm: string): Promise<ProfileSearchR @@ -134,52 +119,143 @@ export async function searchProfiles(searchTerm: string): Promise<ProfileSearchR
* Search for NIP-05 addresses across common domains
*/
async function searchNip05Domains(searchTerm: string, ndk: any): Promise<NostrProfile[]> {
const foundProfiles: NostrProfile[] = [];
// Enhanced list of common domains for NIP-05 lookups
// Prioritize gitcitadel.com since we know it has profiles
const commonDomains = [
'gitcitadel.com', // Prioritize this domain
'theforest.nostr1.com',
'nostr1.com',
'nostr.land',
'sovbit.host',
'damus.io',
'snort.social',
'iris.to',
'coracle.social',
'nostr.band',
'nostr.wine',
'purplepag.es',
'relay.noswhere.com',
'aggr.nostr.land',
'nostr.sovbit.host',
'freelay.sovbit.host',
'nostr21.com',
'greensoul.space',
'relay.damus.io',
'relay.nostr.band'
];
// Normalize the search term for NIP-05 lookup
const normalizedSearchTerm = searchTerm.toLowerCase().trim();
console.log('NIP-05 search: normalized search term:', normalizedSearchTerm);
// Try gitcitadel.com first with extra debugging
const gitcitadelAddress = `${normalizedSearchTerm}@gitcitadel.com`;
console.log('NIP-05 search: trying gitcitadel.com first:', gitcitadelAddress);
try {
for (const domain of COMMON_DOMAINS) {
const nip05Address = `${searchTerm}@${domain}`;
const npub = await getNpubFromNip05(gitcitadelAddress);
if (npub) {
console.log('NIP-05 search: SUCCESS! found npub for gitcitadel.com:', npub);
const metadata = await getUserMetadata(npub);
const profile: NostrProfile = {
...metadata,
pubkey: npub
};
console.log('NIP-05 search: created profile for gitcitadel.com:', profile);
foundProfiles.push(profile);
return foundProfiles; // Return immediately if we found it on gitcitadel.com
} else {
console.log('NIP-05 search: no npub found for gitcitadel.com');
}
} catch (e) {
console.log('NIP-05 search: error for gitcitadel.com:', e);
}
// If gitcitadel.com didn't work, try other domains
console.log('NIP-05 search: gitcitadel.com failed, trying other domains...');
const otherDomains = commonDomains.filter(domain => domain !== 'gitcitadel.com');
// Search all other domains in parallel with timeout
const searchPromises = otherDomains.map(async (domain) => {
const nip05Address = `${normalizedSearchTerm}@${domain}`;
console.log('NIP-05 search: trying address:', nip05Address);
try {
const npub = await getNpubFromNip05(nip05Address);
if (npub) {
console.log('NIP-05 search: found npub for', nip05Address, ':', npub);
const metadata = await getUserMetadata(npub);
const profile: NostrProfile = {
...metadata,
pubkey: npub
};
return [profile];
console.log('NIP-05 search: created profile for', nip05Address, ':', profile);
return profile;
} else {
console.log('NIP-05 search: no npub found for', nip05Address);
}
} catch (e) {
console.log('NIP-05 search: error for', nip05Address, ':', e);
// Continue to next domain
}
return null;
});
// Wait for all searches with timeout
const results = await Promise.allSettled(searchPromises);
for (const result of results) {
if (result.status === 'fulfilled' && result.value) {
foundProfiles.push(result.value);
}
} catch (e) {
console.error('[Search] NIP-05 domain search failed:', e);
}
return [];
console.log('NIP-05 search: total profiles found:', foundProfiles.length);
return foundProfiles;
}
/**
* Search for profiles across relays
* Quick relay search with short timeout
*/
async function searchProfilesAcrossRelays(searchTerm: string, ndk: any): Promise<NostrProfile[]> {
async function quickRelaySearch(searchTerm: string, ndk: any): Promise<NostrProfile[]> {
console.log('quickRelaySearch called with:', searchTerm);
const foundProfiles: NostrProfile[] = [];
// 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);
// Normalize the search term for relay search
const normalizedSearchTerm = normalizeSearchTerm(searchTerm);
console.log('Normalized search term for relay search:', normalizedSearchTerm);
// Use all profile relays for better coverage
const quickRelayUrls = [...standardRelays, ...fallbackRelays]; // Use all available relays
console.log('Using all relays for search:', quickRelayUrls);
// Create relay sets for parallel search
const relaySets = quickRelayUrls.map(url => {
try {
return NDKRelaySet.fromRelayUrls([url], ndk);
} catch (e) {
console.warn(`Failed to create relay set for ${url}:`, e);
return null;
}
}).filter(Boolean);
// Search all relays in parallel with short timeout
const searchPromises = relaySets.map(async (relaySet, index) => {
if (!relaySet) return [];
return new Promise<NostrProfile[]>((resolve) => {
const foundInRelay: NostrProfile[] = [];
let eventCount = 0;
console.log(`Starting search on relay ${index + 1}: ${quickRelayUrls[index]}`);
// Subscribe to profile events
const sub = ndk.subscribe(
{ kinds: [0] },
{ closeOnEose: true },
relaySet
{ closeOnEose: true, relaySet }
);
return new Promise((resolve) => {
sub.on('event', (event: NDKEvent) => {
eventCount++;
try {
if (!event.content) return;
const profileData = JSON.parse(event.content);
@ -189,20 +265,27 @@ async function searchProfilesAcrossRelays(searchTerm: string, ndk: any): Promise @@ -189,20 +265,27 @@ async function searchProfilesAcrossRelays(searchTerm: string, ndk: any): Promise
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);
// Check if any field matches the search term using normalized comparison
const matchesDisplayName = fieldMatches(displayName, normalizedSearchTerm);
const matchesDisplay_name = fieldMatches(display_name, normalizedSearchTerm);
const matchesName = fieldMatches(name, normalizedSearchTerm);
const matchesNip05 = nip05Matches(nip05, normalizedSearchTerm);
const matchesAbout = fieldMatches(about, normalizedSearchTerm);
if (matchesDisplayName || matchesDisplay_name || matchesName || matchesNip05 || matchesAbout) {
console.log(`Found matching profile on relay ${index + 1}:`, {
name: profileData.name,
display_name: profileData.display_name,
nip05: profileData.nip05,
pubkey: event.pubkey,
searchTerm: normalizedSearchTerm
});
const profile = createProfileFromEvent(event, profileData);
// Check if we already have this profile
const existingIndex = foundProfiles.findIndex(p => p.pubkey === event.pubkey);
// Check if we already have this profile in this relay
const existingIndex = foundInRelay.findIndex(p => p.pubkey === event.pubkey);
if (existingIndex === -1) {
foundProfiles.push(profile);
foundInRelay.push(profile);
}
}
} catch (e) {
@ -211,23 +294,35 @@ async function searchProfilesAcrossRelays(searchTerm: string, ndk: any): Promise @@ -211,23 +294,35 @@ async function searchProfilesAcrossRelays(searchTerm: string, ndk: any): Promise
});
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 };
console.log(`Relay ${index + 1} (${quickRelayUrls[index]}) search completed, processed ${eventCount} events, found ${foundInRelay.length} matches`);
resolve(foundInRelay);
});
// Short timeout for quick search
setTimeout(() => {
console.log(`Relay ${index + 1} (${quickRelayUrls[index]}) search timed out after 1.5s, processed ${eventCount} events, found ${foundInRelay.length} matches`);
sub.stop();
resolve(foundInRelay);
}, 1500); // 1.5 second timeout per relay
});
});
// Wait for all searches to complete
const results = await Promise.allSettled(searchPromises);
// Combine and deduplicate results
const allProfiles: Record<string, NostrProfile> = {};
for (const result of results) {
if (result.status === 'fulfilled') {
for (const profile of result.value) {
if (profile.pubkey) {
allProfiles[profile.pubkey] = profile;
}
}
}
const dedupedProfiles = Object.values(deduped).map(x => x.profile);
resolve(dedupedProfiles);
} else {
resolve([]);
}
});
});
console.log(`Total unique profiles found: ${Object.keys(allProfiles).length}`);
return Object.values(allProfiles);
}

5
src/lib/utils/search_constants.ts

@ -14,7 +14,10 @@ export const TIMEOUTS = { @@ -14,7 +14,10 @@ export const TIMEOUTS = {
PROFILE_SEARCH: 15000,
/** Timeout for subscription search operations */
SUBSCRIPTION_SEARCH: 30000,
SUBSCRIPTION_SEARCH: 10000,
/** Timeout for second-order search operations */
SECOND_ORDER_SEARCH: 5000,
/** Timeout for relay diagnostics */
RELAY_DIAGNOSTICS: 5000,

91
src/lib/utils/subscription_search.ts

@ -3,7 +3,7 @@ import { getMatchingTags, getNpubFromNip05 } from '$lib/utils/nostrUtils'; @@ -3,7 +3,7 @@ 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 { communityRelay, profileRelays } from '$lib/consts';
import { get } from 'svelte/store';
import type { SearchResult, SearchSubscriptionType, SearchFilter, SearchCallbacks, SecondOrderSearchParams } from './search_types';
import { fieldMatches, nip05Matches, normalizeSearchTerm, COMMON_DOMAINS, isEmojiReaction } from './search_utils';
@ -209,17 +209,17 @@ async function createProfileSearchFilter(normalizedSearchTerm: string): Promise< @@ -209,17 +209,17 @@ async function createProfileSearchFilter(normalizedSearchTerm: string): Promise<
*/
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 + '/'
// For profile searches, use profile relays first
const profileRelaySet = Array.from(ndk.pool.relays.values()).filter((relay: any) =>
profileRelays.some(profileRelay => relay.url === profileRelay || relay.url === profileRelay + '/')
);
return new NDKRelaySet(new Set(profileRelays) as any, ndk);
return new NDKRelaySet(new Set(profileRelaySet) as any, ndk);
} else {
// For other searches, use community relay first
const communityRelays = Array.from(ndk.pool.relays.values()).filter((relay: any) =>
const communityRelaySet = Array.from(ndk.pool.relays.values()).filter((relay: any) =>
relay.url === communityRelay || relay.url === communityRelay + '/'
);
return new NDKRelaySet(new Set(communityRelays) as any, ndk);
return new NDKRelaySet(new Set(communityRelaySet) as any, ndk);
}
}
@ -308,8 +308,13 @@ function processContentEvent(event: NDKEvent, searchType: SearchSubscriptionType @@ -308,8 +308,13 @@ function processContentEvent(event: NDKEvent, searchType: SearchSubscriptionType
if (event.id) {
searchState.eventIds.add(event.id);
}
const aTags = getMatchingTags(event, "a");
aTags.forEach((tag: string[]) => {
// Handle both "a" tags (NIP-62) and "e" tags (legacy)
let tags = getMatchingTags(event, "a");
if (tags.length === 0) {
tags = getMatchingTags(event, "e");
}
tags.forEach((tag: string[]) => {
if (tag[1]) {
searchState.eventAddresses.add(tag[1]);
}
@ -338,9 +343,9 @@ function hasResults(searchState: any, searchType: SearchSubscriptionType): boole @@ -338,9 +343,9 @@ function hasResults(searchState: any, searchType: SearchSubscriptionType): boole
*/
function createSearchResult(searchState: any, searchType: SearchSubscriptionType, normalizedSearchTerm: string): SearchResult {
return {
events: searchType === 'n' ? searchState.foundProfiles : searchState.firstOrderEvents,
events: searchType === 'n' ? searchState.foundProfiles : searchType === 't' ? searchState.tTagEvents : searchState.firstOrderEvents,
secondOrder: [],
tTagEvents: searchType === 't' ? searchState.tTagEvents : [],
tTagEvents: [],
eventIds: searchState.eventIds,
addresses: searchState.eventAddresses,
searchType: searchType,
@ -364,8 +369,8 @@ async function searchOtherRelaysInBackground( @@ -364,8 +369,8 @@ async function searchOtherRelaysInBackground(
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 + '/';
// For profile searches, exclude profile relays from fallback search
return !profileRelays.some(profileRelay => relay.url === profileRelay || relay.url === profileRelay + '/');
} else {
// For other searches, exclude community relay from fallback search
return relay.url !== communityRelay && relay.url !== communityRelay + '/';
@ -525,9 +530,9 @@ function processTTagEoseResults(searchState: any): SearchResult { @@ -525,9 +530,9 @@ function processTTagEoseResults(searchState: any): SearchResult {
}
return {
events: [],
events: searchState.tTagEvents,
secondOrder: [],
tTagEvents: searchState.tTagEvents,
tTagEvents: [],
eventIds: new Set(),
addresses: new Set(),
searchType: 't',
@ -565,49 +570,46 @@ async function performSecondOrderSearchInBackground( @@ -565,49 +570,46 @@ async function performSecondOrderSearchInBackground(
const ndk = get(ndkInstance);
let allSecondOrderEvents: NDKEvent[] = [];
// Set a timeout for second-order search
const timeoutPromise = new Promise((_, reject) => {
setTimeout(() => reject(new Error('Second-order search timeout')), TIMEOUTS.SECOND_ORDER_SEARCH);
});
const searchPromise = (async () => {
if (searchType === 'n' && targetPubkey) {
// Search for events that mention this pubkey via p-tags
const pTagFilter = { "#p": [targetPubkey] };
const pTagFilter = { '#p': [targetPubkey] };
const pTagEvents = await ndk.fetchEvents(
pTagFilter,
{ closeOnEose: true },
new NDKRelaySet(new Set(Array.from(ndk.pool.relays.values())), ndk),
);
// Filter out emoji reactions
const filteredEvents = Array.from(pTagEvents).filter(event => !isEmojiReaction(event));
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,
// Parallel fetch for #e and #a tag events
const relaySet = new NDKRelaySet(new Set(Array.from(ndk.pool.relays.values())), ndk);
const [eTagEvents, aTagEvents] = await Promise.all([
eventIds.size > 0
? ndk.fetchEvents(
{ '#e': Array.from(eventIds) },
{ 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,
relaySet
)
: Promise.resolve([]),
addresses.size > 0
? ndk.fetchEvents(
{ '#a': Array.from(addresses) },
{ closeOnEose: true },
new NDKRelaySet(new Set(Array.from(ndk.pool.relays.values())), ndk),
);
relaySet
)
: Promise.resolve([]),
]);
// Filter out emoji reactions
const filteredETagEvents = Array.from(eTagEvents).filter(event => !isEmojiReaction(event));
const filteredATagEvents = Array.from(aTagEvents).filter(event => !isEmojiReaction(event));
allSecondOrderEvents = [...allSecondOrderEvents, ...filteredATagEvents];
}
allSecondOrderEvents = [...allSecondOrderEvents, ...filteredETagEvents, ...filteredATagEvents];
}
// Deduplicate by event ID
@ -644,7 +646,10 @@ async function performSecondOrderSearchInBackground( @@ -644,7 +646,10 @@ async function performSecondOrderSearchInBackground(
if (callbacks?.onSecondOrderUpdate) {
callbacks.onSecondOrderUpdate(result);
}
})();
// Race between search and timeout
await Promise.race([searchPromise, timeoutPromise]);
} catch (err) {
console.error(`[Search] Error in second-order ${searchType}-tag search:`, err);
}

63
src/routes/+page.svelte

@ -1,37 +1,13 @@ @@ -1,37 +1,13 @@
<script lang="ts">
import {
FeedType,
feedTypeStorageKey,
standardRelays,
fallbackRelays,
} from "$lib/consts";
import { Alert, Button, Dropdown, Radio, Input } from "flowbite-svelte";
import { ChevronDownOutline, HammerSolid } from "flowbite-svelte-icons";
import { Alert, Input } from "flowbite-svelte";
import { HammerSolid } from "flowbite-svelte-icons";
import { userStore } from '$lib/stores/userStore';
import { inboxRelays, ndkSignedIn } from "$lib/ndk";
import PublicationFeed from '$lib/components/PublicationFeed.svelte';
import { feedType } from '$lib/stores';
$effect(() => {
localStorage.setItem(feedTypeStorageKey, $feedType);
});
$effect(() => {
if (!$ndkSignedIn && $feedType !== FeedType.StandardRelays) {
feedType.set(FeedType.StandardRelays);
}
});
const getFeedTypeFriendlyName = (feedType: FeedType): string => {
switch (feedType) {
case FeedType.StandardRelays:
return `Alexandria's Relays`;
case FeedType.UserRelays:
return `Your Relays`;
default:
return "";
}
};
let searchQuery = $state('');
let user = $state($userStore);
@ -52,44 +28,11 @@ @@ -52,44 +28,11 @@
<main class='leather flex flex-col flex-grow-0 space-y-4 p-4'>
<div class='leather w-full flex flex-row items-center justify-center gap-4 mb-4'>
<Button id="feed-toggle-btn" class="min-w-[220px] max-w-sm">
{`Showing publications from: ${getFeedTypeFriendlyName($feedType)}`}
{#if $ndkSignedIn}
<ChevronDownOutline class='w-6 h-6' />
{/if}
</Button>
<Input
bind:value={searchQuery}
placeholder="Search publications by title or author..."
class="flex-grow max-w-2xl min-w-[300px] text-base"
/>
{#if $ndkSignedIn}
<Dropdown
class="w-fit p-2 space-y-2 text-sm"
triggeredBy="#feed-toggle-btn"
>
<li>
<Radio
name="relays"
bind:group={$feedType}
value={FeedType.StandardRelays}>Alexandria's Relays</Radio
>
</li>
<li>
<Radio
name="follows"
bind:group={$feedType}
value={FeedType.UserRelays}>Your Relays</Radio
>
</li>
</Dropdown>
{/if}
</div>
{#if !$ndkSignedIn}
<PublicationFeed relays={standardRelays} {fallbackRelays} {searchQuery} />
{:else if $feedType === FeedType.StandardRelays}
<PublicationFeed relays={standardRelays} {fallbackRelays} {searchQuery} />
{:else if $feedType === FeedType.UserRelays}
<PublicationFeed relays={$inboxRelays} {fallbackRelays} {searchQuery} />
{/if}
<PublicationFeed relays={standardRelays} {fallbackRelays} {searchQuery} userRelays={$ndkSignedIn ? $inboxRelays : []} />
</main>

105
src/routes/events/+page.svelte

@ -83,6 +83,25 @@ @@ -83,6 +83,25 @@
searchValue = url.get('id') ?? url.get('d');
});
// Add support for t and n parameters
$effect(() => {
const url = $page.url.searchParams;
const tParam = url.get('t');
const nParam = url.get('n');
if (tParam) {
// Decode the t parameter and set it as searchValue with t: prefix
const decodedT = decodeURIComponent(tParam);
searchValue = `t:${decodedT}`;
}
if (nParam) {
// Decode the n parameter and set it as searchValue with n: prefix
const decodedN = decodeURIComponent(nParam);
searchValue = `n:${decodedN}`;
}
});
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;
@ -167,9 +186,13 @@ @@ -167,9 +186,13 @@
}
}
// Check if this event has a-tags referencing original events
const aTags = getMatchingTags(event, "a");
for (const tag of aTags) {
// Check if this event has a-tags or e-tags referencing original events
let tags = getMatchingTags(event, "a");
if (tags.length === 0) {
tags = getMatchingTags(event, "e");
}
for (const tag of tags) {
if (originalAddresses.has(tag[1])) {
return "Reply/Reference (a-tag)";
}
@ -261,8 +284,10 @@ @@ -261,8 +284,10 @@
function updateSearchFromURL() {
const id = $page.url.searchParams.get("id");
const dTag = $page.url.searchParams.get("d");
const tParam = $page.url.searchParams.get("t");
const nParam = $page.url.searchParams.get("n");
console.log("Events page URL update:", { id, dTag, searchValue });
console.log("Events page URL update:", { id, dTag, tParam, nParam, searchValue });
if (id !== searchValue) {
console.log("ID changed, updating searchValue:", { old: searchValue, new: id });
@ -287,8 +312,38 @@ @@ -287,8 +312,38 @@
profile = null;
}
// Reset state if both id and dTag are absent
if (!id && !dTag) {
// Handle t parameter
if (tParam) {
const decodedT = decodeURIComponent(tParam);
const tSearchValue = `t:${decodedT}`;
if (tSearchValue !== searchValue) {
console.log("T parameter changed, updating searchValue:", { old: searchValue, new: tSearchValue });
searchValue = tSearchValue;
dTagValue = null;
// For t-tag searches (which return multiple results), close side panel
showSidePanel = false;
event = null;
profile = null;
}
}
// Handle n parameter
if (nParam) {
const decodedN = decodeURIComponent(nParam);
const nSearchValue = `n:${decodedN}`;
if (nSearchValue !== searchValue) {
console.log("N parameter changed, updating searchValue:", { old: searchValue, new: nSearchValue });
searchValue = nSearchValue;
dTagValue = null;
// For n-tag searches (which return multiple results), close side panel
showSidePanel = false;
event = null;
profile = null;
}
}
// Reset state if all parameters are absent
if (!id && !dTag && !tParam && !nParam) {
event = null;
searchResults = [];
profile = null;
@ -304,8 +359,10 @@ @@ -304,8 +359,10 @@
function handleUrlChange() {
const id = $page.url.searchParams.get("id");
const dTag = $page.url.searchParams.get("d");
const tParam = $page.url.searchParams.get("t");
const nParam = $page.url.searchParams.get("n");
console.log("Events page URL change:", { id, dTag, currentSearchValue: searchValue, currentDTagValue: dTagValue });
console.log("Events page URL change:", { id, dTag, tParam, nParam, currentSearchValue: searchValue, currentDTagValue: dTagValue });
// Handle ID parameter changes
if (id !== searchValue) {
@ -329,9 +386,37 @@ @@ -329,9 +386,37 @@
profile = null;
}
// Reset state if both parameters are absent
if (!id && !dTag) {
console.log("Both ID and d-tag parameters absent, resetting state");
// Handle t parameter changes
if (tParam) {
const decodedT = decodeURIComponent(tParam);
const tSearchValue = `t:${decodedT}`;
if (tSearchValue !== searchValue) {
console.log("t parameter changed:", { old: searchValue, new: tSearchValue });
searchValue = tSearchValue;
dTagValue = null;
showSidePanel = false;
event = null;
profile = null;
}
}
// Handle n parameter changes
if (nParam) {
const decodedN = decodeURIComponent(nParam);
const nSearchValue = `n:${decodedN}`;
if (nSearchValue !== searchValue) {
console.log("n parameter changed:", { old: searchValue, new: nSearchValue });
searchValue = nSearchValue;
dTagValue = null;
showSidePanel = false;
event = null;
profile = null;
}
}
// Reset state if all parameters are absent
if (!id && !dTag && !tParam && !nParam) {
console.log("All parameters absent, resetting state");
event = null;
searchResults = [];
profile = null;

1
src/routes/publication/+page.svelte

@ -68,6 +68,7 @@ @@ -68,6 +68,7 @@
{#await data.waitable}
<TextPlaceholder divClass="skeleton-leather w-full" size="xxl" />
{:then}
{@const debugInfo = console.debug(`[Publication Page] Data loaded, rendering Publication component with publicationType: ${data.publicationType}, rootAddress: ${data.indexEvent.tagAddress()}`)}
<Publication
rootAddress={data.indexEvent.tagAddress()}
publicationType={data.publicationType}

11
src/routes/visualize/+page.svelte

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

Loading…
Cancel
Save