Browse Source

Merges pull request #57

Issue#296 Part 2
master
silberengel 7 months ago
parent
commit
16dccb4f27
No known key found for this signature in database
GPG Key ID: 962BEC8725790894
  1. 31
      src/lib/components/CommentBox.svelte
  2. 904
      src/lib/components/CommentViewer.svelte
  3. 16
      src/lib/components/EventDetails.svelte
  4. 5
      src/lib/components/EventInput.svelte
  5. 132
      src/lib/components/EventSearch.svelte
  6. 1186
      src/lib/components/Notifications.svelte
  7. 92
      src/lib/components/RelayInfoDisplay.svelte
  8. 143
      src/lib/components/RelayInfoList.svelte
  9. 7
      src/lib/consts.ts
  10. 88
      src/lib/ndk.ts
  11. 9
      src/lib/utils.ts
  12. 64
      src/lib/utils/event_search.ts
  13. 135
      src/lib/utils/kind24_utils.ts
  14. 45
      src/lib/utils/markup/basicMarkupParser.ts
  15. 10
      src/lib/utils/nostrEventService.ts
  16. 225
      src/lib/utils/notification_utils.ts
  17. 44
      src/lib/utils/npubCache.ts
  18. 27
      src/lib/utils/profile_search.ts
  19. 166
      src/lib/utils/relay_info_service.ts
  20. 7
      src/lib/utils/search_constants.ts
  21. 229
      src/lib/utils/subscription_search.ts
  22. 28
      src/routes/+layout.svelte
  23. 10
      src/routes/contact/+page.svelte
  24. 259
      src/routes/events/+page.svelte

31
src/lib/components/CommentBox.svelte

@ -10,6 +10,7 @@ @@ -10,6 +10,7 @@
} from "$lib/utils/search_utility";
import { userPubkey } from "$lib/stores/authStore.Svelte";
import { userStore } from "$lib/stores/userStore";
import type { NDKEvent } from "$lib/utils/nostrUtils";
import {
extractRootEventInfo,
@ -67,17 +68,12 @@ @@ -67,17 +68,12 @@
}
});
// Get user profile from userStore
$effect(() => {
const trimmedPubkey = $userPubkey?.trim();
const npub = toNpub(trimmedPubkey);
if (npub) {
// Call an async function, but don't make the effect itself async
getUserMetadata(npub).then((metadata) => {
userProfile = metadata;
});
} else if (trimmedPubkey) {
userProfile = null;
error = "Invalid public key: must be a 64-character hex string.";
const currentUser = $userStore;
if (currentUser?.signedIn && currentUser.profile) {
userProfile = currentUser.profile;
error = null;
} else {
userProfile = null;
error = null;
@ -590,17 +586,20 @@ @@ -590,17 +586,20 @@
<img
src={userProfile.picture}
alt={userProfile.name || "Profile"}
class="w-8 h-8 rounded-full"
onerror={(e) => {
const img = e.target as HTMLImageElement;
img.src = `https://api.dicebear.com/7.x/avataaars/svg?seed=${encodeURIComponent(img.alt)}`;
}}
class="w-8 h-8 rounded-full object-cover"
onerror={(e) => (e.target as HTMLImageElement).style.display = 'none'}
/>
{:else}
<div class="w-8 h-8 rounded-full bg-gray-300 dark:bg-gray-600 flex items-center justify-center">
<span class="text-sm font-medium text-gray-600 dark:text-gray-300">
{(userProfile.displayName || userProfile.name || "U").charAt(0).toUpperCase()}
</span>
</div>
{/if}
<span class="text-gray-900 dark:text-gray-100">
{userProfile.displayName ||
userProfile.name ||
nip19.npubEncode($userPubkey || "").slice(0, 8) + "..."}
`${$userPubkey?.slice(0, 8)}...${$userPubkey?.slice(-4)}`}
</span>
</div>
{/if}

904
src/lib/components/CommentViewer.svelte

@ -0,0 +1,904 @@ @@ -0,0 +1,904 @@
<script lang="ts">
import { Button, P, Heading } from "flowbite-svelte";
import { getUserMetadata, toNpub } from "$lib/utils/nostrUtils";
import { neventEncode } from "$lib/utils";
import { activeInboxRelays, ndkInstance } from "$lib/ndk";
import { goto } from "$app/navigation";
import { onMount } from "svelte";
import type { NDKEvent } from "@nostr-dev-kit/ndk";
import { userBadge } from "$lib/snippets/UserSnippets.svelte";
import { parseBasicmarkup } from "$lib/utils/markup/basicMarkupParser";
const { event } = $props<{ event: NDKEvent }>();
// AI-NOTE: 2025-01-08 - Clean, efficient comment viewer implementation
// This component fetches and displays threaded comments with proper hierarchy
// Uses simple, reliable profile fetching and efficient state management
// AI-NOTE: 2025-01-24 - Added support for kind 9802 highlights (NIP-84)
// Highlights are displayed with special styling and include source attribution
// State management
let comments: NDKEvent[] = $state([]);
let loading = $state(false);
let error = $state<string | null>(null);
let profiles = $state(new Map<string, any>());
let activeSub: any = null;
interface CommentNode {
event: NDKEvent;
children: CommentNode[];
level: number;
}
// Simple profile fetching
async function fetchProfile(pubkey: string) {
if (profiles.has(pubkey)) return;
try {
const npub = toNpub(pubkey);
if (!npub) return;
// Force fetch to ensure we get the latest profile data
const profile = await getUserMetadata(npub, true);
const newProfiles = new Map(profiles);
newProfiles.set(pubkey, profile);
profiles = newProfiles;
console.log(`[CommentViewer] Fetched profile for ${pubkey}:`, profile);
} catch (err) {
console.warn(`Failed to fetch profile for ${pubkey}:`, err);
// Set a fallback profile to avoid repeated failed requests
const fallbackProfile = {
name: `${pubkey.slice(0, 8)}...${pubkey.slice(-4)}`,
displayName: `${pubkey.slice(0, 8)}...${pubkey.slice(-4)}`,
picture: null
};
const newProfiles = new Map(profiles);
newProfiles.set(pubkey, fallbackProfile);
profiles = newProfiles;
}
}
// Fetch comments once when component mounts
async function fetchComments() {
if (!event?.id) return;
loading = true;
error = null;
comments = [];
console.log(`[CommentViewer] Fetching comments for event: ${event.id}`);
console.log(`[CommentViewer] Event kind: ${event.kind}`);
console.log(`[CommentViewer] Event pubkey: ${event.pubkey}`);
console.log(`[CommentViewer] Available relays: ${$activeInboxRelays.length}`);
// Wait for relays to be available
let attempts = 0;
while ($activeInboxRelays.length === 0 && attempts < 10) {
await new Promise(resolve => setTimeout(resolve, 500));
attempts++;
}
if ($activeInboxRelays.length === 0) {
error = "No relays available";
loading = false;
return;
}
try {
// Build address for NIP-22 search if this is a replaceable event
let eventAddress: string | null = null;
if (event.kind && event.pubkey) {
const dTag = event.getMatchingTags("d")[0]?.[1];
if (dTag) {
eventAddress = `${event.kind}:${event.pubkey}:${dTag}`;
}
}
console.log(`[CommentViewer] Event address for NIP-22: ${eventAddress}`);
// Use more targeted filters to reduce noise
const filters = [
// Primary filter: events that explicitly reference our target via e-tags
{
kinds: [1, 1111, 9802],
"#e": [event.id],
limit: 50,
}
];
// Add NIP-22 filter only if we have a valid event address
if (eventAddress) {
filters.push({
kinds: [1111, 9802],
"#a": [eventAddress],
limit: 50,
} as any);
}
console.log(`[CommentViewer] Setting up subscription with ${filters.length} filters:`, filters);
// Debug: Check if the provided event would match our filters
console.log(`[CommentViewer] Debug: Checking if event b9a15298f2b203d42ba6d0c56c43def87efc887697460c0febb9542515d5a00b would match our filters`);
console.log(`[CommentViewer] Debug: Target event ID: ${event.id}`);
console.log(`[CommentViewer] Debug: Event address: ${eventAddress}`);
// Get all available relays for a more comprehensive search
// Use the full NDK pool relays instead of just active relays
const ndkPoolRelays = Array.from($ndkInstance.pool.relays.values()).map(relay => relay.url);
console.log(`[CommentViewer] Using ${ndkPoolRelays.length} NDK pool relays for search:`, ndkPoolRelays);
// Try all filters to find comments with full relay set
activeSub = $ndkInstance.subscribe(filters);
// Also try a direct search for the specific comment we're looking for
console.log(`[CommentViewer] Also searching for specific comment: 64173a81c2a8e26342d4a75d3def804da8644377bde99cfdfeaf189dff87f942`);
const specificCommentSub = $ndkInstance.subscribe({
ids: ["64173a81c2a8e26342d4a75d3def804da8644377bde99cfdfeaf189dff87f942"]
});
specificCommentSub.on("event", (specificEvent: NDKEvent) => {
console.log(`[CommentViewer] Found specific comment via direct search:`, specificEvent.id);
console.log(`[CommentViewer] Specific comment tags:`, specificEvent.tags);
// Check if this specific comment references our target
const eTags = specificEvent.getMatchingTags("e");
const aTags = specificEvent.getMatchingTags("a");
console.log(`[CommentViewer] Specific comment e-tags:`, eTags.map(t => t[1]));
console.log(`[CommentViewer] Specific comment a-tags:`, aTags.map(t => t[1]));
const hasETag = eTags.some(tag => tag[1] === event.id);
const hasATag = eventAddress ? aTags.some(tag => tag[1] === eventAddress) : false;
console.log(`[CommentViewer] Specific comment has matching e-tag: ${hasETag}`);
console.log(`[CommentViewer] Specific comment has matching a-tag: ${hasATag}`);
});
specificCommentSub.on("eose", () => {
console.log(`[CommentViewer] Specific comment search EOSE`);
specificCommentSub.stop();
});
const timeout = setTimeout(() => {
console.log(`[CommentViewer] Subscription timeout - no comments found`);
if (activeSub) {
activeSub.stop();
activeSub = null;
}
loading = false;
}, 10000);
activeSub.on("event", (commentEvent: NDKEvent) => {
console.log(`[CommentViewer] Received comment: ${commentEvent.id}`);
console.log(`[CommentViewer] Comment kind: ${commentEvent.kind}`);
console.log(`[CommentViewer] Comment pubkey: ${commentEvent.pubkey}`);
console.log(`[CommentViewer] Comment content preview: ${commentEvent.content?.slice(0, 100)}...`);
// Special debug for the specific comment we're looking for
if (commentEvent.id === "64173a81c2a8e26342d4a75d3def804da8644377bde99cfdfeaf189dff87f942") {
console.log(`[CommentViewer] DEBUG: Found the specific comment we're looking for!`);
console.log(`[CommentViewer] DEBUG: Comment tags:`, commentEvent.tags);
}
// Check if this event actually references our target event
let referencesTarget = false;
let referenceMethod = "";
// Check e-tags (standard format)
const eTags = commentEvent.getMatchingTags("e");
console.log(`[CommentViewer] Checking e-tags:`, eTags.map(t => t[1]));
console.log(`[CommentViewer] Target event ID: ${event.id}`);
const hasETag = eTags.some(tag => tag[1] === event.id);
console.log(`[CommentViewer] Has matching e-tag: ${hasETag}`);
if (hasETag) {
referencesTarget = true;
referenceMethod = "e-tag";
}
// Check a-tags (NIP-22 format) if not found via e-tags
if (!referencesTarget && eventAddress) {
const aTags = commentEvent.getMatchingTags("a");
console.log(`[CommentViewer] Checking a-tags:`, aTags.map(t => t[1]));
console.log(`[CommentViewer] Expected a-tag: ${eventAddress}`);
const hasATag = aTags.some(tag => tag[1] === eventAddress);
console.log(`[CommentViewer] Has matching a-tag: ${hasATag}`);
if (hasATag) {
referencesTarget = true;
referenceMethod = "a-tag";
}
}
if (referencesTarget) {
console.log(`[CommentViewer] Comment references target event via ${referenceMethod} - adding to comments`);
comments = [...comments, commentEvent];
fetchProfile(commentEvent.pubkey);
// Fetch nested replies for this comment
fetchNestedReplies(commentEvent.id);
} else {
console.log(`[CommentViewer] Comment does not reference target event - skipping`);
console.log(`[CommentViewer] e-tags:`, eTags.map(t => t[1]));
if (eventAddress) {
console.log(`[CommentViewer] a-tags:`, commentEvent.getMatchingTags("a").map(t => t[1]));
console.log(`[CommentViewer] expected a-tag:`, eventAddress);
}
}
});
activeSub.on("eose", () => {
console.log(`[CommentViewer] EOSE received, found ${comments.length} comments`);
clearTimeout(timeout);
if (activeSub) {
activeSub.stop();
activeSub = null;
}
loading = false;
// Pre-fetch all profiles after comments are loaded
preFetchAllProfiles();
// AI-NOTE: 2025-01-24 - Fetch nested replies for all found comments
comments.forEach(comment => {
fetchNestedReplies(comment.id);
});
// AI-NOTE: 2025-01-24 - Test for comments if none were found
if (comments.length === 0) {
testForComments();
}
});
activeSub.on("error", (err: any) => {
console.error(`[CommentViewer] Subscription error:`, err);
clearTimeout(timeout);
if (activeSub) {
activeSub.stop();
activeSub = null;
}
error = "Error fetching comments";
loading = false;
});
} catch (err) {
console.error(`[CommentViewer] Error setting up subscription:`, err);
error = "Error setting up subscription";
loading = false;
}
}
// Pre-fetch all profiles for comments
async function preFetchAllProfiles() {
const uniquePubkeys = new Set<string>();
comments.forEach(comment => {
if (comment.pubkey && !profiles.has(comment.pubkey)) {
uniquePubkeys.add(comment.pubkey);
}
});
console.log(`[CommentViewer] Pre-fetching ${uniquePubkeys.size} profiles`);
// Fetch profiles in parallel
const profilePromises = Array.from(uniquePubkeys).map(pubkey => fetchProfile(pubkey));
await Promise.allSettled(profilePromises);
console.log(`[CommentViewer] Pre-fetching complete`);
}
// AI-NOTE: 2025-01-24 - Function to manually test for comments
async function testForComments() {
if (!event?.id) return;
console.log(`[CommentViewer] Testing for comments on event: ${event.id}`);
try {
// Try a broader search to see if there are any events that might be comments
const testSub = $ndkInstance.subscribe({
kinds: [1, 1111, 9802],
"#e": [event.id],
limit: 10,
});
let testComments = 0;
testSub.on("event", (testEvent: NDKEvent) => {
testComments++;
console.log(`[CommentViewer] Test found event: ${testEvent.id}, kind: ${testEvent.kind}, content: ${testEvent.content?.slice(0, 50)}...`);
// Special debug for the specific comment we're looking for
if (testEvent.id === "64173a81c2a8e26342d4a75d3def804da8644377bde99cfdfeaf189dff87f942") {
console.log(`[CommentViewer] DEBUG: Test found the specific comment we're looking for!`);
console.log(`[CommentViewer] DEBUG: Test comment tags:`, testEvent.tags);
}
// Show the e-tags to help debug
const eTags = testEvent.getMatchingTags("e");
console.log(`[CommentViewer] Test event e-tags:`, eTags.map(t => t[1]));
});
testSub.on("eose", () => {
console.log(`[CommentViewer] Test search found ${testComments} potential comments`);
testSub.stop();
});
// Stop the test after 5 seconds
setTimeout(() => {
testSub.stop();
}, 5000);
} catch (err) {
console.error(`[CommentViewer] Test search error:`, err);
}
}
// Build threaded comment structure
function buildCommentThread(events: NDKEvent[]): CommentNode[] {
if (events.length === 0) return [];
const eventMap = new Map<string, NDKEvent>();
const commentMap = new Map<string, CommentNode>();
const rootComments: CommentNode[] = [];
// Create nodes for all events
events.forEach(event => {
eventMap.set(event.id, event);
commentMap.set(event.id, {
event,
children: [],
level: 0
});
});
// Build parent-child relationships
events.forEach(event => {
const node = commentMap.get(event.id);
if (!node) return;
let parentId: string | null = null;
const eTags = event.getMatchingTags("e");
if (event.kind === 1) {
// Kind 1: Look for the last e-tag that references another comment
for (let i = eTags.length - 1; i >= 0; i--) {
const tag = eTags[i];
const referencedId = tag[1];
if (eventMap.has(referencedId) && referencedId !== event.id) {
parentId = referencedId;
break;
}
}
} else if (event.kind === 1111) {
// Kind 1111: Use NIP-22 threading format
// First try to find parent using 'a' tags (NIP-22 parent scope)
const aTags = event.getMatchingTags("a");
for (const tag of aTags) {
const address = tag[1];
// Extract event ID from address if it's a coordinate
const parts = address.split(":");
if (parts.length >= 3) {
const [kind, pubkey, dTag] = parts;
// Look for the parent event with this address
for (const [eventId, parentEvent] of eventMap) {
if (parentEvent.kind === parseInt(kind) &&
parentEvent.pubkey === pubkey &&
parentEvent.getMatchingTags("d")[0]?.[1] === dTag) {
parentId = eventId;
break;
}
}
if (parentId) break;
}
}
// Fallback to 'e' tags if no parent found via 'a' tags
if (!parentId) {
for (const tag of eTags) {
const referencedId = tag[1];
if (eventMap.has(referencedId) && referencedId !== event.id) {
parentId = referencedId;
break;
}
}
}
}
// Add to parent or root
if (parentId && commentMap.has(parentId)) {
const parent = commentMap.get(parentId);
if (parent) {
parent.children.push(node);
node.level = parent.level + 1;
}
} else {
rootComments.push(node);
}
});
// Sort by creation time (newest first)
function sortComments(nodes: CommentNode[]): CommentNode[] {
return nodes.sort((a, b) => (b.event.created_at || 0) - (a.event.created_at || 0));
}
function sortRecursive(nodes: CommentNode[]): CommentNode[] {
const sorted = sortComments(nodes);
sorted.forEach(node => {
node.children = sortRecursive(node.children);
});
return sorted;
}
return sortRecursive(rootComments);
}
// Derived value for threaded comments
let threadedComments = $derived(buildCommentThread(comments));
// Fetch comments when event changes
$effect(() => {
if (event?.id) {
console.log(`[CommentViewer] Event changed, fetching comments for:`, event.id, `kind:`, event.kind);
if (activeSub) {
activeSub.stop();
activeSub = null;
}
fetchComments();
}
});
// AI-NOTE: 2025-01-24 - Add recursive comment fetching for nested replies
let isFetchingNestedReplies = $state(false);
let nestedReplyIds = $state<Set<string>>(new Set());
// Function to fetch nested replies for a given event
async function fetchNestedReplies(eventId: string) {
if (isFetchingNestedReplies || nestedReplyIds.has(eventId)) {
console.log(`[CommentViewer] Skipping nested reply fetch for ${eventId} - already fetching or processed`);
return;
}
console.log(`[CommentViewer] Starting nested reply fetch for event: ${eventId}`);
isFetchingNestedReplies = true;
nestedReplyIds.add(eventId);
try {
console.log(`[CommentViewer] Fetching nested replies for event:`, eventId);
// Search for replies to this specific event
const nestedSub = $ndkInstance.subscribe({
kinds: [1, 1111, 9802],
"#e": [eventId],
limit: 50,
});
let nestedCount = 0;
nestedSub.on("event", (nestedEvent: NDKEvent) => {
console.log(`[CommentViewer] Found nested reply:`, nestedEvent.id, `kind:`, nestedEvent.kind);
// Check if this event actually references the target event
const eTags = nestedEvent.getMatchingTags("e");
const referencesTarget = eTags.some(tag => tag[1] === eventId);
console.log(`[CommentViewer] Nested reply references target:`, referencesTarget, `eTags:`, eTags);
if (referencesTarget && !comments.some(c => c.id === nestedEvent.id)) {
console.log(`[CommentViewer] Adding nested reply to comments`);
comments = [...comments, nestedEvent];
fetchProfile(nestedEvent.pubkey);
// Recursively fetch replies to this nested reply
fetchNestedReplies(nestedEvent.id);
} else if (!referencesTarget) {
console.log(`[CommentViewer] Nested reply does not reference target, skipping`);
} else {
console.log(`[CommentViewer] Nested reply already exists in comments`);
}
});
nestedSub.on("eose", () => {
console.log(`[CommentViewer] Nested replies EOSE, found ${nestedCount} replies`);
nestedSub.stop();
isFetchingNestedReplies = false;
});
// Also search for NIP-22 format nested replies
const event = comments.find(c => c.id === eventId);
if (event && event.kind && event.pubkey) {
const dTag = event.getMatchingTags("d")[0]?.[1];
if (dTag) {
const eventAddress = `${event.kind}:${event.pubkey}:${dTag}`;
const nip22Sub = $ndkInstance.subscribe({
kinds: [1111, 9802],
"#a": [eventAddress],
limit: 50,
});
nip22Sub.on("event", (nip22Event: NDKEvent) => {
console.log(`[CommentViewer] Found NIP-22 nested reply:`, nip22Event.id, `kind:`, nip22Event.kind);
const aTags = nip22Event.getMatchingTags("a");
const referencesTarget = aTags.some(tag => tag[1] === eventAddress);
console.log(`[CommentViewer] NIP-22 nested reply references target:`, referencesTarget, `aTags:`, aTags, `eventAddress:`, eventAddress);
if (referencesTarget && !comments.some(c => c.id === nip22Event.id)) {
console.log(`[CommentViewer] Adding NIP-22 nested reply to comments`);
comments = [...comments, nip22Event];
fetchProfile(nip22Event.pubkey);
// Recursively fetch replies to this nested reply
fetchNestedReplies(nip22Event.id);
} else if (!referencesTarget) {
console.log(`[CommentViewer] NIP-22 nested reply does not reference target, skipping`);
} else {
console.log(`[CommentViewer] NIP-22 nested reply already exists in comments`);
}
});
nip22Sub.on("eose", () => {
console.log(`[CommentViewer] NIP-22 nested replies EOSE`);
nip22Sub.stop();
});
}
}
} catch (err) {
console.error(`[CommentViewer] Error fetching nested replies:`, err);
isFetchingNestedReplies = false;
}
}
// Cleanup on unmount
onMount(() => {
return () => {
if (activeSub) {
activeSub.stop();
activeSub = null;
}
};
});
// Navigation functions
function getNeventUrl(commentEvent: NDKEvent): string {
try {
console.log(`[CommentViewer] Generating nevent for:`, commentEvent.id, `kind:`, commentEvent.kind);
const nevent = neventEncode(commentEvent, $activeInboxRelays);
console.log(`[CommentViewer] Generated nevent:`, nevent);
return nevent;
} catch (error) {
console.error(`[CommentViewer] Error generating nevent:`, error);
// Fallback to just the event ID
return commentEvent.id;
}
}
// AI-NOTE: 2025-01-24 - View button functionality is working correctly
// This function navigates to the specific event as the main event, allowing
// users to view replies as the primary content
function navigateToComment(commentEvent: NDKEvent) {
try {
const nevent = getNeventUrl(commentEvent);
console.log(`[CommentViewer] Navigating to comment:`, nevent);
goto(`/events?id=${encodeURIComponent(nevent)}`);
} catch (error) {
console.error(`[CommentViewer] Error navigating to comment:`, error);
// Fallback to event ID
goto(`/events?id=${commentEvent.id}`);
}
}
// Utility functions
function formatDate(timestamp: number): string {
return new Date(timestamp * 1000).toLocaleDateString();
}
function formatRelativeDate(timestamp: number): string {
const now = Date.now();
const date = timestamp * 1000;
const diffInSeconds = Math.floor((now - date) / 1000);
if (diffInSeconds < 60) {
return `${diffInSeconds} seconds ago`;
}
const diffInMinutes = Math.floor(diffInSeconds / 60);
if (diffInMinutes < 60) {
return `${diffInMinutes} minute${diffInMinutes !== 1 ? 's' : ''} ago`;
}
const diffInHours = Math.floor(diffInMinutes / 60);
if (diffInHours < 24) {
return `${diffInHours} hour${diffInHours !== 1 ? 's' : ''} ago`;
}
const diffInDays = Math.floor(diffInHours / 24);
if (diffInDays < 7) {
return `${diffInDays} day${diffInDays !== 1 ? 's' : ''} ago`;
}
const diffInWeeks = Math.floor(diffInDays / 7);
if (diffInWeeks < 4) {
return `${diffInWeeks} week${diffInWeeks !== 1 ? 's' : ''} ago`;
}
const diffInMonths = Math.floor(diffInDays / 30);
if (diffInMonths < 12) {
return `${diffInMonths} month${diffInMonths !== 1 ? 's' : ''} ago`;
}
const diffInYears = Math.floor(diffInDays / 365);
return `${diffInYears} year${diffInYears !== 1 ? 's' : ''} ago`;
}
function shortenNevent(nevent: string): string {
if (nevent.length <= 20) return nevent;
return nevent.slice(0, 10) + "…" + nevent.slice(-10);
}
function getAuthorName(pubkey: string): string {
const profile = profiles.get(pubkey);
if (profile?.displayName) return profile.displayName;
if (profile?.name) return profile.name;
return `${pubkey.slice(0, 8)}...${pubkey.slice(-4)}`;
}
function getAuthorPicture(pubkey: string): string | null {
const profile = profiles.get(pubkey);
return profile?.picture || null;
}
function getIndentation(level: number): string {
const maxLevel = 5;
const actualLevel = Math.min(level, maxLevel);
return `${actualLevel * 16}px`;
}
async function parseContent(content: string): Promise<string> {
if (!content) return "";
let parsedContent = await parseBasicmarkup(content);
return parsedContent;
}
// AI-NOTE: 2025-01-24 - Get highlight source information
function getHighlightSource(highlightEvent: NDKEvent): { type: string; value: string; url?: string } | null {
// Check for e-tags (nostr events)
const eTags = highlightEvent.getMatchingTags("e");
if (eTags.length > 0) {
return { type: "nostr_event", value: eTags[0][1] };
}
// Check for r-tags (URLs)
const rTags = highlightEvent.getMatchingTags("r");
if (rTags.length > 0) {
return { type: "url", value: rTags[0][1], url: rTags[0][1] };
}
return null;
}
// AI-NOTE: 2025-01-24 - Get highlight attribution
function getHighlightAttribution(highlightEvent: NDKEvent): Array<{ pubkey: string; role?: string }> {
const pTags = highlightEvent.getMatchingTags("p");
return pTags.map(tag => ({
pubkey: tag[1],
role: tag[3] || undefined
}));
}
// AI-NOTE: 2025-01-24 - Check if highlight has comment
function hasHighlightComment(highlightEvent: NDKEvent): boolean {
return highlightEvent.getMatchingTags("comment").length > 0;
}
</script>
<!-- Recursive Comment Item Component -->
{#snippet CommentItem(node: CommentNode)}
<div class="mb-4">
<div
class="bg-white dark:bg-gray-800 rounded-lg p-4 border border-gray-200 dark:border-gray-700 break-words"
style="margin-left: {getIndentation(node.level)};"
>
<div class="flex justify-between items-start mb-2">
<div class="flex items-center space-x-2">
<button
class="cursor-pointer"
onclick={() => goto(`/events?n=${toNpub(node.event.pubkey)}`)}
>
{#if getAuthorPicture(node.event.pubkey)}
<img
src={getAuthorPicture(node.event.pubkey)}
alt={getAuthorName(node.event.pubkey)}
class="w-8 h-8 rounded-full object-cover hover:opacity-80 transition-opacity"
onerror={(e) => (e.target as HTMLImageElement).style.display = 'none'}
/>
{:else}
<div class="w-8 h-8 rounded-full bg-gray-300 dark:bg-gray-600 flex items-center justify-center hover:opacity-80 transition-opacity">
<span class="text-sm font-medium text-gray-600 dark:text-gray-300">
{getAuthorName(node.event.pubkey).charAt(0).toUpperCase()}
</span>
</div>
{/if}
</button>
<div class="flex flex-col min-w-0">
<button
class="font-medium text-gray-900 dark:text-white truncate hover:underline cursor-pointer text-left"
onclick={() => goto(`/events?n=${toNpub(node.event.pubkey)}`)}
>
{getAuthorName(node.event.pubkey)}
</button>
<span
class="text-sm text-gray-500 cursor-help"
title={formatDate(node.event.created_at || 0)}
>
{formatRelativeDate(node.event.created_at || 0)} • Kind: {node.event.kind}
</span>
</div>
</div>
<div class="flex items-center space-x-2 flex-shrink-0">
<span class="text-sm text-gray-600 dark:text-gray-300 truncate max-w-32">
{shortenNevent(getNeventUrl(node.event))}
</span>
<Button
size="xs"
color="light"
onclick={() => navigateToComment(node.event)}
>
View
</Button>
</div>
</div>
<div class="text-gray-800 dark:text-gray-200 whitespace-pre-wrap break-words overflow-hidden">
{#if node.event.kind === 9802}
<!-- Highlight rendering -->
<div class="highlight-container bg-yellow-50 dark:bg-yellow-900/20 border-l-4 border-yellow-400 p-3 rounded-r">
{#if hasHighlightComment(node.event)}
<!-- Quote highlight with comment -->
<div class="highlight-quote bg-gray-50 dark:bg-gray-800 p-3 rounded mb-3 border-l-4 border-blue-400">
<div class="text-sm text-gray-600 dark:text-gray-400 mb-2">
<span class="font-medium">Highlighted content:</span>
</div>
{#if node.event.getMatchingTags("context")[0]?.[1]}
<div class="highlight-context">
{@html node.event.getMatchingTags("context")[0]?.[1]}
</div>
{:else}
<div class="highlight-content text-gray-800 dark:text-gray-200">
{node.event.content || ""}
</div>
{/if}
{#if getHighlightSource(node.event)}
<div class="text-xs text-gray-500 dark:text-gray-400 mt-2">
Source: {getHighlightSource(node.event)?.type === 'nostr_event' ? 'Nostr Event' : 'URL'}
</div>
{/if}
</div>
<div class="highlight-comment">
<div class="text-sm text-gray-600 dark:text-gray-400 mb-2">
<span class="font-medium">Comment:</span>
</div>
{#await parseContent(node.event.getMatchingTags("comment")[0]?.[1] || "") then parsedContent}
{@html parsedContent}
{:catch}
{@html node.event.getMatchingTags("comment")[0]?.[1] || ""}
{/await}
</div>
{:else}
<!-- Simple highlight -->
{#if node.event.getMatchingTags("context")[0]?.[1]}
<div class="highlight-context">
{@html node.event.getMatchingTags("context")[0]?.[1]}
</div>
{:else}
<div class="highlight-content">
{node.event.content || ""}
</div>
{/if}
{#if getHighlightSource(node.event)}
<div class="text-xs text-gray-500 dark:text-gray-400 mt-2">
Source: {getHighlightSource(node.event)?.type === 'nostr_event' ? 'Nostr Event' : 'URL'}
</div>
{/if}
{/if}
{#if getHighlightAttribution(node.event).length > 0}
<div class="text-xs text-gray-500 dark:text-gray-400 mt-2">
<span class="font-medium">Attribution:</span>
{#each getHighlightAttribution(node.event) as attribution}
<button
class="ml-1 text-blue-600 dark:text-blue-400 hover:underline cursor-pointer"
onclick={() => goto(`/events?n=${toNpub(attribution.pubkey)}`)}
>
{getAuthorName(attribution.pubkey)}
{#if attribution.role}
<span class="text-gray-400">({attribution.role})</span>
{/if}
</button>
{/each}
</div>
{/if}
</div>
{:else}
<!-- Regular comment content -->
{#await parseContent(node.event.content || "") then parsedContent}
{@html parsedContent}
{:catch}
{@html node.event.content || ""}
{/await}
{/if}
</div>
</div>
{#if node.children.length > 0}
<div class="space-y-4">
{#each node.children as childNode (childNode.event.id)}
{@render CommentItem(childNode)}
{/each}
</div>
{/if}
</div>
{/snippet}
<div class="mt-6">
<Heading tag="h3" class="h-leather mb-4">
Comments & Highlights ({threadedComments.length})
</Heading>
{#if loading}
<div class="text-center py-4">
<P>Loading comments...</P>
</div>
{:else if error}
<div class="text-center py-4">
<P class="text-red-600">{error}</P>
</div>
{:else if threadedComments.length === 0}
<div class="text-center py-4">
<P class="text-gray-500">No comments or highlights yet. Be the first to engage!</P>
</div>
{:else}
<div class="space-y-4">
{#each threadedComments as node (node.event.id)}
{@render CommentItem(node)}
{/each}
</div>
{/if}
</div>
<style>
/* Highlight styles */
.highlight-container {
position: relative;
}
.highlight-content {
font-style: italic;
background: linear-gradient(transparent 0%, transparent 40%, rgba(255, 255, 0, 0.3) 40%, rgba(255, 255, 0, 0.3) 100%);
}
.highlight-quote {
position: relative;
}
.highlight-quote::before {
content: '"';
position: absolute;
top: -5px;
left: -10px;
font-size: 2rem;
color: #3b82f6;
opacity: 0.5;
}
.highlight-context {
color: #6b7280;
font-size: 0.875rem;
margin-bottom: 0.5rem;
opacity: 0.8;
}
</style>

16
src/lib/components/EventDetails.svelte

@ -14,6 +14,7 @@ @@ -14,6 +14,7 @@
import CopyToClipboard from "$lib/components/util/CopyToClipboard.svelte";
import { navigateToEvent } from "$lib/utils/nostrEventService";
import ContainingIndexes from "$lib/components/util/ContainingIndexes.svelte";
import Notifications from "$lib/components/Notifications.svelte";
const {
event,
@ -400,6 +401,11 @@ @@ -400,6 +401,11 @@
</h2>
{/if}
<!-- Notifications (for profile events) -->
{#if event.kind === 0}
<Notifications {event} />
{/if}
<div class="flex items-center space-x-2">
{#if toNpub(event.pubkey)}
<span class="text-gray-600 dark:text-gray-400"
@ -449,10 +455,11 @@ @@ -449,10 +455,11 @@
<ContainingIndexes {event} />
<!-- Content -->
<div class="flex flex-col space-y-1">
{#if event.kind !== 0}
<span class="text-gray-700 dark:text-gray-300">Content:</span>
<div class="prose dark:prose-invert max-w-none">
<div class="card-leather bg-highlight dark:bg-primary-800 p-4 mb-4 rounded-lg border">
<div class="flex flex-col space-y-1">
<span class="text-gray-700 dark:text-gray-300 font-semibold">Content:</span>
<div class="prose dark:prose-invert max-w-none text-gray-900 dark:text-gray-100">
{@html showFullContent ? parsedContent : contentPreview}
{#if !showFullContent && parsedContent.length > 250}
<button
@ -461,8 +468,9 @@ @@ -461,8 +468,9 @@
>
{/if}
</div>
{/if}
</div>
</div>
{/if}
<!-- If event is profile -->
{#if event.kind === 0}

5
src/lib/components/EventInput.svelte

@ -29,6 +29,7 @@ @@ -29,6 +29,7 @@
import { Button } from "flowbite-svelte";
import { goto } from "$app/navigation";
import { WebSocketPool } from "$lib/data_structures/websocket_pool";
import { anonymousRelays } from "$lib/consts";
let kind = $state<number>(30040);
let tags = $state<[string, string][]>([]);
@ -384,9 +385,7 @@ @@ -384,9 +385,7 @@
// Try to publish to relays directly
const relays = [
"wss://relay.damus.io",
"wss://relay.nostr.band",
"wss://nos.lol",
...anonymousRelays,
...$activeOutboxRelays,
...$activeInboxRelays,
];

132
src/lib/components/EventSearch.svelte

@ -488,6 +488,61 @@ @@ -488,6 +488,61 @@
searchType,
searchTerm,
});
// AI-NOTE: 2025-01-24 - Check cache first for profile searches to provide immediate response
if (searchType === "n") {
try {
const { getUserMetadata } = await import("$lib/utils/nostrUtils");
const cachedProfile = await getUserMetadata(searchTerm, false);
if (cachedProfile && cachedProfile.name) {
console.log("EventSearch: Found cached profile, displaying immediately:", cachedProfile);
// Create a mock NDKEvent for the cached profile
const { NDKEvent } = await import("@nostr-dev-kit/ndk");
const { nip19 } = await import("$lib/utils/nostrUtils");
// Decode the npub to get the actual pubkey
let pubkey = searchTerm;
try {
const decoded = nip19.decode(searchTerm);
if (decoded && decoded.type === "npub") {
pubkey = decoded.data;
}
} catch (error) {
console.warn("EventSearch: Failed to decode npub for mock event:", error);
}
const mockEvent = new NDKEvent(undefined, {
kind: 0,
pubkey: pubkey,
content: JSON.stringify(cachedProfile),
tags: [],
created_at: Math.floor(Date.now() / 1000),
id: "", // Will be computed by NDK
sig: "", // Will be computed by NDK
});
// Display the cached profile immediately
handleFoundEvent(mockEvent);
updateSearchState(false, true, 1, "profile-cached");
// AI-NOTE: 2025-01-24 - Still perform background search for second-order events
// but with better timeout handling to prevent hanging
setTimeout(async () => {
try {
await performBackgroundProfileSearch(searchType, searchTerm);
} catch (error) {
console.warn("EventSearch: Background profile search failed:", error);
}
}, 100);
return;
}
} catch (error) {
console.warn("EventSearch: Cache check failed, proceeding with subscription search:", error);
}
}
isResetting = false; // Allow effects to run for new searches
localError = null;
updateSearchState(true);
@ -663,6 +718,83 @@ @@ -663,6 +718,83 @@
}
}
// AI-NOTE: 2025-01-24 - Function to perform background profile search without blocking UI
async function performBackgroundProfileSearch(
searchType: "d" | "t" | "n",
searchTerm: string,
) {
console.log("EventSearch: Performing background profile search:", {
searchType,
searchTerm,
});
try {
// Cancel existing search
if (currentAbortController) {
currentAbortController.abort();
}
currentAbortController = new AbortController();
// AI-NOTE: 2025-01-24 - Add timeout to prevent hanging background searches
const timeoutPromise = new Promise((_, reject) => {
setTimeout(() => {
reject(new Error("Background search timeout"));
}, 10000); // 10 second timeout for background searches
});
const searchPromise = searchBySubscription(
searchType,
searchTerm,
{
onSecondOrderUpdate: (updatedResult) => {
console.log("EventSearch: Background second order update:", updatedResult);
// Only update if we have new results
if (updatedResult.events.length > 0) {
onSearchResults(
updatedResult.events,
updatedResult.secondOrder,
updatedResult.tTagEvents,
updatedResult.eventIds,
updatedResult.addresses,
updatedResult.searchType,
updatedResult.searchTerm,
);
}
},
onSubscriptionCreated: (sub) => {
console.log("EventSearch: Background subscription created:", sub);
if (activeSub) {
activeSub.stop();
}
activeSub = sub;
},
},
currentAbortController.signal,
);
// Race between search and timeout
const result = await Promise.race([searchPromise, timeoutPromise]) as any;
console.log("EventSearch: Background search completed:", result);
// Only update results if we have new data
if (result.events.length > 0) {
onSearchResults(
result.events,
result.secondOrder,
result.tTagEvents,
result.eventIds,
result.addresses,
result.searchType,
result.searchTerm,
);
}
} catch (error) {
console.warn("EventSearch: Background profile search failed:", error);
}
}
// Search utility functions
function handleClear() {
isResetting = true;
searchQuery = "";

1186
src/lib/components/Notifications.svelte

File diff suppressed because it is too large Load Diff

92
src/lib/components/RelayInfoDisplay.svelte

@ -0,0 +1,92 @@ @@ -0,0 +1,92 @@
<script lang="ts">
import { onMount } from 'svelte';
import { fetchRelayInfo, getRelayTypeLabel, getRelayIcon, type RelayInfoWithMetadata } from '$lib/utils/relay_info_service';
const { relay, showIcon = true, showType = true, showName = true, size = 'sm' } = $props<{
relay: string;
showIcon?: boolean;
showType?: boolean;
showName?: boolean;
size?: 'xs' | 'sm' | 'md' | 'lg';
}>();
let relayInfo = $state<RelayInfoWithMetadata | undefined>(undefined);
let isLoading = $state(true);
let error = $state<string | null>(null);
// Size classes
const sizeClasses: Record<'xs' | 'sm' | 'md' | 'lg', string> = {
xs: 'text-xs',
sm: 'text-sm',
md: 'text-base',
lg: 'text-lg'
};
const iconSizeClasses: Record<'xs' | 'sm' | 'md' | 'lg', string> = {
xs: 'w-3 h-3',
sm: 'w-4 h-4',
md: 'w-5 h-5',
lg: 'w-6 h-6'
};
async function loadRelayInfo() {
isLoading = true;
error = null;
try {
relayInfo = await fetchRelayInfo(relay);
} catch (err) {
error = err instanceof Error ? err.message : 'Failed to load relay info';
console.warn(`[RelayInfoDisplay] Error loading info for ${relay}:`, err);
} finally {
isLoading = false;
}
}
onMount(() => {
loadRelayInfo();
});
// Get relay type and label
const relayType = $derived(getRelayTypeLabel(relay, relayInfo));
const relayIcon = $derived(getRelayIcon(relayInfo, relay));
const displayName = $derived(relayInfo?.name || relayInfo?.shortUrl || relay);
</script>
<div class="inline-flex items-center gap-2 flex-1">
{#if showIcon && relayIcon}
<img
src={relayIcon}
alt="Relay icon"
class="{iconSizeClasses[size as keyof typeof iconSizeClasses]} rounded object-contain"
onerror={(e) => (e.target as HTMLImageElement).style.display = 'none'}
/>
{:else if showIcon}
<!-- Fallback icon -->
<div class="{iconSizeClasses[size as keyof typeof iconSizeClasses]} bg-gray-300 dark:bg-gray-600 rounded flex items-center justify-center">
<svg class="w-2/3 h-2/3 text-gray-600 dark:text-gray-400" fill="currentColor" viewBox="0 0 20 20">
<path fill-rule="evenodd" d="M3 4a1 1 0 011-1h12a1 1 0 011 1v2a1 1 0 01-.293.707L12 11.414V15a1 1 0 01-.293.707l-2 2A1 1 0 018 17v-5.586L3.293 6.707A1 1 0 013 6V4z" clip-rule="evenodd" />
</svg>
</div>
{/if}
<div class="flex flex-col min-w-0 flex-1">
{#if showName}
<span class="{sizeClasses[size as keyof typeof sizeClasses]} font-medium text-gray-900 dark:text-gray-100 leading-tight truncate">
{isLoading ? 'Loading...' : displayName}
</span>
{/if}
{#if showType}
<span class="text-xs text-gray-500 dark:text-gray-400 leading-tight truncate">
{relayType}
</span>
{/if}
</div>
{#if error}
<span class="text-xs text-red-500 dark:text-red-400 flex-shrink-0" title={error}>
</span>
{/if}
</div>

143
src/lib/components/RelayInfoList.svelte

@ -0,0 +1,143 @@ @@ -0,0 +1,143 @@
<script lang="ts">
import RelayInfoDisplay from './RelayInfoDisplay.svelte';
import { fetchRelayInfos, type RelayInfoWithMetadata } from '$lib/utils/relay_info_service';
const {
relays,
inboxRelays = [],
outboxRelays = [],
showLabels = true,
compact = false
} = $props<{
relays: string[];
inboxRelays?: string[];
outboxRelays?: string[];
showLabels?: boolean;
compact?: boolean;
}>();
let relayInfos = $state<RelayInfoWithMetadata[]>([]);
let isLoading = $state(true);
type CategorizedRelay = {
relay: string;
category: 'both' | 'inbox' | 'outbox' | 'other';
label: string;
};
// Categorize relays by their function (inbox/outbox/both)
const categorizedRelays = $derived(() => {
const inbox = new Set(inboxRelays);
const outbox = new Set(outboxRelays);
const relayCategories = new Map<string, CategorizedRelay>();
// Process inbox relays (up to top 3)
const topInboxRelays = inboxRelays.slice(0, 3);
topInboxRelays.forEach((relay: string) => {
const isOutbox = outbox.has(relay);
if (isOutbox) {
relayCategories.set(relay, { relay, category: 'both', label: 'Inbox & Outbox' });
} else {
relayCategories.set(relay, { relay, category: 'inbox', label: 'Recipient Inbox' });
}
});
// Process outbox relays (up to top 3)
const topOutboxRelays = outboxRelays.slice(0, 3);
topOutboxRelays.forEach((relay: string) => {
if (!relayCategories.has(relay)) {
relayCategories.set(relay, { relay, category: 'outbox', label: 'Sender Outbox' });
}
});
return Array.from(relayCategories.values());
});
// Group by category for display
const groupedRelays = $derived(() => {
const categorized = categorizedRelays();
return {
both: categorized.filter((r: CategorizedRelay) => r.category === 'both'),
inbox: categorized.filter((r: CategorizedRelay) => r.category === 'inbox'),
outbox: categorized.filter((r: CategorizedRelay) => r.category === 'outbox'),
other: categorized.filter((r: CategorizedRelay) => r.category === 'other')
};
});
async function loadRelayInfos() {
isLoading = true;
try {
const categorized = categorizedRelays();
const relayUrls = categorized.map(r => r.relay);
relayInfos = await fetchRelayInfos(relayUrls);
} catch (error) {
console.warn('[RelayInfoList] Error loading relay infos:', error);
} finally {
isLoading = false;
}
}
// Load relay info when categorized relays change
$effect(() => {
const categorized = categorizedRelays();
if (categorized.length > 0) {
loadRelayInfos();
}
});
// Get relay info for a specific relay
function getRelayInfo(relayUrl: string): RelayInfoWithMetadata | undefined {
return relayInfos.find(info => info.url === relayUrl);
}
// Category colors
const categoryColors = {
both: 'bg-green-100 dark:bg-green-900 border-green-200 dark:border-green-700 text-green-800 dark:text-green-200',
inbox: 'bg-blue-100 dark:bg-blue-900 border-blue-200 dark:border-blue-700 text-blue-800 dark:text-blue-200',
outbox: 'bg-purple-100 dark:bg-purple-900 border-purple-200 dark:border-purple-700 text-purple-800 dark:text-purple-200',
other: 'bg-gray-100 dark:bg-gray-800 border-gray-200 dark:border-gray-700 text-gray-800 dark:text-gray-200'
};
const categoryIcons = {
both: '🔄',
inbox: '📥',
outbox: '📤',
other: '🌐'
};
</script>
<div class="space-y-2">
{#if showLabels && !compact}
{@const categorizedCount = categorizedRelays().length}
<div class="text-sm font-medium text-gray-700 dark:text-gray-300">
Publishing to {categorizedCount} relay(s):
</div>
{/if}
{#if isLoading}
<div class="flex items-center justify-center py-2">
<div class="animate-spin rounded-full h-4 w-4 border-b-2 border-primary-600"></div>
<span class="ml-2 text-sm text-gray-600 dark:text-gray-400">Loading relay info...</span>
</div>
{:else}
{@const categorized = categorizedRelays()}
<div class="space-y-1">
{#each categorized as { relay, category, label }}
<div class="p-2 bg-gray-50 dark:bg-gray-800 rounded-md border border-gray-200 dark:border-gray-700">
<div class="flex items-center justify-between">
<span class="text-sm font-mono text-gray-900 dark:text-gray-100">
{relay}
</span>
{#if category === 'both'}
<span class="text-xs text-gray-500 dark:text-gray-400 italic">
common relay
</span>
{/if}
</div>
</div>
{/each}
</div>
{/if}
</div>

7
src/lib/consts.ts

@ -14,6 +14,9 @@ export const searchRelays = [ @@ -14,6 +14,9 @@ export const searchRelays = [
"wss://aggr.nostr.land",
"wss://relay.noswhere.com",
"wss://nostr.wine",
"wss://relay.damus.io",
"wss://relay.nostr.band",
"wss://freelay.sovbit.host"
];
export const secondaryRelays = [
@ -30,6 +33,8 @@ export const secondaryRelays = [ @@ -30,6 +33,8 @@ export const secondaryRelays = [
export const anonymousRelays = [
"wss://freelay.sovbit.host",
"wss://thecitadel.nostr1.com",
"wss://relay.damus.io",
"wss://relay.nostr.band"
];
export const lowbandwidthRelays = [
@ -48,5 +53,7 @@ export enum FeedType { @@ -48,5 +53,7 @@ export enum FeedType {
UserRelays = "user",
}
export const EXPIRATION_DURATION = 28 * 24 * 60 * 60; // 4 weeks in seconds
export const loginStorageKey = "alexandria/login/pubkey";
export const feedTypeStorageKey = "alexandria/feed/type";

88
src/lib/ndk.ts

@ -33,6 +33,63 @@ export const outboxRelays = writable<string[]>([]); @@ -33,6 +33,63 @@ export const outboxRelays = writable<string[]>([]);
export const activeInboxRelays = writable<string[]>([]);
export const activeOutboxRelays = writable<string[]>([]);
// AI-NOTE: 2025-01-08 - Persistent relay storage to avoid recalculation
let persistentRelaySet: { inboxRelays: string[]; outboxRelays: string[] } | null = null;
let relaySetLastUpdated: number = 0;
const RELAY_SET_CACHE_DURATION = 5 * 60 * 1000; // 5 minutes
const RELAY_SET_STORAGE_KEY = 'alexandria/relay_set_cache';
/**
* Load persistent relay set from localStorage
*/
function loadPersistentRelaySet(): { relaySet: { inboxRelays: string[]; outboxRelays: string[] } | null; lastUpdated: number } {
try {
const stored = localStorage.getItem(RELAY_SET_STORAGE_KEY);
if (!stored) return { relaySet: null, lastUpdated: 0 };
const data = JSON.parse(stored);
const now = Date.now();
// Check if cache is expired
if (now - data.timestamp > RELAY_SET_CACHE_DURATION) {
localStorage.removeItem(RELAY_SET_STORAGE_KEY);
return { relaySet: null, lastUpdated: 0 };
}
return { relaySet: data.relaySet, lastUpdated: data.timestamp };
} catch (error) {
console.warn('[NDK.ts] Failed to load persistent relay set:', error);
localStorage.removeItem(RELAY_SET_STORAGE_KEY);
return { relaySet: null, lastUpdated: 0 };
}
}
/**
* Save persistent relay set to localStorage
*/
function savePersistentRelaySet(relaySet: { inboxRelays: string[]; outboxRelays: string[] }): void {
try {
const data = {
relaySet,
timestamp: Date.now()
};
localStorage.setItem(RELAY_SET_STORAGE_KEY, JSON.stringify(data));
} catch (error) {
console.warn('[NDK.ts] Failed to save persistent relay set:', error);
}
}
/**
* Clear persistent relay set from localStorage
*/
function clearPersistentRelaySet(): void {
try {
localStorage.removeItem(RELAY_SET_STORAGE_KEY);
} catch (error) {
console.warn('[NDK.ts] Failed to clear persistent relay set:', error);
}
}
// Subscribe to userStore changes and update ndkSignedIn accordingly
userStore.subscribe((userState) => {
ndkSignedIn.set(userState.signedIn);
@ -351,15 +408,39 @@ export async function getActiveRelaySet(ndk: NDK): Promise<{ inboxRelays: string @@ -351,15 +408,39 @@ export async function getActiveRelaySet(ndk: NDK): Promise<{ inboxRelays: string
/**
* Updates the active relay stores and NDK pool with new relay URLs
* @param ndk NDK instance
* @param forceUpdate Force update even if cached (default: false)
*/
export async function updateActiveRelayStores(ndk: NDK): Promise<void> {
export async function updateActiveRelayStores(ndk: NDK, forceUpdate: boolean = false): Promise<void> {
try {
// AI-NOTE: 2025-01-08 - Use persistent relay set to avoid recalculation
const now = Date.now();
const cacheExpired = now - relaySetLastUpdated > RELAY_SET_CACHE_DURATION;
// Load from persistent storage if not already loaded
if (!persistentRelaySet) {
const loaded = loadPersistentRelaySet();
persistentRelaySet = loaded.relaySet;
relaySetLastUpdated = loaded.lastUpdated;
}
if (!forceUpdate && persistentRelaySet && !cacheExpired) {
console.debug('[NDK.ts] updateActiveRelayStores: Using cached relay set');
activeInboxRelays.set(persistentRelaySet.inboxRelays);
activeOutboxRelays.set(persistentRelaySet.outboxRelays);
return;
}
console.debug('[NDK.ts] updateActiveRelayStores: Starting relay store update');
// Get the active relay set from the relay management system
const relaySet = await getActiveRelaySet(ndk);
console.debug('[NDK.ts] updateActiveRelayStores: Got relay set:', relaySet);
// Cache the relay set
persistentRelaySet = relaySet;
relaySetLastUpdated = now;
savePersistentRelaySet(relaySet); // Save to persistent storage
// Update the stores with the new relay configuration
activeInboxRelays.set(relaySet.inboxRelays);
activeOutboxRelays.set(relaySet.outboxRelays);
@ -560,6 +641,11 @@ export function logout(user: NDKUser): void { @@ -560,6 +641,11 @@ export function logout(user: NDKUser): void {
activeInboxRelays.set([]);
activeOutboxRelays.set([]);
// AI-NOTE: 2025-01-08 - Clear persistent relay set on logout
persistentRelaySet = null;
relaySetLastUpdated = 0;
clearPersistentRelaySet(); // Clear persistent storage
// Stop network monitoring
stopNetworkStatusMonitoring();

9
src/lib/utils.ts

@ -19,12 +19,19 @@ export class InvalidKindError extends DecodeError { @@ -19,12 +19,19 @@ export class InvalidKindError extends DecodeError {
}
export function neventEncode(event: NDKEvent, relays: string[]) {
return nip19.neventEncode({
try {
const nevent = nip19.neventEncode({
id: event.id,
kind: event.kind,
relays,
author: event.pubkey,
});
return nevent;
} catch (error) {
console.error(`[neventEncode] Error encoding nevent:`, error);
throw error;
}
}
export function naddrEncode(event: NDKEvent, relays: string[]) {

64
src/lib/utils/event_search.ts

@ -1,5 +1,5 @@ @@ -1,5 +1,5 @@
import { ndkInstance } from "../ndk.ts";
import { fetchEventWithFallback } from "./nostrUtils.ts";
import { fetchEventWithFallback, NDKRelaySetFromNDK } from "./nostrUtils.ts";
import { nip19 } from "nostr-tools";
import { NDKEvent } from "@nostr-dev-kit/ndk";
import type { Filter } from "./search_types.ts";
@ -11,6 +11,26 @@ import { TIMEOUTS, VALIDATION } from "./search_constants.ts"; @@ -11,6 +11,26 @@ import { TIMEOUTS, VALIDATION } from "./search_constants.ts";
* Search for a single event by ID or filter
*/
export async function searchEvent(query: string): Promise<NDKEvent | null> {
const ndk = get(ndkInstance);
if (!ndk) {
console.warn("[Search] No NDK instance available");
return null;
}
// Wait for relays to be available
let attempts = 0;
const maxAttempts = 10;
while (ndk.pool.relays.size === 0 && attempts < maxAttempts) {
console.log(`[Search] Waiting for relays to be available (attempt ${attempts + 1}/${maxAttempts})`);
await new Promise(resolve => setTimeout(resolve, 500));
attempts++;
}
if (ndk.pool.relays.size === 0) {
console.warn("[Search] No relays available after waiting");
return null;
}
// Clean the query and normalize to lowercase
const cleanedQuery = query.replace(/^nostr:/, "").toLowerCase();
let filterOrId: Filter | string = cleanedQuery;
@ -51,8 +71,50 @@ export async function searchEvent(query: string): Promise<NDKEvent | null> { @@ -51,8 +71,50 @@ export async function searchEvent(query: string): Promise<NDKEvent | null> {
try {
const decoded = nip19.decode(cleanedQuery);
if (!decoded) throw new Error("Invalid identifier");
console.log(`[Search] Decoded identifier:`, {
type: decoded.type,
data: decoded.data,
query: cleanedQuery
});
switch (decoded.type) {
case "nevent":
console.log(`[Search] Processing nevent:`, {
id: decoded.data.id,
kind: decoded.data.kind,
relays: decoded.data.relays
});
// Use the relays from the nevent if available
if (decoded.data.relays && decoded.data.relays.length > 0) {
console.log(`[Search] Using relays from nevent:`, decoded.data.relays);
// Try to fetch the event using the nevent's relays
try {
// Create a temporary relay set for this search
const neventRelaySet = NDKRelaySetFromNDK.fromRelayUrls(decoded.data.relays, ndk);
if (neventRelaySet.relays.size > 0) {
console.log(`[Search] Created relay set with ${neventRelaySet.relays.size} relays from nevent`);
// Try to fetch the event using the nevent's relays
const event = await ndk
.fetchEvent({ ids: [decoded.data.id] }, undefined, neventRelaySet)
.withTimeout(TIMEOUTS.EVENT_FETCH);
if (event) {
console.log(`[Search] Found event using nevent relays:`, event.id);
return event;
} else {
console.log(`[Search] Event not found on nevent relays, trying default relays`);
}
}
} catch (error) {
console.warn(`[Search] Error fetching from nevent relays:`, error);
}
}
filterOrId = decoded.data.id;
break;
case "note":

135
src/lib/utils/kind24_utils.ts

@ -0,0 +1,135 @@ @@ -0,0 +1,135 @@
import { get } from "svelte/store";
import { ndkInstance } from "../ndk";
import { userStore } from "../stores/userStore";
import { NDKEvent, NDKRelaySet, NDKUser } from "@nostr-dev-kit/ndk";
import type NDK from "@nostr-dev-kit/ndk";
import { nip19 } from "nostr-tools";
import { createSignedEvent } from "./nostrEventService.ts";
import { anonymousRelays } from "../consts";
import { buildCompleteRelaySet } from "./relay_management";
// AI-NOTE: Using existing relay utilities from relay_management.ts instead of duplicating functionality
/**
* Gets optimal relay set for kind 24 messages between two users
* @param senderPubkey The sender's pubkey
* @param recipientPubkey The recipient's pubkey
* @returns Promise resolving to relay URLs prioritized by commonality
*/
export async function getKind24RelaySet(
senderPubkey: string,
recipientPubkey: string
): Promise<string[]> {
const ndk = get(ndkInstance);
if (!ndk) {
throw new Error("NDK not available");
}
const senderPrefix = senderPubkey.slice(0, 8);
const recipientPrefix = recipientPubkey.slice(0, 8);
console.log(`[getKind24RelaySet] Getting relays for ${senderPrefix} -> ${recipientPrefix}`);
try {
// Fetch both users' complete relay sets using existing utilities
const [senderRelaySet, recipientRelaySet] = await Promise.all([
buildCompleteRelaySet(ndk, ndk.getUser({ pubkey: senderPubkey })),
buildCompleteRelaySet(ndk, ndk.getUser({ pubkey: recipientPubkey }))
]);
// Use sender's outbox relays and recipient's inbox relays
const senderOutboxRelays = senderRelaySet.outboxRelays;
const recipientInboxRelays = recipientRelaySet.inboxRelays;
// Prioritize common relays for better privacy
const commonRelays = senderOutboxRelays.filter(relay =>
recipientInboxRelays.includes(relay)
);
const senderOnlyRelays = senderOutboxRelays.filter(relay =>
!recipientInboxRelays.includes(relay)
);
const recipientOnlyRelays = recipientInboxRelays.filter(relay =>
!senderOutboxRelays.includes(relay)
);
// Prioritize: common relays first, then sender outbox, then recipient inbox
const finalRelays = [...commonRelays, ...senderOnlyRelays, ...recipientOnlyRelays];
console.log(`[getKind24RelaySet] ${senderPrefix}->${recipientPrefix} - Common: ${commonRelays.length}, Sender-only: ${senderOnlyRelays.length}, Recipient-only: ${recipientOnlyRelays.length}, Total: ${finalRelays.length}`);
return finalRelays;
} catch (error) {
console.error(`[getKind24RelaySet] Error getting relay set for ${senderPrefix}->${recipientPrefix}:`, error);
throw error;
}
}
/**
* Creates a kind 24 public message reply according to NIP-A4
* @param content The message content
* @param recipientPubkey The recipient's pubkey
* @param originalEvent The original event being replied to (optional)
* @returns Promise resolving to publish result with relay information
*/
export async function createKind24Reply(
content: string,
recipientPubkey: string,
originalEvent?: NDKEvent
): Promise<{ success: boolean; eventId?: string; error?: string; relays?: string[] }> {
const ndk = get(ndkInstance);
if (!ndk?.activeUser) {
return { success: false, error: "Not logged in" };
}
if (!content.trim()) {
return { success: false, error: "Message content cannot be empty" };
}
try {
// Get optimal relay set for this sender-recipient pair
const targetRelays = await getKind24RelaySet(ndk.activeUser.pubkey, recipientPubkey);
if (targetRelays.length === 0) {
return { success: false, error: "No relays available for publishing" };
}
// Build tags for the kind 24 event
const tags: string[][] = [
["p", recipientPubkey, targetRelays[0]] // Use first relay as primary
];
// Add q tag if replying to an original event
if (originalEvent) {
tags.push(["q", originalEvent.id, targetRelays[0] || anonymousRelays[0]]);
}
// Create and sign the event
const { event: signedEventData } = await createSignedEvent(
content,
ndk.activeUser.pubkey,
24,
tags
);
// Create NDKEvent and publish
const event = new NDKEvent(ndk, signedEventData);
const relaySet = NDKRelaySet.fromRelayUrls(targetRelays, ndk);
const publishedToRelays = await event.publish(relaySet);
if (publishedToRelays.size > 0) {
console.log(`[createKind24Reply] Successfully published to ${publishedToRelays.size} relays`);
return { success: true, eventId: event.id, relays: targetRelays };
} else {
console.warn(`[createKind24Reply] Failed to publish to any relays`);
return { success: false, error: "Failed to publish to any relays", relays: targetRelays };
}
} catch (error) {
console.error("[createKind24Reply] Error creating kind 24 reply:", error);
return {
success: false,
error: error instanceof Error ? error.message : "Unknown error"
};
}
}

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

@ -78,20 +78,7 @@ function replaceAlexandriaNostrLinks(text: string): string { @@ -78,20 +78,7 @@ function replaceAlexandriaNostrLinks(text: string): string {
return `nostr:${bech32Match[0]}`;
}
}
// For non-Alexandria/localhost URLs, append (View here: nostr:<id>) if a Nostr identifier is present
const hexMatch = url.match(hexPattern);
if (hexMatch) {
try {
const nevent = nip19.neventEncode({ id: hexMatch[0] });
return `${url} (View here: nostr:${nevent})`;
} catch {
return url;
}
}
const bech32Match = url.match(bech32Pattern);
if (bech32Match) {
return `${url} (View here: nostr:${bech32Match[0]})`;
}
// For non-Alexandria/localhost URLs, just return the URL as-is
return url;
});
@ -253,7 +240,18 @@ function processBasicFormatting(content: string): string { @@ -253,7 +240,18 @@ function processBasicFormatting(content: string): string {
}
// Only render <img> if the url ends with a direct image extension
if (IMAGE_EXTENSIONS.test(url.split("?")[0])) {
return `<img src="${url}" alt="${alt}" class="max-w-full h-auto rounded-lg shadow-lg my-4" loading="lazy" decoding="async">`;
return `<div class="relative inline-block w-[300px] my-4">
<div class="w-full h-48 bg-gradient-to-br from-pink-200 to-purple-200 rounded-lg shadow-lg flex items-center justify-center">
<div class="text-center">
<div class="text-4xl mb-2">🖼</div>
<div class="text-gray-600 font-medium">Image</div>
</div>
</div>
<img src="${url}" alt="${alt}" class="absolute inset-0 w-full h-full object-cover rounded-lg shadow-lg opacity-0 transition-opacity duration-300" loading="lazy" decoding="async" onload="this.style.opacity='0';">
<button class="absolute inset-0 flex items-center justify-center bg-black bg-opacity-30 text-white font-semibold rounded-lg hover:bg-opacity-40 transition-all duration-300" onclick="this.parentElement.querySelector('img').style.opacity='1'; this.style.display='none';">
Reveal Image
</button>
</div>`;
}
// Otherwise, render as a clickable link
return `<a href="${url}" class="text-primary-600 dark:text-primary-500 hover:underline" target="_blank" rel="noopener noreferrer">${alt || url}</a>`;
@ -290,7 +288,18 @@ function processBasicFormatting(content: string): string { @@ -290,7 +288,18 @@ function processBasicFormatting(content: string): string {
}
// Only render <img> if the url ends with a direct image extension
if (IMAGE_EXTENSIONS.test(clean.split("?")[0])) {
return `<img src="${clean}" alt="Embedded media" class="max-w-full h-auto rounded-lg shadow-lg my-4" loading="lazy" decoding="async">`;
return `<div class="relative inline-block w-[300px] my-4">
<div class="w-full h-48 bg-gradient-to-br from-pink-200 to-purple-200 rounded-lg shadow-lg flex items-center justify-center">
<div class="text-center">
<div class="text-4xl mb-2">🖼</div>
<div class="text-gray-600 font-medium">Image</div>
</div>
</div>
<img src="${clean}" alt="Embedded media" class="absolute inset-0 w-full h-full object-cover rounded-lg shadow-lg opacity-0 transition-opacity duration-300" loading="lazy" decoding="async" onload="this.style.opacity='0';">
<button class="absolute inset-0 flex items-center justify-center bg-black bg-opacity-30 text-white font-semibold rounded-lg hover:bg-opacity-40 transition-all duration-300" onclick="this.parentElement.querySelector('img').style.opacity='1'; this.style.display='none';">
Reveal Image
</button>
</div>`;
}
// Otherwise, render as a clickable link
return `<a href="${clean}" target="_blank" rel="noopener noreferrer" class="text-blue-500 hover:text-blue-600 dark:text-blue-400 dark:hover:text-blue-300">${clean}</a>`;
@ -310,10 +319,10 @@ function processBasicFormatting(content: string): string { @@ -310,10 +319,10 @@ function processBasicFormatting(content: string): string {
},
);
// Process hashtags
// Process hashtags as clickable buttons
processedText = processedText.replace(
HASHTAG_REGEX,
'<span class="text-primary-600 dark:text-primary-500">#$1</span>',
'<button class="text-primary-600 dark:text-primary-500 hover:underline cursor-pointer" onclick="window.location.href=\'/events?t=$1\'">#$1</button>',
);
// --- Improved List Grouping and Parsing ---

10
src/lib/utils/nostrEventService.ts

@ -3,6 +3,7 @@ import { getEventHash, signEvent, prefixNostrAddresses } from "./nostrUtils.ts"; @@ -3,6 +3,7 @@ import { getEventHash, signEvent, prefixNostrAddresses } from "./nostrUtils.ts";
import { get } from "svelte/store";
import { goto } from "$app/navigation";
import { EVENT_KINDS, TIME_CONSTANTS } from "./search_constants.ts";
import { EXPIRATION_DURATION } from "../consts.ts";
import { ndkInstance } from "../ndk.ts";
import { NDKRelaySet, NDKEvent } from "@nostr-dev-kit/ndk";
@ -320,12 +321,19 @@ export async function createSignedEvent( @@ -320,12 +321,19 @@ export async function createSignedEvent(
): Promise<{ id: string; sig: string; event: any }> {
const prefixedContent = prefixNostrAddresses(content);
// Add expiration tag for kind 24 events (NIP-40)
const finalTags = [...tags];
if (kind === 24) {
const expirationTimestamp = Math.floor(Date.now() / TIME_CONSTANTS.UNIX_TIMESTAMP_FACTOR) + EXPIRATION_DURATION;
finalTags.push(["expiration", String(expirationTimestamp)]);
}
const eventToSign = {
kind: Number(kind),
created_at: Number(
Math.floor(Date.now() / TIME_CONSTANTS.UNIX_TIMESTAMP_FACTOR),
),
tags: tags.map((tag) => [
tags: finalTags.map((tag) => [
String(tag[0]),
String(tag[1]),
String(tag[2] || ""),

225
src/lib/utils/notification_utils.ts

@ -0,0 +1,225 @@ @@ -0,0 +1,225 @@
import { parseBasicmarkup } from "$lib/utils/markup/basicMarkupParser";
import type { NDKEvent } from "$lib/utils/nostrUtils";
import { getUserMetadata, NDKRelaySetFromNDK, toNpub } from "$lib/utils/nostrUtils";
import { get } from "svelte/store";
import { ndkInstance } from "$lib/ndk";
import { searchRelays } from "$lib/consts";
import { userStore } from "$lib/stores/userStore";
import { buildCompleteRelaySet } from "$lib/utils/relay_management";
import { neventEncode } from "$lib/utils";
// AI-NOTE: Notification-specific utility functions that don't exist elsewhere
/**
* Truncates content to a specified length
*/
export function truncateContent(content: string, maxLength: number = 300): string {
if (content.length <= maxLength) return content;
return content.slice(0, maxLength) + "...";
}
/**
* Truncates rendered HTML content while preserving quote boxes
*/
export function truncateRenderedContent(renderedHtml: string, maxLength: number = 300): string {
if (renderedHtml.length <= maxLength) return renderedHtml;
const hasQuoteBoxes = renderedHtml.includes('jump-to-message');
if (hasQuoteBoxes) {
const quoteBoxPattern = /<div class="block w-fit my-2 px-3 py-2 bg-gray-200[^>]*onclick="window\.dispatchEvent\(new CustomEvent\('jump-to-message'[^>]*>[^<]*<\/div>/g;
const quoteBoxes = renderedHtml.match(quoteBoxPattern) || [];
let textOnly = renderedHtml.replace(quoteBoxPattern, '|||QUOTEBOX|||');
if (textOnly.length > maxLength) {
const availableLength = maxLength - (quoteBoxes.join('').length);
if (availableLength > 50) {
textOnly = textOnly.slice(0, availableLength) + "...";
} else {
textOnly = textOnly.slice(0, 50) + "...";
}
}
let result = textOnly;
quoteBoxes.forEach(box => {
result = result.replace('|||QUOTEBOX|||', box);
});
return result;
} else {
if (renderedHtml.includes('<')) {
const truncated = renderedHtml.slice(0, maxLength);
const lastTagStart = truncated.lastIndexOf('<');
const lastTagEnd = truncated.lastIndexOf('>');
if (lastTagStart > lastTagEnd) {
return renderedHtml.slice(0, lastTagStart) + "...";
}
return truncated + "...";
} else {
return renderedHtml.slice(0, maxLength) + "...";
}
}
}
/**
* Parses content using basic markup parser
*/
export async function parseContent(content: string): Promise<string> {
if (!content) return "";
return await parseBasicmarkup(content);
}
/**
* Renders quoted content for a message
*/
export async function renderQuotedContent(message: NDKEvent, publicMessages: NDKEvent[]): Promise<string> {
const qTags = message.getMatchingTags("q");
if (qTags.length === 0) return "";
const qTag = qTags[0];
const eventId = qTag[1];
if (eventId) {
// First try to find in local messages
let quotedMessage = publicMessages.find(msg => msg.id === eventId);
// If not found locally, fetch from relays
if (!quotedMessage) {
try {
const ndk = get(ndkInstance);
if (ndk) {
const userStoreValue = get(userStore);
const user = userStoreValue.signedIn && userStoreValue.pubkey ? ndk.getUser({ pubkey: userStoreValue.pubkey }) : null;
const relaySet = await buildCompleteRelaySet(ndk, user);
const allRelays = [...relaySet.inboxRelays, ...relaySet.outboxRelays, ...searchRelays];
if (allRelays.length > 0) {
const ndkRelaySet = NDKRelaySetFromNDK.fromRelayUrls(allRelays, ndk);
const fetchedEvent = await ndk.fetchEvent({ ids: [eventId], limit: 1 }, undefined, ndkRelaySet);
quotedMessage = fetchedEvent || undefined;
}
}
} catch (error) {
console.warn(`[renderQuotedContent] Failed to fetch quoted event ${eventId}:`, error);
}
}
if (quotedMessage) {
const quotedContent = quotedMessage.content ? quotedMessage.content.slice(0, 200) : "No content";
const parsedContent = await parseBasicmarkup(quotedContent);
return `<div class="block w-fit my-2 px-3 py-2 bg-gray-200 dark:bg-gray-700 border-l-2 border-gray-400 dark:border-gray-500 rounded cursor-pointer hover:bg-gray-300 dark:hover:bg-gray-600 transition-colors text-sm text-gray-600 dark:text-gray-300" onclick="window.dispatchEvent(new CustomEvent('jump-to-message', { detail: '${eventId}' }))">${parsedContent}</div>`;
} else {
// Fallback to nevent link
const nevent = neventEncode({ id: eventId } as any, []);
return `<div class="block w-fit my-2 px-3 py-2 bg-gray-200 dark:bg-gray-700 border-l-2 border-gray-400 dark:border-gray-500 rounded cursor-pointer hover:bg-gray-300 dark:hover:bg-gray-600 transition-colors text-sm text-gray-600 dark:text-gray-300" onclick="window.location.href='/events?id=${nevent}'">Quoted message not found. Click to view event ${eventId.slice(0, 8)}...</div>`;
}
}
return "";
}
/**
* Gets notification type based on event kind
*/
export function getNotificationType(event: NDKEvent): string {
switch (event.kind) {
case 1: return "Reply";
case 1111: return "Custom Reply";
case 9802: return "Highlight";
case 6: return "Repost";
case 16: return "Generic Repost";
case 24: return "Public Message";
default: return `Kind ${event.kind}`;
}
}
/**
* Fetches author profiles for a list of events
*/
export async function fetchAuthorProfiles(events: NDKEvent[]): Promise<Map<string, { name?: string; displayName?: string; picture?: string }>> {
const authorProfiles = new Map<string, { name?: string; displayName?: string; picture?: string }>();
const uniquePubkeys = new Set<string>();
events.forEach(event => {
if (event.pubkey) uniquePubkeys.add(event.pubkey);
});
const profilePromises = Array.from(uniquePubkeys).map(async (pubkey) => {
try {
const npub = toNpub(pubkey);
if (!npub) return;
// Try cache first
let profile = await getUserMetadata(npub, false);
if (profile && (profile.name || profile.displayName || profile.picture)) {
authorProfiles.set(pubkey, profile);
return;
}
// Try search relays
for (const relay of searchRelays) {
try {
const ndk = get(ndkInstance);
if (!ndk) break;
const relaySet = NDKRelaySetFromNDK.fromRelayUrls([relay], ndk);
const profileEvent = await ndk.fetchEvent(
{ kinds: [0], authors: [pubkey] },
undefined,
relaySet
);
if (profileEvent) {
const profileData = JSON.parse(profileEvent.content);
authorProfiles.set(pubkey, {
name: profileData.name,
displayName: profileData.display_name || profileData.displayName,
picture: profileData.picture || profileData.image
});
return;
}
} catch (error) {
console.warn(`[fetchAuthorProfiles] Failed to fetch profile from ${relay}:`, error);
}
}
// Try all available relays as fallback
try {
const ndk = get(ndkInstance);
if (!ndk) return;
const userStoreValue = get(userStore);
const user = userStoreValue.signedIn && userStoreValue.pubkey ? ndk.getUser({ pubkey: userStoreValue.pubkey }) : null;
const relaySet = await buildCompleteRelaySet(ndk, user);
const allRelays = [...relaySet.inboxRelays, ...relaySet.outboxRelays];
if (allRelays.length > 0) {
const ndkRelaySet = NDKRelaySetFromNDK.fromRelayUrls(allRelays, ndk);
const profileEvent = await ndk.fetchEvent(
{ kinds: [0], authors: [pubkey] },
undefined,
ndkRelaySet
);
if (profileEvent) {
const profileData = JSON.parse(profileEvent.content);
authorProfiles.set(pubkey, {
name: profileData.name,
displayName: profileData.display_name || profileData.displayName,
picture: profileData.picture || profileData.image
});
}
}
} catch (error) {
console.warn(`[fetchAuthorProfiles] Failed to fetch profile from all relays:`, error);
}
} catch (error) {
console.warn(`[fetchAuthorProfiles] Failed to fetch profile for ${pubkey}:`, error);
}
});
await Promise.allSettled(profilePromises);
return authorProfiles;
}

44
src/lib/utils/npubCache.ts

@ -4,6 +4,47 @@ export type NpubMetadata = NostrProfile; @@ -4,6 +4,47 @@ export type NpubMetadata = NostrProfile;
class NpubCache {
private cache: Record<string, NpubMetadata> = {};
private readonly storageKey = 'alexandria_npub_cache';
private readonly maxAge = 24 * 60 * 60 * 1000; // 24 hours in milliseconds
constructor() {
this.loadFromStorage();
}
private loadFromStorage(): void {
try {
if (typeof window !== 'undefined') {
const stored = localStorage.getItem(this.storageKey);
if (stored) {
const data = JSON.parse(stored) as Record<string, { profile: NpubMetadata; timestamp: number }>;
const now = Date.now();
// Filter out expired entries
for (const [key, entry] of Object.entries(data)) {
if (entry.timestamp && (now - entry.timestamp) < this.maxAge) {
this.cache[key] = entry.profile;
}
}
}
}
} catch (error) {
console.warn('Failed to load npub cache from storage:', error);
}
}
private saveToStorage(): void {
try {
if (typeof window !== 'undefined') {
const data: Record<string, { profile: NpubMetadata; timestamp: number }> = {};
for (const [key, profile] of Object.entries(this.cache)) {
data[key] = { profile, timestamp: Date.now() };
}
localStorage.setItem(this.storageKey, JSON.stringify(data));
}
} catch (error) {
console.warn('Failed to save npub cache to storage:', error);
}
}
get(key: string): NpubMetadata | undefined {
return this.cache[key];
@ -11,6 +52,7 @@ class NpubCache { @@ -11,6 +52,7 @@ class NpubCache {
set(key: string, value: NpubMetadata): void {
this.cache[key] = value;
this.saveToStorage();
}
has(key: string): boolean {
@ -20,6 +62,7 @@ class NpubCache { @@ -20,6 +62,7 @@ class NpubCache {
delete(key: string): boolean {
if (key in this.cache) {
delete this.cache[key];
this.saveToStorage();
return true;
}
return false;
@ -37,6 +80,7 @@ class NpubCache { @@ -37,6 +80,7 @@ class NpubCache {
clear(): void {
this.cache = {};
this.saveToStorage();
}
size(): number {

27
src/lib/utils/profile_search.ts

@ -1,8 +1,8 @@ @@ -1,8 +1,8 @@
import { ndkInstance } from "../ndk.ts";
import { ndkInstance, activeInboxRelays } from "../ndk.ts";
import { getUserMetadata, getNpubFromNip05 } from "./nostrUtils.ts";
import NDK, { NDKRelaySet, NDKEvent } from "@nostr-dev-kit/ndk";
import { searchCache } from "./searchCache.ts";
import { communityRelays, secondaryRelays } from "../consts.ts";
import { searchRelays, communityRelays, secondaryRelays } from "../consts.ts";
import { get } from "svelte/store";
import type { NostrProfile, ProfileSearchResult } from "./search_types.ts";
import {
@ -264,12 +264,21 @@ async function quickRelaySearch( @@ -264,12 +264,21 @@ async function quickRelaySearch(
const normalizedSearchTerm = normalizeSearchTerm(searchTerm);
console.log("Normalized search term for relay search:", normalizedSearchTerm);
// Use all profile relays for better coverage
const quickRelayUrls = [...communityRelays, ...secondaryRelays]; // Use all available relays
console.log("Using all relays for search:", quickRelayUrls);
// Use search relays (optimized for profiles) + user's inbox relays + community relays
const userInboxRelays = get(activeInboxRelays);
const quickRelayUrls = [
...searchRelays, // Dedicated profile search relays
...userInboxRelays, // User's personal inbox relays
...communityRelays, // Community relays
...secondaryRelays // Secondary relays as fallback
];
// Deduplicate relay URLs
const uniqueRelayUrls = [...new Set(quickRelayUrls)];
console.log("Using relays for profile search:", uniqueRelayUrls);
// Create relay sets for parallel search
const relaySets = quickRelayUrls
const relaySets = uniqueRelayUrls
.map((url) => {
try {
return NDKRelaySet.fromRelayUrls([url], ndk);
@ -289,7 +298,7 @@ async function quickRelaySearch( @@ -289,7 +298,7 @@ async function quickRelaySearch(
let eventCount = 0;
console.log(
`Starting search on relay ${index + 1}: ${quickRelayUrls[index]}`,
`Starting search on relay ${index + 1}: ${uniqueRelayUrls[index]}`,
);
const sub = ndk.subscribe(
@ -354,7 +363,7 @@ async function quickRelaySearch( @@ -354,7 +363,7 @@ async function quickRelaySearch(
sub.on("eose", () => {
console.log(
`Relay ${index + 1} (${quickRelayUrls[index]}) search completed, processed ${eventCount} events, found ${foundInRelay.length} matches`,
`Relay ${index + 1} (${uniqueRelayUrls[index]}) search completed, processed ${eventCount} events, found ${foundInRelay.length} matches`,
);
resolve(foundInRelay);
});
@ -362,7 +371,7 @@ async function quickRelaySearch( @@ -362,7 +371,7 @@ async function quickRelaySearch(
// 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`,
`Relay ${index + 1} (${uniqueRelayUrls[index]}) search timed out after 1.5s, processed ${eventCount} events, found ${foundInRelay.length} matches`,
);
sub.stop();
resolve(foundInRelay);

166
src/lib/utils/relay_info_service.ts

@ -0,0 +1,166 @@ @@ -0,0 +1,166 @@
/**
* Simplifies a URL by removing protocol and common prefixes
* @param url The URL to simplify
* @returns Simplified URL string
*/
function simplifyUrl(url: string): string {
try {
const urlObj = new URL(url);
return urlObj.hostname + (urlObj.port ? `:${urlObj.port}` : '');
} catch {
// If URL parsing fails, return the original string
return url;
}
}
export interface RelayInfo {
name?: string;
description?: string;
icon?: string;
pubkey?: string;
contact?: string;
supported_nips?: number[];
software?: string;
version?: string;
tags?: string[];
payments_url?: string;
limitation?: {
auth_required?: boolean;
payment_required?: boolean;
};
}
export interface RelayInfoWithMetadata extends RelayInfo {
url: string;
shortUrl: string;
hasNip11: boolean;
triedNip11: boolean;
}
/**
* Fetches relay information using NIP-11
* @param url The relay URL to fetch info for
* @returns Promise resolving to relay info or undefined if failed
*/
export async function fetchRelayInfo(url: string): Promise<RelayInfoWithMetadata | undefined> {
try {
// Convert WebSocket URL to HTTP URL for NIP-11
const httpUrl = url.replace('ws://', 'http://').replace('wss://', 'https://');
const response = await fetch(httpUrl, {
headers: {
'Accept': 'application/nostr+json',
'User-Agent': 'Alexandria/1.0'
},
// Add timeout to prevent hanging
signal: AbortSignal.timeout(5000)
});
if (!response.ok) {
console.warn(`[RelayInfo] HTTP ${response.status} for ${url}`);
return {
url,
shortUrl: simplifyUrl(url),
hasNip11: false,
triedNip11: true
};
}
const relayInfo = await response.json() as RelayInfo;
return {
...relayInfo,
url,
shortUrl: simplifyUrl(url),
hasNip11: Object.keys(relayInfo).length > 0,
triedNip11: true
};
} catch (error) {
console.warn(`[RelayInfo] Failed to fetch info for ${url}:`, error);
return {
url,
shortUrl: simplifyUrl(url),
hasNip11: false,
triedNip11: true
};
}
}
/**
* Fetches relay information for multiple relays in parallel
* @param urls Array of relay URLs to fetch info for
* @returns Promise resolving to array of relay info objects
*/
export async function fetchRelayInfos(urls: string[]): Promise<RelayInfoWithMetadata[]> {
if (urls.length === 0) {
return [];
}
const promises = urls.map(url => fetchRelayInfo(url));
const results = await Promise.allSettled(promises);
return results
.map(result => result.status === 'fulfilled' ? result.value : undefined)
.filter((info): info is RelayInfoWithMetadata => info !== undefined);
}
/**
* Gets relay type label based on relay URL and info
* @param relayUrl The relay URL
* @param relayInfo Optional relay info
* @returns String describing the relay type
*/
export function getRelayTypeLabel(relayUrl: string, relayInfo?: RelayInfoWithMetadata): string {
// Check if it's a local relay
if (relayUrl.includes('localhost') || relayUrl.includes('127.0.0.1')) {
return 'Local';
}
// Check if it's a community relay
if (relayUrl.includes('nostr.band') || relayUrl.includes('noswhere.com') ||
relayUrl.includes('damus.io') || relayUrl.includes('nostr.wine')) {
return 'Community';
}
// Check if it's a user's relay (likely inbox/outbox)
if (relayUrl.includes('relay.nsec.app') || relayUrl.includes('relay.snort.social')) {
return 'User';
}
// Use relay name if available
if (relayInfo?.name) {
return relayInfo.name;
}
// Fallback to domain
try {
const domain = new URL(relayUrl).hostname;
return domain.replace('www.', '');
} catch {
return 'Unknown';
}
}
/**
* Gets relay icon URL or fallback
* @param relayInfo Relay info object
* @param relayUrl Relay URL as fallback
* @returns Icon URL or undefined
*/
export function getRelayIcon(relayInfo?: RelayInfoWithMetadata, relayUrl?: string): string | undefined {
if (relayInfo?.icon) {
return relayInfo.icon;
}
// Generate favicon URL from relay URL
if (relayUrl) {
try {
const url = new URL(relayUrl);
return `${url.protocol}//${url.hostname}/favicon.ico`;
} catch {
// Invalid URL, return undefined
}
}
return undefined;
}

7
src/lib/utils/search_constants.ts

@ -17,7 +17,7 @@ export const TIMEOUTS = { @@ -17,7 +17,7 @@ export const TIMEOUTS = {
SUBSCRIPTION_SEARCH: 10000,
/** Timeout for second-order search operations */
SECOND_ORDER_SEARCH: 5000,
SECOND_ORDER_SEARCH: 3000, // AI-NOTE: 2025-01-24 - Reduced timeout since we limit scope
/** Timeout for relay diagnostics */
RELAY_DIAGNOSTICS: 5000,
@ -44,7 +44,10 @@ export const SEARCH_LIMITS = { @@ -44,7 +44,10 @@ export const SEARCH_LIMITS = {
SPECIFIC_PROFILE: 10,
/** Limit for general profile searches */
GENERAL_PROFILE: 500,
GENERAL_PROFILE: 100, // AI-NOTE: 2025-01-24 - Reduced from 500 to prevent wild searches
/** Limit for general content searches (t-tag, d-tag, etc.) */
GENERAL_CONTENT: 100, // AI-NOTE: 2025-01-24 - Added limit for all content searches
/** Limit for community relay checks */
COMMUNITY_CHECK: 1,

229
src/lib/utils/subscription_search.ts

@ -26,6 +26,17 @@ const normalizeUrl = (url: string): string => { @@ -26,6 +26,17 @@ const normalizeUrl = (url: string): string => {
return url.replace(/\/$/, ''); // Remove trailing slash
};
/**
* Filter out unwanted events from search results
* @param events Array of NDKEvent to filter
* @returns Filtered array of NDKEvent
*/
function filterUnwantedEvents(events: NDKEvent[]): NDKEvent[] {
return events.filter(
(event) => !isEmojiReaction(event) && event.kind !== 3 && event.kind !== 5,
);
}
/**
* Search for events by subscription type (d, t, n)
*/
@ -35,6 +46,7 @@ export async function searchBySubscription( @@ -35,6 +46,7 @@ export async function searchBySubscription(
callbacks?: SearchCallbacks,
abortSignal?: AbortSignal,
): Promise<SearchResult> {
const startTime = Date.now(); // AI-NOTE: 2025-01-08 - Track search performance
const normalizedSearchTerm = searchTerm.toLowerCase().trim();
console.log("subscription_search: Starting search:", {
@ -47,7 +59,14 @@ export async function searchBySubscription( @@ -47,7 +59,14 @@ export async function searchBySubscription(
const cachedResult = searchCache.get(searchType, normalizedSearchTerm);
if (cachedResult) {
console.log("subscription_search: Found cached result:", cachedResult);
// AI-NOTE: 2025-01-24 - For profile searches, return cached results immediately
// The EventSearch component now handles cache checking before calling this function
if (searchType === "n") {
console.log("subscription_search: Returning cached profile result immediately");
return cachedResult;
} else {
return cachedResult;
}
}
const ndk = get(ndkInstance);
@ -64,7 +83,7 @@ export async function searchBySubscription( @@ -64,7 +83,7 @@ export async function searchBySubscription(
searchState.timeoutId = setTimeout(() => {
console.log("subscription_search: Search timeout reached");
cleanup();
}, TIMEOUTS.SUBSCRIPTION_SEARCH);
}, TIMEOUTS.SUBSCRIPTION_SEARCH); // AI-NOTE: 2025-01-24 - Use standard timeout since cache is checked first
// Check for abort signal
if (abortSignal?.aborted) {
@ -125,7 +144,26 @@ export async function searchBySubscription( @@ -125,7 +144,26 @@ export async function searchBySubscription(
);
searchCache.set(searchType, normalizedSearchTerm, immediateResult);
// Start Phase 2 in background for additional results
// AI-NOTE: 2025-01-08 - For profile searches, return immediately when found
// but still start background search for second-order results
if (searchType === "n") {
console.log("subscription_search: Profile found, returning immediately but starting background second-order search");
// Start Phase 2 in background for second-order results
searchOtherRelaysInBackground(
searchType,
searchFilter,
searchState,
callbacks,
cleanup,
);
const elapsed = Date.now() - startTime;
console.log(`subscription_search: Profile search completed in ${elapsed}ms`);
return immediateResult;
}
// Start Phase 2 in background for additional results (only for non-profile searches)
searchOtherRelaysInBackground(
searchType,
searchFilter,
@ -135,11 +173,75 @@ export async function searchBySubscription( @@ -135,11 +173,75 @@ export async function searchBySubscription(
);
return immediateResult;
} else {
console.log(
"subscription_search: No results from primary relay",
);
// AI-NOTE: 2025-01-08 - For profile searches, if no results found in search relays,
// try all relays as fallback
if (searchType === "n") {
console.log(
"subscription_search: No profile found in search relays, trying all relays",
);
// Try with all relays as fallback
const allRelaySet = new NDKRelaySet(new Set(Array.from(ndk.pool.relays.values())) as any, ndk);
try {
const fallbackEvents = await ndk.fetchEvents(
searchFilter.filter,
{ closeOnEose: true },
allRelaySet,
);
console.log(
"subscription_search: Fallback search returned",
fallbackEvents.size,
"events",
);
processPrimaryRelayResults(
fallbackEvents,
searchType,
searchFilter.subscriptionType,
normalizedSearchTerm,
searchState,
abortSignal,
cleanup,
);
if (hasResults(searchState, searchType)) {
console.log(
"subscription_search: Found profile in fallback search, returning immediately",
);
const fallbackResult = createSearchResult(
searchState,
searchType,
normalizedSearchTerm,
);
searchCache.set(searchType, normalizedSearchTerm, fallbackResult);
const elapsed = Date.now() - startTime;
console.log(`subscription_search: Profile search completed in ${elapsed}ms (fallback)`);
return fallbackResult;
}
} catch (fallbackError) {
console.error("subscription_search: Fallback search failed:", fallbackError);
}
console.log(
"subscription_search: Profile not found in any relays, returning empty result",
);
const emptyResult = createEmptySearchResult(searchType, normalizedSearchTerm);
// AI-NOTE: 2025-01-08 - Don't cache empty profile results as they may be due to search issues
// rather than the profile not existing
const elapsed = Date.now() - startTime;
console.log(`subscription_search: Profile search completed in ${elapsed}ms (not found)`);
return emptyResult;
} else {
console.log(
"subscription_search: No results from primary relay, continuing to Phase 2",
);
}
}
} catch (error) {
console.error(
`subscription_search: Error searching primary relay:`,
@ -153,13 +255,21 @@ export async function searchBySubscription( @@ -153,13 +255,21 @@ export async function searchBySubscription(
}
// Always do Phase 2: Search all other relays in parallel
return searchOtherRelaysInBackground(
const result = await searchOtherRelaysInBackground(
searchType,
searchFilter,
searchState,
callbacks,
cleanup,
);
// AI-NOTE: 2025-01-08 - Log performance for non-profile searches
if (searchType !== "n") {
const elapsed = Date.now() - startTime;
console.log(`subscription_search: ${searchType} search completed in ${elapsed}ms`);
}
return result;
}
/**
@ -214,7 +324,7 @@ async function createSearchFilter( @@ -214,7 +324,7 @@ async function createSearchFilter(
switch (searchType) {
case "d": {
const dFilter = {
filter: { "#d": [normalizedSearchTerm] },
filter: { "#d": [normalizedSearchTerm], limit: SEARCH_LIMITS.GENERAL_CONTENT },
subscriptionType: "d-tag",
};
console.log("subscription_search: Created d-tag filter:", dFilter);
@ -222,7 +332,7 @@ async function createSearchFilter( @@ -222,7 +332,7 @@ async function createSearchFilter(
}
case "t": {
const tFilter = {
filter: { "#t": [normalizedSearchTerm] },
filter: { "#t": [normalizedSearchTerm], limit: SEARCH_LIMITS.GENERAL_CONTENT },
subscriptionType: "t-tag",
};
console.log("subscription_search: Created t-tag filter:", tFilter);
@ -253,7 +363,7 @@ async function createProfileSearchFilter( @@ -253,7 +363,7 @@ async function createProfileSearchFilter(
filter: {
kinds: [0],
authors: [decoded.data],
limit: SEARCH_LIMITS.SPECIFIC_PROFILE,
limit: 1, // AI-NOTE: 2025-01-08 - Only need 1 result for specific npub search
},
subscriptionType: "npub-specific",
};
@ -273,7 +383,7 @@ async function createProfileSearchFilter( @@ -273,7 +383,7 @@ async function createProfileSearchFilter(
filter: {
kinds: [0],
authors: [npub],
limit: SEARCH_LIMITS.SPECIFIC_PROFILE,
limit: 1, // AI-NOTE: 2025-01-08 - Only need 1 result for specific npub search
},
subscriptionType: "nip05-found",
};
@ -299,31 +409,38 @@ function createPrimaryRelaySet( @@ -299,31 +409,38 @@ function createPrimaryRelaySet(
searchType: SearchSubscriptionType,
ndk: any,
): NDKRelaySet {
// Use the new relay management system
const searchRelays = [...get(activeInboxRelays), ...get(activeOutboxRelays)];
console.debug('subscription_search: Active relay stores:', {
inboxRelays: get(activeInboxRelays),
outboxRelays: get(activeOutboxRelays),
searchRelays
});
// Debug: Log all relays in NDK pool
const poolRelays = Array.from(ndk.pool.relays.values());
console.debug('subscription_search: NDK pool relays:', poolRelays.map((r: any) => r.url));
if (searchType === "n") {
// For profile searches, use search relays first
const profileRelaySet = poolRelays.filter(
// AI-NOTE: 2025-01-08 - For profile searches, prioritize search relays for speed
// Use search relays first, then fall back to all relays if needed
const searchRelaySet = poolRelays.filter(
(relay: any) =>
searchRelays.some(
(searchRelay: string) =>
normalizeUrl(relay.url) === normalizeUrl(searchRelay),
),
);
console.debug('subscription_search: Profile relay set:', profileRelaySet.map((r: any) => r.url));
return new NDKRelaySet(new Set(profileRelaySet) as any, ndk);
if (searchRelaySet.length > 0) {
console.debug('subscription_search: Profile search - using search relays for speed:', searchRelaySet.map((r: any) => r.url));
return new NDKRelaySet(new Set(searchRelaySet) as any, ndk);
} else {
// Fallback to all relays if search relays not available
console.debug('subscription_search: Profile search - fallback to all relays:', poolRelays.map((r: any) => r.url));
return new NDKRelaySet(new Set(poolRelays) as any, ndk);
}
} else {
// For other searches, use active relays first
const searchRelays = [...get(activeInboxRelays), ...get(activeOutboxRelays)];
console.debug('subscription_search: Active relay stores:', {
inboxRelays: get(activeInboxRelays),
outboxRelays: get(activeOutboxRelays),
searchRelays
});
const activeRelaySet = poolRelays.filter(
(relay: any) =>
searchRelays.some(
@ -534,11 +651,9 @@ function searchOtherRelaysInBackground( @@ -534,11 +651,9 @@ function searchOtherRelaysInBackground(
new Set(
Array.from(ndk.pool.relays.values()).filter((relay: any) => {
if (searchType === "n") {
// For profile searches, exclude search relays from fallback search
return !searchRelays.some(
(searchRelay: string) =>
normalizeUrl(relay.url) === normalizeUrl(searchRelay),
);
// AI-NOTE: 2025-01-08 - For profile searches, use ALL available relays
// Don't exclude any relays since we want maximum coverage
return true;
} else {
// For other searches, exclude community relays from fallback search
return !communityRelays.some(
@ -652,6 +767,7 @@ function processProfileEoseResults( @@ -652,6 +767,7 @@ function processProfileEoseResults(
) {
const targetPubkey = dedupedProfiles[0]?.pubkey;
if (targetPubkey) {
console.log("subscription_search: Triggering second-order search for npub-specific profile:", targetPubkey);
performSecondOrderSearchInBackground(
"n",
dedupedProfiles,
@ -660,11 +776,14 @@ function processProfileEoseResults( @@ -660,11 +776,14 @@ function processProfileEoseResults(
targetPubkey,
callbacks,
);
} else {
console.log("subscription_search: No targetPubkey found for second-order search");
}
} 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) {
console.log("subscription_search: Triggering second-order search for general profile:", profile.pubkey);
performSecondOrderSearchInBackground(
"n",
dedupedProfiles,
@ -675,6 +794,8 @@ function processProfileEoseResults( @@ -675,6 +794,8 @@ function processProfileEoseResults(
);
}
}
} else {
console.log("subscription_search: No second-order search triggered for subscription type:", searchFilter.subscriptionType);
}
return {
@ -784,6 +905,7 @@ async function performSecondOrderSearchInBackground( @@ -784,6 +905,7 @@ async function performSecondOrderSearchInBackground(
callbacks?: SearchCallbacks,
) {
try {
console.log("subscription_search: Starting second-order search for", searchType, "with targetPubkey:", targetPubkey);
const ndk = get(ndkInstance);
let allSecondOrderEvents: NDKEvent[] = [];
@ -797,18 +919,46 @@ async function performSecondOrderSearchInBackground( @@ -797,18 +919,46 @@ async function performSecondOrderSearchInBackground(
const searchPromise = (async () => {
if (searchType === "n" && targetPubkey) {
console.log("subscription_search: Searching for events mentioning pubkey:", targetPubkey);
// AI-NOTE: 2025-01-24 - Use only active relays for second-order profile search to prevent hanging
const activeRelays = [...get(activeInboxRelays), ...get(activeOutboxRelays)];
const availableRelays = activeRelays
.map(url => ndk.pool.relays.get(url))
.filter((relay): relay is any => relay !== undefined);
const relaySet = new NDKRelaySet(
new Set(availableRelays),
ndk
);
console.log("subscription_search: Using", activeRelays.length, "active relays for second-order search");
// Search for events that mention this pubkey via p-tags
const pTagFilter = { "#p": [targetPubkey] };
const pTagFilter = { "#p": [targetPubkey], limit: 50 }; // AI-NOTE: 2025-01-24 - Limit results to prevent hanging
const pTagEvents = await ndk.fetchEvents(
pTagFilter,
{ closeOnEose: true },
new NDKRelaySet(new Set(Array.from(ndk.pool.relays.values())), ndk),
relaySet,
);
// Filter out emoji reactions
const filteredEvents = Array.from(pTagEvents).filter(
(event) => !isEmojiReaction(event),
console.log("subscription_search: Found", pTagEvents.size, "events with p-tag for", targetPubkey);
// AI-NOTE: 2025-01-24 - Also search for events written by this pubkey with limit
const authorFilter = { authors: [targetPubkey], limit: 50 }; // AI-NOTE: 2025-01-24 - Limit results to prevent hanging
const authorEvents = await ndk.fetchEvents(
authorFilter,
{ closeOnEose: true },
relaySet,
);
allSecondOrderEvents = [...allSecondOrderEvents, ...filteredEvents];
console.log("subscription_search: Found", authorEvents.size, "events written by", targetPubkey);
// Filter out unwanted events from both sets
const filteredPTagEvents = filterUnwantedEvents(Array.from(pTagEvents));
const filteredAuthorEvents = filterUnwantedEvents(Array.from(authorEvents));
console.log("subscription_search: After filtering unwanted events:", filteredPTagEvents.length, "p-tag events,", filteredAuthorEvents.length, "author events");
// Combine both sets of events
allSecondOrderEvents = [...filteredPTagEvents, ...filteredAuthorEvents];
} else if (searchType === "d") {
// Parallel fetch for #e and #a tag events
const relaySet = new NDKRelaySet(
@ -818,26 +968,22 @@ async function performSecondOrderSearchInBackground( @@ -818,26 +968,22 @@ async function performSecondOrderSearchInBackground(
const [eTagEvents, aTagEvents] = await Promise.all([
eventIds.size > 0
? ndk.fetchEvents(
{ "#e": Array.from(eventIds) },
{ "#e": Array.from(eventIds), limit: SEARCH_LIMITS.SECOND_ORDER_RESULTS },
{ closeOnEose: true },
relaySet,
)
: Promise.resolve([]),
addresses.size > 0
? ndk.fetchEvents(
{ "#a": Array.from(addresses) },
{ "#a": Array.from(addresses), limit: SEARCH_LIMITS.SECOND_ORDER_RESULTS },
{ closeOnEose: true },
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),
);
// Filter out unwanted events
const filteredETagEvents = filterUnwantedEvents(Array.from(eTagEvents));
const filteredATagEvents = filterUnwantedEvents(Array.from(aTagEvents));
allSecondOrderEvents = [
...allSecondOrderEvents,
...filteredETagEvents,
@ -866,6 +1012,8 @@ async function performSecondOrderSearchInBackground( @@ -866,6 +1012,8 @@ async function performSecondOrderSearchInBackground(
.sort((a, b) => (b.created_at || 0) - (a.created_at || 0))
.slice(0, SEARCH_LIMITS.SECOND_ORDER_RESULTS);
console.log("subscription_search: Second-order search completed with", sortedSecondOrder.length, "results");
// Update the search results with second-order events
const result: SearchResult = {
events: firstOrderEvents,
@ -882,7 +1030,10 @@ async function performSecondOrderSearchInBackground( @@ -882,7 +1030,10 @@ async function performSecondOrderSearchInBackground(
// Notify UI of updated results
if (callbacks?.onSecondOrderUpdate) {
console.log("subscription_search: Calling onSecondOrderUpdate callback with", sortedSecondOrder.length, "second-order events");
callbacks.onSecondOrderUpdate(result);
} else {
console.log("subscription_search: No onSecondOrderUpdate callback available");
}
})();

28
src/routes/+layout.svelte

@ -5,7 +5,10 @@ @@ -5,7 +5,10 @@
import { page } from "$app/stores";
import { Alert } from "flowbite-svelte";
import { HammerSolid } from "flowbite-svelte-icons";
import { logCurrentRelayConfiguration } from "$lib/ndk";
import { logCurrentRelayConfiguration, activeInboxRelays, activeOutboxRelays } from "$lib/ndk";
// Define children prop for Svelte 5
let { children } = $props();
// Get standard metadata for OpenGraph tags
let title = "Library of Alexandria";
@ -16,12 +19,27 @@ @@ -16,12 +19,27 @@
let summary =
"Alexandria is a digital library, utilizing Nostr events for curated publications and wiki pages.";
// AI-NOTE: Refactored to avoid blocking $effect with logging operations
// Reactive effect to log relay configuration when stores change - non-blocking approach
$effect.pre(() => {
const inboxRelays = $activeInboxRelays;
const outboxRelays = $activeOutboxRelays;
// Only log if we have relays (not empty arrays)
if (inboxRelays.length > 0 || outboxRelays.length > 0) {
// Defer logging to avoid blocking the reactive system
requestAnimationFrame(() => {
console.log('🔌 Relay Configuration Updated:');
console.log('📥 Inbox Relays:', inboxRelays);
console.log('📤 Outbox Relays:', outboxRelays);
console.log(`📊 Total: ${inboxRelays.length} inbox, ${outboxRelays.length} outbox`);
});
}
});
onMount(() => {
const rect = document.body.getBoundingClientRect();
// document.body.style.height = `${rect.height}px`;
// Log relay configuration when layout mounts
logCurrentRelayConfiguration();
});
</script>
@ -47,5 +65,5 @@ @@ -47,5 +65,5 @@
<div class={"leather mt-[76px] w-full mx-auto flex flex-col items-center"}>
<Navigation class="fixed top-0" />
<slot />
{@render children()}
</div>

10
src/routes/contact/+page.svelte

@ -11,7 +11,7 @@ @@ -11,7 +11,7 @@
} from "flowbite-svelte";
import { ndkInstance, ndkSignedIn, activeInboxRelays, activeOutboxRelays } from "$lib/ndk";
import { userStore } from "$lib/stores/userStore";
import { communityRelays } from "$lib/consts";
import { communityRelays, anonymousRelays } from "$lib/consts";
import type NDK from "@nostr-dev-kit/ndk";
import { NDKEvent, NDKRelaySet } from "@nostr-dev-kit/ndk";
// @ts-ignore - Workaround for Svelte component import issue
@ -62,13 +62,11 @@ @@ -62,13 +62,11 @@
const repoAddress =
"naddr1qvzqqqrhnypzplfq3m5v3u5r0q9f255fdeyz8nyac6lagssx8zy4wugxjs8ajf7pqy88wumn8ghj7mn0wvhxcmmv9uqq5stvv4uxzmnywf5kz2elajr";
// Use the new relay management system instead of hardcoded relays
// Use the new relay management system with anonymous relays as fallbacks
const allRelays = [
"wss://relay.damus.io",
"wss://relay.nostr.band",
"wss://nos.lol",
...$activeInboxRelays,
...$activeOutboxRelays,
...anonymousRelays,
];
// Hard-coded repository owner pubkey and ID from the task
@ -213,7 +211,7 @@ @@ -213,7 +211,7 @@
...(ndk.pool
? Array.from(ndk.pool.relays.values())
.filter(
(relay) => relay.url && !relay.url.includes("wss://nos.lol"),
(relay) => relay.url,
)
.map((relay) => normalizeRelayUrl(relay.url))
: []),

259
src/routes/events/+page.svelte

@ -8,9 +8,10 @@ @@ -8,9 +8,10 @@
import EventDetails from "$lib/components/EventDetails.svelte";
import RelayActions from "$lib/components/RelayActions.svelte";
import CommentBox from "$lib/components/CommentBox.svelte";
import CommentViewer from "$lib/components/CommentViewer.svelte";
import { userStore } from "$lib/stores/userStore";
import { userBadge } from "$lib/snippets/UserSnippets.svelte";
import { getMatchingTags, toNpub } from "$lib/utils/nostrUtils";
import { getMatchingTags, toNpub, getUserMetadata } from "$lib/utils/nostrUtils";
import EventInput from "$lib/components/EventInput.svelte";
import { userPubkey, isLoggedIn } from "$lib/stores/authStore.Svelte";
import CopyToClipboard from "$lib/components/util/CopyToClipboard.svelte";
@ -74,6 +75,25 @@ @@ -74,6 +75,25 @@
} else {
profile = null;
}
// AI-NOTE: 2025-01-24 - Ensure profile is cached for the event author
if (newEvent.pubkey) {
cacheProfileForPubkey(newEvent.pubkey);
}
}
// AI-NOTE: 2025-01-24 - Function to ensure profile is cached for a pubkey
async function cacheProfileForPubkey(pubkey: string) {
try {
const npub = toNpub(pubkey);
if (npub) {
// Force fetch to ensure profile is cached
await getUserMetadata(npub, true);
console.log(`[Events Page] Cached profile for pubkey: ${pubkey}`);
}
} catch (error) {
console.warn(`[Events Page] Failed to cache profile for ${pubkey}:`, error);
}
}
// Use Svelte 5 idiomatic effect to update searchValue when $page.url.searchParams.get('id') changes
@ -150,21 +170,27 @@ @@ -150,21 +170,27 @@
searchInProgress =
loading || (results.length > 0 && secondOrder.length === 0);
// Show second-order search message when we have first-order results but no second-order yet
// AI-NOTE: 2025-01-08 - Only show second-order search message if we're actually searching
// Don't show it for cached results that have no second-order events
if (
results.length > 0 &&
secondOrder.length === 0 &&
searchTypeParam === "n"
searchTypeParam === "n" &&
!loading // Only show message if we're actively searching, not for cached results
) {
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"
searchTypeParam === "d" &&
!loading // Only show message if we're actively searching, not for cached results
) {
secondOrderSearchMessage = `Found ${results.length} event(s). Starting second-order search for events referencing these events...`;
} else if (secondOrder.length > 0) {
secondOrderSearchMessage = null;
} else {
// Clear message if we have results but no second-order search is happening
secondOrderSearchMessage = null;
}
// Check community status for all search results
@ -178,11 +204,32 @@ @@ -178,11 +204,32 @@
checkCommunityStatusForResults(tTagEvents);
}
// AI-NOTE: 2025-01-24 - Cache profiles for all search results
cacheProfilesForEvents([...results, ...secondOrder, ...tTagEvents]);
// Don't clear the current event - let the user continue viewing it
// event = null;
// profile = null;
}
// AI-NOTE: 2025-01-24 - Function to cache profiles for multiple events
async function cacheProfilesForEvents(events: NDKEvent[]) {
const uniquePubkeys = new Set<string>();
events.forEach(event => {
if (event.pubkey) {
uniquePubkeys.add(event.pubkey);
}
});
console.log(`[Events Page] Caching profiles for ${uniquePubkeys.size} unique pubkeys`);
// Cache profiles in parallel
const cachePromises = Array.from(uniquePubkeys).map(pubkey => cacheProfileForPubkey(pubkey));
await Promise.allSettled(cachePromises);
console.log(`[Events Page] Profile caching complete`);
}
function handleClear() {
searchType = null;
searchTerm = null;
@ -226,48 +273,47 @@ @@ -226,48 +273,47 @@
originalEventIds: Set<string>,
originalAddresses: Set<string>,
): string {
// Check if this event has e-tags referencing original events
const eTags = getMatchingTags(event, "e");
for (const tag of eTags) {
if (originalEventIds.has(tag[1])) {
return "Reply/Reference (e-tag)";
}
}
// Check if this event has a-tags or e-tags referencing original events
let tags = getMatchingTags(event, "a");
if (tags.length === 0) {
tags = getMatchingTags(event, "e");
}
const eTags = event.getMatchingTags("e");
const aTags = event.getMatchingTags("a");
for (const tag of tags) {
if (originalAddresses.has(tag[1])) {
return "Reply/Reference (a-tag)";
if (eTags.length > 0) {
const referencedEventId = eTags[eTags.length - 1][1];
if (originalEventIds.has(referencedEventId)) {
return "Reply";
}
}
// Check if this event has content references
if (event.content) {
for (const id of originalEventIds) {
const neventPattern = new RegExp(`nevent1[a-z0-9]{50,}`, "i");
const notePattern = new RegExp(`note1[a-z0-9]{50,}`, "i");
if (
neventPattern.test(event.content) ||
notePattern.test(event.content)
) {
return "Content Reference";
if (aTags.length > 0) {
const referencedAddress = aTags[aTags.length - 1][1];
if (originalAddresses.has(referencedAddress)) {
return "Quote";
}
}
for (const address of originalAddresses) {
const naddrPattern = new RegExp(`naddr1[a-z0-9]{50,}`, "i");
if (naddrPattern.test(event.content)) {
return "Content Reference";
}
return "Reference";
}
// AI-NOTE: 2025-01-24 - Function to parse profile content from kind 0 events
function parseProfileContent(event: NDKEvent): {
name?: string;
display_name?: string;
about?: string;
picture?: string;
banner?: string;
website?: string;
lud16?: string;
nip05?: string;
} | null {
if (event.kind !== 0 || !event.content) {
return null;
}
return "Reference";
try {
return JSON.parse(event.content);
} catch (error) {
console.warn("Failed to parse profile content:", error);
return null;
}
}
function getNeventUrl(event: NDKEvent): string {
@ -346,9 +392,22 @@ @@ -346,9 +392,22 @@
// Log relay configuration when page mounts
onMount(() => {
logCurrentRelayConfiguration();
// AI-NOTE: Refactored to avoid blocking $effect with logging operations
// Reactive effect to log relay configuration when stores change - non-blocking approach
$effect.pre(() => {
const inboxRelays = $activeInboxRelays;
const outboxRelays = $activeOutboxRelays;
// Only log if we have relays (not empty arrays)
if (inboxRelays.length > 0 || outboxRelays.length > 0) {
// Defer logging to avoid blocking the reactive system
requestAnimationFrame(() => {
console.log('🔌 Events Page - Relay Configuration Updated:');
console.log('📥 Inbox Relays:', inboxRelays);
console.log('📤 Outbox Relays:', outboxRelays);
console.log(`📊 Total: ${inboxRelays.length} inbox, ${outboxRelays.length} outbox`);
});
}
});
</script>
@ -398,19 +457,22 @@ @@ -398,19 +457,22 @@
{#if searchResults.length > 0}
<div class="mt-8">
<Heading tag="h2" class="h-leather mb-4">
<Heading tag="h2" class="h-leather mb-4 break-words">
{#if searchType === "n"}
Search Results for name: "{searchTerm}" ({searchResults.length} profiles)
Search Results for name: "{searchTerm && searchTerm.length > 50 ? searchTerm.slice(0, 50) + '...' : searchTerm || ''}" ({searchResults.length} profiles)
{:else if searchType === "t"}
Search Results for t-tag: "{searchTerm}" ({searchResults.length}
Search Results for t-tag: "{searchTerm && searchTerm.length > 50 ? searchTerm.slice(0, 50) + '...' : searchTerm || ''}" ({searchResults.length}
events)
{:else}
Search Results for d-tag: "{searchTerm ||
dTagValue?.toLowerCase()}" ({searchResults.length} events)
Search Results for d-tag: "{(() => {
const term = searchTerm || dTagValue?.toLowerCase() || '';
return term.length > 50 ? term.slice(0, 50) + '...' : term;
})()}" ({searchResults.length} events)
{/if}
</Heading>
<div class="space-y-4">
{#each searchResults as result, index}
{@const profileData = parseProfileContent(result)}
<button
class="w-full text-left border border-gray-300 dark:border-gray-600 rounded-lg p-4 bg-white dark:bg-primary-900/70 hover:bg-gray-100 dark:hover:bg-primary-800 focus:bg-gray-100 dark:focus:bg-primary-800 focus:outline-none focus:ring-2 focus:ring-primary-500 transition-colors overflow-hidden"
onclick={() => handleEventFound(result)}
@ -445,7 +507,7 @@ @@ -445,7 +507,7 @@
<span class="text-xs text-gray-600 dark:text-gray-400">
{@render userBadge(
toNpub(result.pubkey) as string,
undefined,
profileData?.display_name || profileData?.name,
)}
</span>
<span
@ -458,6 +520,38 @@ @@ -458,6 +520,38 @@
: "Unknown date"}
</span>
</div>
{#if result.kind === 0 && profileData}
<div class="flex items-center gap-3 mb-2">
{#if profileData.picture}
<img
src={profileData.picture}
alt="Profile"
class="w-12 h-12 rounded-full object-cover border border-gray-200 dark:border-gray-600"
onerror={(e) => {
(e.target as HTMLImageElement).style.display = 'none';
}}
/>
{:else}
<div class="w-12 h-12 rounded-full bg-gray-300 dark:bg-gray-600 flex items-center justify-center border border-gray-200 dark:border-gray-600">
<span class="text-lg font-medium text-gray-600 dark:text-gray-300">
{(profileData.display_name || profileData.name || result.pubkey.slice(0, 1)).toUpperCase()}
</span>
</div>
{/if}
<div class="flex flex-col min-w-0 flex-1">
{#if profileData.display_name || profileData.name}
<span class="font-medium text-gray-900 dark:text-gray-100 truncate">
{profileData.display_name || profileData.name}
</span>
{/if}
{#if profileData.about}
<span class="text-sm text-gray-600 dark:text-gray-400 line-clamp-2">
{profileData.about}
</span>
{/if}
</div>
</div>
{:else}
{#if getSummary(result)}
<div
class="text-sm text-primary-900 dark:text-primary-200 mb-1 line-clamp-2"
@ -511,6 +605,7 @@ @@ -511,6 +605,7 @@
: ""}
</div>
{/if}
{/if}
</div>
</button>
{/each}
@ -535,6 +630,7 @@ @@ -535,6 +630,7 @@
</P>
<div class="space-y-4">
{#each secondOrderResults as result, index}
{@const profileData = parseProfileContent(result)}
<button
class="w-full text-left border border-gray-300 dark:border-gray-600 rounded-lg p-4 bg-gray-50 dark:bg-primary-800/50 hover:bg-gray-100 dark:hover:bg-primary-700 focus:bg-gray-100 dark:focus:bg-primary-700 focus:outline-none focus:ring-2 focus:ring-primary-500 transition-colors overflow-hidden"
onclick={() => handleEventFound(result)}
@ -568,7 +664,7 @@ @@ -568,7 +664,7 @@
<span class="text-xs text-gray-600 dark:text-gray-400">
{@render userBadge(
toNpub(result.pubkey) as string,
undefined,
profileData?.display_name || profileData?.name,
)}
</span>
<span
@ -588,6 +684,38 @@ @@ -588,6 +684,38 @@
originalAddresses,
)}
</div>
{#if result.kind === 0 && profileData}
<div class="flex items-center gap-3 mb-2">
{#if profileData.picture}
<img
src={profileData.picture}
alt="Profile"
class="w-12 h-12 rounded-full object-cover border border-gray-200 dark:border-gray-600"
onerror={(e) => {
(e.target as HTMLImageElement).style.display = 'none';
}}
/>
{:else}
<div class="w-12 h-12 rounded-full bg-gray-300 dark:bg-gray-600 flex items-center justify-center border border-gray-200 dark:border-gray-600">
<span class="text-lg font-medium text-gray-600 dark:text-gray-300">
{(profileData.display_name || profileData.name || result.pubkey.slice(0, 1)).toUpperCase()}
</span>
</div>
{/if}
<div class="flex flex-col min-w-0 flex-1">
{#if profileData.display_name || profileData.name}
<span class="font-medium text-gray-900 dark:text-gray-100 truncate">
{profileData.display_name || profileData.name}
</span>
{/if}
{#if profileData.about}
<span class="text-sm text-gray-600 dark:text-gray-400 line-clamp-2">
{profileData.about}
</span>
{/if}
</div>
</div>
{:else}
{#if getSummary(result)}
<div
class="text-sm text-primary-900 dark:text-primary-200 mb-1 line-clamp-2"
@ -641,6 +769,7 @@ @@ -641,6 +769,7 @@
: ""}
</div>
{/if}
{/if}
</div>
</button>
{/each}
@ -659,6 +788,7 @@ @@ -659,6 +788,7 @@
</P>
<div class="space-y-4">
{#each tTagResults as result, index}
{@const profileData = parseProfileContent(result)}
<button
class="w-full text-left border border-gray-300 dark:border-gray-600 rounded-lg p-4 bg-gray-50 dark:bg-primary-800/50 hover:bg-gray-100 dark:hover:bg-primary-700 focus:bg-gray-100 dark:focus:bg-primary-700 focus:outline-none focus:ring-2 focus:ring-primary-500 transition-colors overflow-hidden"
onclick={() => handleEventFound(result)}
@ -692,7 +822,7 @@ @@ -692,7 +822,7 @@
<span class="text-xs text-gray-600 dark:text-gray-400">
{@render userBadge(
toNpub(result.pubkey) as string,
undefined,
profileData?.display_name || profileData?.name,
)}
</span>
<span
@ -705,6 +835,38 @@ @@ -705,6 +835,38 @@
: "Unknown date"}
</span>
</div>
{#if result.kind === 0 && profileData}
<div class="flex items-center gap-3 mb-2">
{#if profileData.picture}
<img
src={profileData.picture}
alt="Profile"
class="w-12 h-12 rounded-full object-cover border border-gray-200 dark:border-gray-600"
onerror={(e) => {
(e.target as HTMLImageElement).style.display = 'none';
}}
/>
{:else}
<div class="w-12 h-12 rounded-full bg-gray-300 dark:bg-gray-600 flex items-center justify-center border border-gray-200 dark:border-gray-600">
<span class="text-lg font-medium text-gray-600 dark:text-gray-300">
{(profileData.display_name || profileData.name || result.pubkey.slice(0, 1)).toUpperCase()}
</span>
</div>
{/if}
<div class="flex flex-col min-w-0 flex-1">
{#if profileData.display_name || profileData.name}
<span class="font-medium text-gray-900 dark:text-gray-100 truncate">
{profileData.display_name || profileData.name}
</span>
{/if}
{#if profileData.about}
<span class="text-sm text-gray-600 dark:text-gray-400 line-clamp-2">
{profileData.about}
</span>
{/if}
</div>
</div>
{:else}
{#if getSummary(result)}
<div
class="text-sm text-primary-900 dark:text-primary-200 mb-1 line-clamp-2"
@ -758,6 +920,7 @@ @@ -758,6 +920,7 @@
: ""}
</div>
{/if}
{/if}
</div>
</button>
{/each}
@ -810,6 +973,8 @@ @@ -810,6 +973,8 @@
<EventDetails {event} {profile} {searchValue} />
<RelayActions {event} />
<CommentViewer {event} />
{#if isLoggedIn && userPubkey}
<div class="mt-8">
<Heading tag="h3" class="h-leather mb-4">Add Comment</Heading>

Loading…
Cancel
Save