Browse Source

Merge branch 'master' of ssh://onedev.gitcitadel.eu:6611/Alexandria/gc-alexandria into issue#296

master
silberengel 7 months ago
parent
commit
b259a8dd00
  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. 199
      src/lib/data_structures/docs/relay_selector_design.md
  11. 88
      src/lib/ndk.ts
  12. 9
      src/lib/utils.ts
  13. 64
      src/lib/utils/event_search.ts
  14. 135
      src/lib/utils/kind24_utils.ts
  15. 45
      src/lib/utils/markup/basicMarkupParser.ts
  16. 10
      src/lib/utils/nostrEventService.ts
  17. 225
      src/lib/utils/notification_utils.ts
  18. 44
      src/lib/utils/npubCache.ts
  19. 27
      src/lib/utils/profile_search.ts
  20. 166
      src/lib/utils/relay_info_service.ts
  21. 7
      src/lib/utils/search_constants.ts
  22. 229
      src/lib/utils/subscription_search.ts
  23. 28
      src/routes/+layout.svelte
  24. 10
      src/routes/contact/+page.svelte
  25. 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";

199
src/lib/data_structures/docs/relay_selector_design.md

@ -0,0 +1,199 @@ @@ -0,0 +1,199 @@
# Relay Selector Class Design
The relay selector will be a singleton that tracks, rates, and ranks Nostr relays to help the application determine which relay should be used to handle each request. It will weight relays based on observed characteristics, then use these weights to implement a weighted round robin algorithm for selecting relays, with some additional modifications to account for domain-specific features of Nostr.
## Relay Weights
### Categories
Relays are broadly divided into three categories:
1. **Public**: no authorization is required
2. **Private Write**: authorization is required to write to this relay, but not to read
3. **Private Read and Write**: authorization is required to use any features of this relay
The broadest level of relay selection is based on these categories.
- For users that are not logged in, public relays are used exclusively.
- For logged-in users, public and private read relays are initially rated equally for read operations.
- For logged-in users, private write relays are preferred above public relays for write operations.
### User Preferences
The relay selector will respect user relay preferences while still attempting to optimize for responsiveness and success rate.
- User inbox relays will be stored in a separate list from general-purpose relays, and weighted and sorted separately using the same algorithm as the general-purpose relay list.
- Local relays (beginning with `wss://localhost` or `ws://localhost`) will be stored _unranked_ in a separate list, and used when the relay selector is operating on a web browser (as opposed to a server).
- When a caller requests relays from the relay selector, the selector will return:
- The highest-ranked general-purpose relay
- The highest-ranked user inbox relay
- (If on browser) any local relays
### Weighted Metrics
Several weighted metrics are used to compute a relay's score. The score is used to rank relays to determine which to prefer when fetching events.
#### Response Time
The response time weight of each relay is computed according to the logarithmic function $`r(t) = -log(t) + 1`$, where $`t`$ is the median response time in seconds. This function has a few features which make it useful:
- $`r(1) = 1`$, making a response time of 1s the netural point. This causes the algorithm to prefer relays that respond in under 1s.
- $`r(0.3) \approx 1.5`$ and $`r(3) \approx 0.5`$. This clusters the 0.5 to 1.5 weight range in the 300ms to 3s response time range, which is a sufficiently rapid response time to keep user's from switching context.
- The function has a long tail, so it doesn't discount slower response times too heavily, too quickly.
#### Success Rate
The success rate $`s(x)`$ is computed as the fraction of total requests sent to the relay that returned at least one event in response. The optimal score is 1, meaning the relay successfully responds to 100% of requests.
#### Trust Level
Certain relays may be assigned a constant "trust level" score $`T`$. This modifier is a number in the range $`[-0.5, 0.5]`$ that indicates how much a relay is trusted by the GitCitadel organization.
A few factors contribute to a higher trust rating:
- Effective filtering of spam and abusive content.
- Good data transparency, including such policies as honoring deletion requests.
- Event aggregation policies that aim at synchronization with the broader relay network.
#### Preferred Vendors
Certain relays may be assigned a constant "preferred vendor" score $`V`$. This modifier is a number in the range $`[0, 0.5]`$. It is used to increase the priority of GitCitadel's preferred relay vendors.
### Overall Weight
The overall weight of a relay is calculated as $`w(t, x) = r(t) \times s(x) + T + V`$. The `RelaySelector` class maintains a list of relays sorted by their overall weights. The weights may be updated at runtime when $`t`$ or $`x`$ change. On update, the relay list is re-sorted to account for the new weights.
## Algorithm
The relay weights contribute to a weighted round robin (WRR) algorithm for relay selection. Pseudocode for the algorithm is given below:
```pseudocode
Constants and Variables:
const N // Number of relays
const CW // Connection weight
wInit // Map of relay URLs to initial weights
conn // Map of relay URLs to the number of active connections to that relay
wCurr // Current relay weights
rSorted // List of relay URLs sorted in ascending order
Function getRelay:
r = rSorted[N - 1] // Get the highest-ranked relay
conn[r]++ // Increment the number of connections
wCurr[r] = wInit[r] + conn[r] * CW // Adjust current weights based on new connection weight
sort rSorted by wCurr // Re-sort based on updated weights
return r
```
## Class Methods
The `RelaySelector` class should expose the following methods to support updates to relay weights. Pseudocode for each method is given below.
### Add Response Time Datum
This function updates the class state by side effect. Locking should be used in concurrent use cases.
```pseudocode
Constants and Variables:
const CW // Connection weight
rT // A map of relay URLs to their Trust Level scores
rV // A map of relay URLs to their Preferred Vendor scores
rTimes // A map of relay URLs to a list or recorded response times
rReqs // A map of relay URLs to the number of recorded requests
rSucc // A map of relay URLs to the number of successful requests
rTimes // A map of relay URLs to recorded response times
wInit // Map of relay URLs to initial weights
conn // Map of relay URLs to the number of active connections to that relay
wCurr // Current relay weights
rSorted // List of relay URLs sorted in ascending order
Parameters:
r // A relay URL
rt // A response time datum recorded for the given relay
Function addResponseTimeDatum:
append rt to rTimes[r]
sort rTimes[r]
rtMed = median of rTimes[r]
rtWeight = -1 * log(rtMed) + 1
succRate = rSucc[r] / rReqs[r]
wInit[r] = rtWeight * succRate + rT[r] + rV[r]
wCurr[r] = wInit[r] + conn[r] * CW
sort rSorted by wCurr
```
### Add Success Rate Datum
This function updates the class state by side effect. Locking should be used in concurrent use cases.
```pseudocode
Constants and Variables:
const CW // Connection weight
rT // A map of relay URLs to their Trust Level scores
rV // A map of relay URLs to their Preferred Vendor scores
rReqs // A map of relay URLs to the number of recorded requests
rSucc // A map of relay URLs to the number of successful requests
rTimes // A map of relay URLs to recorded response times
wInit // Map of relay URLs to initial weights
conn // Map of relay URLs to the number of active connections to that relay
wCurr // Current relay weights
rSorted // List of relay URLs sorted in ascending order
Parameters:
r // A relay URL
s // A boolean value indicating whether the latest request to relay r succeeded
Function addSuccessRateDatum:
rReqs[r]++
if s is true:
rSucc[r]++
rtMed = median of rTimes[r]
rtWeight = -1 * log(rtMed) + 1
succRate = rSuccReqs[r] / rReqs[r]
wInit[r] = rtWeight * succRate + rT[r] + rV[r]
wCurr[r] = wInit[r] + conn[r] * CW
sort rSorted by wCurr
```
### Add Relay
```pseudocode
Constants and Variables:
general // A list of general-purpose relay URLs
inbox // A list of user-defined inbox relay URLs
local // A list of local relay URLs
Parameters:
r // The relay URL
rType // The relay type (general, inbox, or local)
Function addRelay:
if rType is "general":
add r to general
sort general by current weights
if rType is "inbox":
add r to inbox
sort inbox by current weights
if rType is "local":
add r to local
```
### Get Relay
```
Constants and Variables:
general // A sorted list of general-purpose relay URLs
inbox // A sorted list of user-defined inbox relay URLs
local // An unsorted list of local relay URLs
Parameters:
rank // The requested rank
Function getRelay:
selected = []
if local has members:
add all local members to selected
if rank less than length of inbox:
add inbox[rank] to selected
if rank less than length of general:
add general[rank] to selected
```

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