24 changed files with 3864 additions and 308 deletions
@ -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 @@ |
|||||||
|
<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,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 @@ |
|||||||
|
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 @@ |
|||||||
|
/** |
||||||
|
* 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