25 changed files with 4063 additions and 308 deletions
@ -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> |
||||
File diff suppressed because it is too large
Load Diff
@ -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> |
||||
@ -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 |
||||
``` |
||||
@ -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"
|
||||
}; |
||||
} |
||||
} |
||||
|
||||
|
||||
@ -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; |
||||
} |
||||
@ -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; |
||||
} |
||||
Loading…
Reference in new issue