Browse Source
- Resolved conflicts in ZettelEditor.svelte: kept unified AsciiDoc publisher - Fixed asciidoc_metadata.ts: restored stripSectionHeader and systemAttributes - Updated compose page to use publishSingleEvent for direct event publishing - Fixed Svelte 5 syntax (onclick instead of on:click) - Removed duplicate publish button from compose page 🤖 Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude <noreply@anthropic.com>master
152 changed files with 20732 additions and 6723 deletions
@ -0,0 +1,887 @@ |
|||||||
|
<script lang="ts"> |
||||||
|
import { Button, P, Heading } from "flowbite-svelte"; |
||||||
|
import { getUserMetadata, toNpub } from "$lib/utils/nostrUtils"; |
||||||
|
import { neventEncode } from "$lib/utils"; |
||||||
|
import { activeInboxRelays, getNdkContext } from "$lib/ndk"; |
||||||
|
import { goto } from "$app/navigation"; |
||||||
|
import { onMount } from "svelte"; |
||||||
|
import type { NDKEvent } from "@nostr-dev-kit/ndk"; |
||||||
|
import EmbeddedEvent from "./embedded_events/EmbeddedEvent.svelte"; |
||||||
|
|
||||||
|
const { event } = $props<{ event: NDKEvent }>(); |
||||||
|
|
||||||
|
const ndk = getNdkContext(); |
||||||
|
|
||||||
|
// 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, ndk, 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(ndk.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 = ndk.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 = ndk.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 = ndk.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 = ndk.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 = ndk.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`; |
||||||
|
} |
||||||
|
|
||||||
|
// 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> |
||||||
|
<EmbeddedEvent nostrIdentifier={node.event.getMatchingTags("comment")[0]?.[1]} nestingLevel={0} /> |
||||||
|
</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 --> |
||||||
|
<EmbeddedEvent nostrIdentifier={node.event.id} nestingLevel={0} /> |
||||||
|
{/if} |
||||||
|
</div> |
||||||
|
</div> |
||||||
|
|
||||||
|
{#if node.children.length > 0} |
||||||
|
<div class="space-y-4"> |
||||||
|
{#each node.children as childNode, index (childNode.event.id + '-' + index)} |
||||||
|
{@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, index (node.event.id + '-root-' + index)} |
||||||
|
{@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
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,311 @@ |
|||||||
|
<script module lang="ts"> |
||||||
|
import type { NDKEvent } from "$lib/utils/nostrUtils"; |
||||||
|
import { NDKRelaySetFromNDK, toNpub, getUserMetadata } from "$lib/utils/nostrUtils"; |
||||||
|
import { get } from "svelte/store"; |
||||||
|
import { searchRelays } from "$lib/consts"; |
||||||
|
import { userStore, type UserState } from "$lib/stores/userStore"; |
||||||
|
import { buildCompleteRelaySet } from "$lib/utils/relay_management"; |
||||||
|
import { nip19 } from "nostr-tools"; |
||||||
|
import { parseEmbeddedMarkup } from "$lib/utils/markup/embeddedMarkupParser"; |
||||||
|
import type NDK from "@nostr-dev-kit/ndk"; |
||||||
|
|
||||||
|
export { |
||||||
|
parsedContent, |
||||||
|
repostContent, |
||||||
|
quotedContent, |
||||||
|
truncateContent, |
||||||
|
truncateRenderedContent, |
||||||
|
getNotificationType, |
||||||
|
fetchAuthorProfiles |
||||||
|
}; |
||||||
|
|
||||||
|
/** |
||||||
|
* Truncates content to a specified length |
||||||
|
*/ |
||||||
|
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 |
||||||
|
*/ |
||||||
|
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) + "..."; |
||||||
|
} |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
/** |
||||||
|
* Gets notification type based on event kind |
||||||
|
*/ |
||||||
|
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 |
||||||
|
*/ |
||||||
|
async function fetchAuthorProfiles(events: NDKEvent[], ndk: NDK): 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, ndk, false); |
||||||
|
if (profile && (profile.name || profile.displayName || profile.picture)) { |
||||||
|
authorProfiles.set(pubkey, profile); |
||||||
|
return; |
||||||
|
} |
||||||
|
|
||||||
|
// Try search relays |
||||||
|
for (const relay of searchRelays) { |
||||||
|
try { |
||||||
|
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 { |
||||||
|
if (!ndk) return; |
||||||
|
|
||||||
|
const userStoreValue: UserState = 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] Error processing profile for ${pubkey}:`, error); |
||||||
|
} |
||||||
|
}); |
||||||
|
|
||||||
|
await Promise.all(profilePromises); |
||||||
|
return authorProfiles; |
||||||
|
} |
||||||
|
|
||||||
|
async function findQuotedMessage(eventId: string, publicMessages: NDKEvent[], ndk: NDK): Promise<NDKEvent | undefined> { |
||||||
|
// Validate eventId format (should be 64 character hex string) |
||||||
|
const isValidEventId = /^[a-fA-F0-9]{64}$/.test(eventId); |
||||||
|
if (!isValidEventId) return undefined; |
||||||
|
|
||||||
|
// 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 { |
||||||
|
if (ndk) { |
||||||
|
const userStoreValue: UserState = 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(`[findQuotedMessage] Failed to fetch quoted event ${eventId}:`, error); |
||||||
|
} |
||||||
|
} |
||||||
|
return quotedMessage; |
||||||
|
} |
||||||
|
</script> |
||||||
|
|
||||||
|
{#snippet parsedContent(content: string)} |
||||||
|
{#await parseEmbeddedMarkup(content, 0) then parsed} |
||||||
|
{@html parsed} |
||||||
|
{/await} |
||||||
|
{/snippet} |
||||||
|
|
||||||
|
{#snippet repostContent(content: string)} |
||||||
|
{@const originalEvent = (() => { |
||||||
|
try { |
||||||
|
return JSON.parse(content); |
||||||
|
} catch { |
||||||
|
return null; |
||||||
|
} |
||||||
|
})()} |
||||||
|
|
||||||
|
{#if originalEvent} |
||||||
|
{@const originalContent = originalEvent.content || ""} |
||||||
|
{@const originalAuthor = originalEvent.pubkey || ""} |
||||||
|
{@const originalCreatedAt = originalEvent.created_at || 0} |
||||||
|
{@const originalKind = originalEvent.kind || 1} |
||||||
|
{@const formattedDate = originalCreatedAt ? new Date(originalCreatedAt * 1000).toLocaleDateString() : "Unknown date"} |
||||||
|
{@const shortAuthor = originalAuthor ? `${originalAuthor.slice(0, 8)}...${originalAuthor.slice(-4)}` : "Unknown"} |
||||||
|
|
||||||
|
<div class="embedded-repost bg-gray-50 dark:bg-gray-800 border border-gray-200 dark:border-gray-700 rounded-lg p-4 my-2"> |
||||||
|
<!-- Event header --> |
||||||
|
<div class="flex items-center justify-between mb-3 min-w-0"> |
||||||
|
<div class="flex items-center space-x-2 min-w-0"> |
||||||
|
<span class="text-xs text-gray-500 dark:text-gray-400 font-mono flex-shrink-0"> |
||||||
|
Kind {originalKind} |
||||||
|
</span> |
||||||
|
<span class="text-xs text-gray-500 dark:text-gray-400 flex-shrink-0"> |
||||||
|
(repost) |
||||||
|
</span> |
||||||
|
<span class="text-xs text-gray-500 dark:text-gray-400 flex-shrink-0">•</span> |
||||||
|
<span class="text-xs text-gray-600 dark:text-gray-400 flex-shrink-0">Author:</span> |
||||||
|
<span class="text-xs text-gray-700 dark:text-gray-300 font-mono"> |
||||||
|
{shortAuthor} |
||||||
|
</span> |
||||||
|
<span class="text-xs text-gray-500 dark:text-gray-400 flex-shrink-0">•</span> |
||||||
|
<span class="text-xs text-gray-500 dark:text-gray-400"> |
||||||
|
{formattedDate} |
||||||
|
</span> |
||||||
|
</div> |
||||||
|
</div> |
||||||
|
|
||||||
|
<!-- Reposted content --> |
||||||
|
<div class="text-sm text-gray-800 dark:text-gray-200 leading-relaxed"> |
||||||
|
{#await parseEmbeddedMarkup(originalContent, 0) then parsedOriginalContent} |
||||||
|
{@html parsedOriginalContent} |
||||||
|
{/await} |
||||||
|
</div> |
||||||
|
</div> |
||||||
|
{:else} |
||||||
|
{#await parseEmbeddedMarkup(content, 0) then parsedContent} |
||||||
|
{@html parsedContent} |
||||||
|
{/await} |
||||||
|
{/if} |
||||||
|
{/snippet} |
||||||
|
|
||||||
|
{#snippet quotedContent(message: NDKEvent, publicMessages: NDKEvent[], ndk: NDK)} |
||||||
|
{@const qTags = message.getMatchingTags("q")} |
||||||
|
{#if qTags.length > 0} |
||||||
|
{@const qTag = qTags[0]} |
||||||
|
{@const eventId = qTag[1]} |
||||||
|
|
||||||
|
{#if eventId} |
||||||
|
{#await findQuotedMessage(eventId, publicMessages, ndk) then quotedMessage} |
||||||
|
{#if quotedMessage} |
||||||
|
{@const quotedContent = quotedMessage.content ? quotedMessage.content.slice(0, 200) : "No content"} |
||||||
|
{#await parseEmbeddedMarkup(quotedContent, 0) then parsedContent} |
||||||
|
<button type="button" class="block text-left 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 }))}> |
||||||
|
{@html parsedContent} |
||||||
|
</button> |
||||||
|
{/await} |
||||||
|
{:else} |
||||||
|
{@const isValidEventId = /^[a-fA-F0-9]{64}$/.test(eventId)} |
||||||
|
{#if isValidEventId} |
||||||
|
{@const nevent = (() => { |
||||||
|
try { |
||||||
|
return nip19.neventEncode({ id: eventId }); |
||||||
|
} catch (error) { |
||||||
|
console.warn(`[quotedContent] Failed to encode nevent for ${eventId}:`, error); |
||||||
|
return null; |
||||||
|
} |
||||||
|
})()} |
||||||
|
{#if nevent} |
||||||
|
<button type="button" class="block text-left 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)}... |
||||||
|
</button> |
||||||
|
{:else} |
||||||
|
<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 text-sm text-gray-600 dark:text-gray-300"> |
||||||
|
Quoted message not found. Event ID: {eventId.slice(0, 8)}... |
||||||
|
</div> |
||||||
|
{/if} |
||||||
|
{:else} |
||||||
|
<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 text-sm text-gray-600 dark:text-gray-300"> |
||||||
|
Invalid quoted message reference |
||||||
|
</div> |
||||||
|
{/if} |
||||||
|
{/if} |
||||||
|
{/await} |
||||||
|
{/if} |
||||||
|
{/if} |
||||||
|
{/snippet} |
||||||
@ -0,0 +1,162 @@ |
|||||||
|
<script lang="ts"> |
||||||
|
import { Tooltip } from "flowbite-svelte"; |
||||||
|
import type { EventData, TagData, ValidationResult } from "./types"; |
||||||
|
import { validateEvent } from "./validation"; |
||||||
|
|
||||||
|
// AI-NOTE: 2025-01-24 - EventForm component handles basic form inputs and validation |
||||||
|
// This component focuses on event kind and content, with validation feedback |
||||||
|
|
||||||
|
let { |
||||||
|
eventData = $bindable(), |
||||||
|
tags, |
||||||
|
onvalidate, |
||||||
|
}: { |
||||||
|
eventData: EventData; |
||||||
|
tags: TagData[]; |
||||||
|
onvalidate: (isValid: boolean, error?: string, warning?: string) => void; |
||||||
|
} = $props(); |
||||||
|
|
||||||
|
let validationError = $state<string | null>(null); |
||||||
|
let validationWarning = $state<string | null>(null); |
||||||
|
|
||||||
|
/** |
||||||
|
* Validates the current form data |
||||||
|
*/ |
||||||
|
function validateForm(): ValidationResult { |
||||||
|
return validateEvent(eventData, tags); |
||||||
|
} |
||||||
|
|
||||||
|
/** |
||||||
|
* Handles form validation |
||||||
|
*/ |
||||||
|
function handleValidate(e: Event) { |
||||||
|
e.preventDefault(); |
||||||
|
validationError = null; |
||||||
|
validationWarning = null; |
||||||
|
|
||||||
|
const validation = validateForm(); |
||||||
|
if (!validation.valid) { |
||||||
|
validationError = validation.reason || "Validation failed."; |
||||||
|
onvalidate(false, validation.reason || "Validation failed."); |
||||||
|
return; |
||||||
|
} |
||||||
|
|
||||||
|
if (validation.warning) { |
||||||
|
validationWarning = validation.warning; |
||||||
|
onvalidate(true, undefined, validation.warning); |
||||||
|
} else { |
||||||
|
onvalidate(true); |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
/** |
||||||
|
* Validates kind input |
||||||
|
*/ |
||||||
|
function isValidKind(kind: number | string): boolean { |
||||||
|
const n = Number(kind); |
||||||
|
return Number.isInteger(n) && n >= 0 && n <= 65535; |
||||||
|
} |
||||||
|
|
||||||
|
/** |
||||||
|
* Gets kind description |
||||||
|
*/ |
||||||
|
function getKindDescription(kind: number): string { |
||||||
|
switch (kind) { |
||||||
|
case 1: |
||||||
|
return "Text Note"; |
||||||
|
case 30023: |
||||||
|
return "Long-form Content"; |
||||||
|
case 30040: |
||||||
|
return "Publication Index"; |
||||||
|
case 30041: |
||||||
|
return "Publication Section"; |
||||||
|
case 30818: |
||||||
|
return "AsciiDoc Document"; |
||||||
|
default: |
||||||
|
return "Custom Event"; |
||||||
|
} |
||||||
|
} |
||||||
|
</script> |
||||||
|
|
||||||
|
<form class="space-y-4" onsubmit={handleValidate}> |
||||||
|
<!-- Event Kind --> |
||||||
|
<div> |
||||||
|
<label class="block font-medium mb-1 text-gray-700 dark:text-gray-300" for="event-kind"> |
||||||
|
Kind |
||||||
|
</label> |
||||||
|
<input |
||||||
|
id="event-kind" |
||||||
|
type="number" |
||||||
|
class="input input-bordered w-full" |
||||||
|
bind:value={eventData.kind} |
||||||
|
min="0" |
||||||
|
max="65535" |
||||||
|
required |
||||||
|
/> |
||||||
|
{#if !isValidKind(eventData.kind)} |
||||||
|
<div class="text-red-600 dark:text-red-400 text-sm mt-1"> |
||||||
|
Kind must be an integer between 0 and 65535 (NIP-01). |
||||||
|
</div> |
||||||
|
{/if} |
||||||
|
{#if isValidKind(eventData.kind)} |
||||||
|
<div class="flex items-center gap-2 mt-1"> |
||||||
|
<span class="text-sm text-gray-600 dark:text-gray-400"> |
||||||
|
{getKindDescription(eventData.kind)} |
||||||
|
</span> |
||||||
|
{#if eventData.kind === 30040} |
||||||
|
<Tooltip class="tooltip-leather" type="auto" placement="bottom"> |
||||||
|
<button |
||||||
|
type="button" |
||||||
|
class="w-6 h-6 rounded-full bg-blue-500 hover:bg-blue-600 text-white flex items-center justify-center text-sm font-bold border border-blue-600 shadow-sm" |
||||||
|
title="Learn more about Publication Index events" |
||||||
|
> |
||||||
|
? |
||||||
|
</button> |
||||||
|
<div class="max-w-sm p-2 text-xs"> |
||||||
|
<strong>30040 - Publication Index:</strong> Events that organize AsciiDoc content into structured publications with metadata tags and section references. |
||||||
|
</div> |
||||||
|
</Tooltip> |
||||||
|
{/if} |
||||||
|
</div> |
||||||
|
{/if} |
||||||
|
</div> |
||||||
|
|
||||||
|
<!-- Event Content --> |
||||||
|
<div> |
||||||
|
<label class="block font-medium mb-1 text-gray-700 dark:text-gray-300" for="event-content"> |
||||||
|
Content |
||||||
|
</label> |
||||||
|
<textarea |
||||||
|
id="event-content" |
||||||
|
bind:value={eventData.content} |
||||||
|
placeholder="Content (start with a header for the title)" |
||||||
|
class="textarea textarea-bordered w-full h-40" |
||||||
|
required |
||||||
|
></textarea> |
||||||
|
|
||||||
|
<!-- Content hints based on kind --> |
||||||
|
{#if eventData.kind === 30023} |
||||||
|
<div class="text-sm text-gray-600 dark:text-gray-400 mt-1"> |
||||||
|
Use Markdown format for long-form content. Do not use AsciiDoc headers (=). |
||||||
|
</div> |
||||||
|
{:else if eventData.kind === 30040 || eventData.kind === 30041 || eventData.kind === 30818} |
||||||
|
<div class="text-sm text-gray-600 dark:text-gray-400 mt-1"> |
||||||
|
Use AsciiDoc format. Start with a document title (=) and include section headers (==). |
||||||
|
</div> |
||||||
|
{/if} |
||||||
|
</div> |
||||||
|
|
||||||
|
<!-- Validation Messages --> |
||||||
|
{#if validationError} |
||||||
|
<div class="text-red-600 dark:text-red-400 text-sm"> |
||||||
|
{validationError} |
||||||
|
</div> |
||||||
|
{/if} |
||||||
|
{#if validationWarning} |
||||||
|
<div class="text-yellow-600 dark:text-yellow-400 text-sm"> |
||||||
|
Warning: {validationWarning} |
||||||
|
</div> |
||||||
|
{/if} |
||||||
|
|
||||||
|
|
||||||
|
</form> |
||||||
@ -0,0 +1,172 @@ |
|||||||
|
<script lang="ts"> |
||||||
|
import { get } from "svelte/store"; |
||||||
|
import { userStore } from "$lib/stores/userStore"; |
||||||
|
import { prefixNostrAddresses } from "$lib/utils/nostrUtils"; |
||||||
|
import { removeMetadataFromContent } from "$lib/utils/asciidoc_metadata"; |
||||||
|
import { build30040EventSet } from "$lib/utils/event_input_utils"; |
||||||
|
import type { EventData, TagData, EventPreview } from "./types"; |
||||||
|
|
||||||
|
// AI-NOTE: 2025-01-24 - EventPreview component shows a preview of the event that will be published |
||||||
|
// This component generates a preview based on the current form data |
||||||
|
|
||||||
|
let { |
||||||
|
ndk, |
||||||
|
eventData, |
||||||
|
tags, |
||||||
|
showJsonPreview, |
||||||
|
onTogglePreview, |
||||||
|
}: { |
||||||
|
ndk: any; |
||||||
|
eventData: EventData; |
||||||
|
tags: TagData[]; |
||||||
|
showJsonPreview: boolean; |
||||||
|
onTogglePreview: () => void; |
||||||
|
} = $props(); |
||||||
|
|
||||||
|
/** |
||||||
|
* Converts TagData array to NDK-compatible format |
||||||
|
*/ |
||||||
|
function convertTagsToNDKFormat(tags: TagData[]): string[][] { |
||||||
|
return tags |
||||||
|
.filter(tag => tag.key.trim() !== "") |
||||||
|
.map(tag => [tag.key, ...tag.values]); |
||||||
|
} |
||||||
|
|
||||||
|
/** |
||||||
|
* Generates event preview |
||||||
|
*/ |
||||||
|
let eventPreview = $derived.by(() => { |
||||||
|
const userState = get(userStore); |
||||||
|
const pubkey = userState.pubkey; |
||||||
|
|
||||||
|
if (!pubkey) { |
||||||
|
return null; |
||||||
|
} |
||||||
|
|
||||||
|
// Build the event data similar to how it's done in publishing |
||||||
|
const baseEvent = { |
||||||
|
pubkey: String(pubkey), |
||||||
|
created_at: eventData.createdAt, |
||||||
|
kind: Number(eventData.kind) |
||||||
|
}; |
||||||
|
|
||||||
|
if (Number(eventData.kind) === 30040) { |
||||||
|
// For 30040, we need to show the index event structure |
||||||
|
try { |
||||||
|
// Convert tags to compatible format (exclude preset tags) |
||||||
|
const presetTagKeys = ["version", "d", "title"]; |
||||||
|
const compatibleTags: [string, string][] = tags |
||||||
|
.filter(tag => tag.key.trim() !== "" && !presetTagKeys.includes(tag.key)) |
||||||
|
.map(tag => [tag.key, tag.values[0] || ""] as [string, string]); |
||||||
|
|
||||||
|
// Create a mock NDK instance for preview |
||||||
|
const mockNdk = { sign: async () => ({ sig: "mock_signature" }) }; |
||||||
|
|
||||||
|
const { indexEvent } = build30040EventSet( |
||||||
|
eventData.content, |
||||||
|
compatibleTags, |
||||||
|
baseEvent, |
||||||
|
mockNdk as any, |
||||||
|
); |
||||||
|
|
||||||
|
// Add preset tags from UI (version, d, title) |
||||||
|
const finalTags = indexEvent.tags.filter(tag => !presetTagKeys.includes(tag[0])); |
||||||
|
const versionTag = tags.find(t => t.key === "version"); |
||||||
|
const dTag = tags.find(t => t.key === "d"); |
||||||
|
const titleTag = tags.find(t => t.key === "title"); |
||||||
|
|
||||||
|
if (versionTag && versionTag.values[0]) { |
||||||
|
finalTags.push(["version", versionTag.values[0]]); |
||||||
|
} |
||||||
|
if (dTag && dTag.values[0]) { |
||||||
|
finalTags.push(["d", dTag.values[0]]); |
||||||
|
} |
||||||
|
if (titleTag && titleTag.values[0]) { |
||||||
|
finalTags.push(["title", titleTag.values[0]]); |
||||||
|
} |
||||||
|
|
||||||
|
return { |
||||||
|
type: "30040_index_event", |
||||||
|
event: { |
||||||
|
id: "[will be generated]", |
||||||
|
pubkey: String(pubkey), |
||||||
|
created_at: eventData.createdAt, |
||||||
|
kind: 30040, |
||||||
|
tags: finalTags, |
||||||
|
content: indexEvent.content, |
||||||
|
sig: "[will be generated]" |
||||||
|
} |
||||||
|
}; |
||||||
|
} catch (error) { |
||||||
|
return { |
||||||
|
type: "error", |
||||||
|
message: `Failed to generate 30040 preview: ${error instanceof Error ? error.message : "Unknown error"}` |
||||||
|
}; |
||||||
|
} |
||||||
|
} else { |
||||||
|
// For other event types |
||||||
|
let eventTags = convertTagsToNDKFormat(tags); |
||||||
|
|
||||||
|
// For AsciiDoc events, remove metadata from content |
||||||
|
let finalContent = eventData.content; |
||||||
|
if (eventData.kind === 30040 || eventData.kind === 30041) { |
||||||
|
finalContent = removeMetadataFromContent(eventData.content); |
||||||
|
} |
||||||
|
|
||||||
|
// Prefix Nostr addresses |
||||||
|
const prefixedContent = prefixNostrAddresses(finalContent); |
||||||
|
|
||||||
|
return { |
||||||
|
type: "standard_event", |
||||||
|
event: { |
||||||
|
id: "[will be generated]", |
||||||
|
pubkey: String(pubkey), |
||||||
|
created_at: eventData.createdAt, |
||||||
|
kind: Number(eventData.kind), |
||||||
|
tags: eventTags, |
||||||
|
content: prefixedContent, |
||||||
|
sig: "[will be generated]" |
||||||
|
} |
||||||
|
}; |
||||||
|
} |
||||||
|
}); |
||||||
|
</script> |
||||||
|
|
||||||
|
<!-- Event Preview Section --> |
||||||
|
<div class="mt-6 border-t border-gray-200 dark:border-gray-700 pt-4"> |
||||||
|
<div class="flex items-center justify-between mb-3"> |
||||||
|
<h3 class="text-lg font-semibold text-gray-900 dark:text-gray-100">Event Preview</h3> |
||||||
|
<button |
||||||
|
type="button" |
||||||
|
class="btn btn-sm btn-outline btn-secondary" |
||||||
|
onclick={onTogglePreview} |
||||||
|
> |
||||||
|
{showJsonPreview ? 'Hide' : 'Show'} JSON Preview |
||||||
|
</button> |
||||||
|
</div> |
||||||
|
|
||||||
|
{#if showJsonPreview} |
||||||
|
{#if eventPreview} |
||||||
|
<div class="bg-gray-50 dark:bg-gray-800 rounded-lg p-4 border border-gray-200 dark:border-gray-600"> |
||||||
|
{#if eventPreview.type === 'error'} |
||||||
|
<div class="text-red-600 dark:text-red-400 text-sm"> |
||||||
|
{eventPreview.message} |
||||||
|
</div> |
||||||
|
{:else} |
||||||
|
<div class="mb-2"> |
||||||
|
<span class="text-sm font-medium text-gray-700 dark:text-gray-300"> |
||||||
|
Event Type: {eventPreview.type === '30040_index_event' ? '30040 Publication Index' : 'Standard Event'} |
||||||
|
</span> |
||||||
|
</div> |
||||||
|
<pre class="text-xs bg-white dark:bg-gray-900 p-3 rounded border overflow-x-auto text-gray-800 dark:text-gray-200 font-mono whitespace-pre-wrap">{JSON.stringify(eventPreview.event, null, 2)}</pre> |
||||||
|
{/if} |
||||||
|
</div> |
||||||
|
{:else} |
||||||
|
<div class="bg-yellow-50 dark:bg-yellow-900/20 rounded-lg p-4 border border-yellow-200 dark:border-yellow-700"> |
||||||
|
<div class="text-yellow-800 dark:text-yellow-200 text-sm"> |
||||||
|
Please log in to see the event preview. |
||||||
|
</div> |
||||||
|
</div> |
||||||
|
{/if} |
||||||
|
{/if} |
||||||
|
</div> |
||||||
@ -0,0 +1,342 @@ |
|||||||
|
<script lang="ts"> |
||||||
|
import { extractSmartMetadata, metadataToTags } from "$lib/utils/asciidoc_metadata"; |
||||||
|
import { titleToDTag, requiresDTag } from "$lib/utils/event_input_utils"; |
||||||
|
import type { TagData, PresetTag } from "./types"; |
||||||
|
|
||||||
|
// AI-NOTE: 2025-01-24 - TagManager component handles tag management with preset tags |
||||||
|
// This component automatically manages preset tags based on event kind and content |
||||||
|
|
||||||
|
let { |
||||||
|
tags = $bindable(), |
||||||
|
kind, |
||||||
|
content, |
||||||
|
}: { |
||||||
|
tags: TagData[]; |
||||||
|
kind: number; |
||||||
|
content: string; |
||||||
|
} = $props(); |
||||||
|
|
||||||
|
let removedTags = $state<Set<string>>(new Set()); |
||||||
|
let extractedMetadata = $state<[string, string][]>([]); |
||||||
|
let lastContent = $state(""); |
||||||
|
let lastKind = $state(0); |
||||||
|
|
||||||
|
// Define preset tags for different event kinds |
||||||
|
let presetTags = $derived.by(() => { |
||||||
|
const presets: PresetTag[] = []; |
||||||
|
|
||||||
|
// Version tag for 30040 events |
||||||
|
if (kind === 30040) { |
||||||
|
presets.push({ |
||||||
|
key: "version", |
||||||
|
defaultValue: "1", |
||||||
|
required: true, |
||||||
|
autoUpdate: false, |
||||||
|
description: "Publication version" |
||||||
|
}); |
||||||
|
} |
||||||
|
|
||||||
|
// D-tag and title for addressable events |
||||||
|
if (requiresDTag(kind)) { |
||||||
|
presets.push({ |
||||||
|
key: "d", |
||||||
|
defaultValue: "default-title", |
||||||
|
required: true, |
||||||
|
autoUpdate: true, |
||||||
|
description: "Document identifier (derived from title)" |
||||||
|
}); |
||||||
|
|
||||||
|
presets.push({ |
||||||
|
key: "title", |
||||||
|
defaultValue: "Default Title", |
||||||
|
required: true, |
||||||
|
autoUpdate: true, |
||||||
|
description: "Document title (extracted from content)" |
||||||
|
}); |
||||||
|
} |
||||||
|
|
||||||
|
return presets; |
||||||
|
}); |
||||||
|
|
||||||
|
// Extract metadata from content for AsciiDoc events |
||||||
|
$effect(() => { |
||||||
|
if (kind === 30040 || kind === 30041) { |
||||||
|
const { metadata } = extractSmartMetadata(content); |
||||||
|
extractedMetadata = metadataToTags(metadata); |
||||||
|
} else { |
||||||
|
extractedMetadata = []; |
||||||
|
} |
||||||
|
}); |
||||||
|
|
||||||
|
// Manage preset tags automatically |
||||||
|
$effect(() => { |
||||||
|
// Only run this effect when content or kind changes, not when tags change |
||||||
|
if (content === lastContent && kind === lastKind) { |
||||||
|
return; // Skip if nothing has changed |
||||||
|
} |
||||||
|
|
||||||
|
lastContent = content; |
||||||
|
lastKind = kind; |
||||||
|
|
||||||
|
const currentTags = [...tags]; // Create a copy to avoid mutation |
||||||
|
|
||||||
|
const newTags: TagData[] = []; |
||||||
|
|
||||||
|
// Add preset tags |
||||||
|
for (const preset of presetTags) { |
||||||
|
if (removedTags.has(preset.key)) continue; |
||||||
|
|
||||||
|
let value = preset.defaultValue; |
||||||
|
|
||||||
|
// Auto-update values based on content |
||||||
|
if (preset.autoUpdate && content.trim()) { |
||||||
|
if (preset.key === "title") { |
||||||
|
const { metadata } = extractSmartMetadata(content); |
||||||
|
value = metadata.title || preset.defaultValue; |
||||||
|
} else if (preset.key === "d") { |
||||||
|
const { metadata } = extractSmartMetadata(content); |
||||||
|
value = titleToDTag(metadata.title || "") || preset.defaultValue; |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
// Find existing tag or create new one |
||||||
|
const existingTag = currentTags.find(t => t.key === preset.key); |
||||||
|
if (existingTag) { |
||||||
|
// For preset tags, always ensure exactly one value |
||||||
|
if (preset.autoUpdate) { |
||||||
|
newTags.push({ |
||||||
|
key: preset.key, |
||||||
|
values: [value] // Only keep the first (primary) value |
||||||
|
}); |
||||||
|
} else { |
||||||
|
newTags.push({ |
||||||
|
key: preset.key, |
||||||
|
values: [existingTag.values[0] || preset.defaultValue] // Keep user value or default |
||||||
|
}); |
||||||
|
} |
||||||
|
} else { |
||||||
|
newTags.push({ |
||||||
|
key: preset.key, |
||||||
|
values: [value] |
||||||
|
}); |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
// Add non-preset tags (avoid duplicates) |
||||||
|
for (const tag of currentTags) { |
||||||
|
const isPresetKey = presetTags.some(p => p.key === tag.key); |
||||||
|
const alreadyAdded = newTags.some(t => t.key === tag.key); |
||||||
|
|
||||||
|
if (!isPresetKey && !alreadyAdded) { |
||||||
|
newTags.push(tag); |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
// Ensure there's always an empty tag row for user input |
||||||
|
if (newTags.length === 0 || newTags[newTags.length - 1].key !== "") { |
||||||
|
newTags.push({ key: "", values: [""] }); |
||||||
|
} |
||||||
|
|
||||||
|
// Only update if the tags have actually changed |
||||||
|
const tagsChanged = JSON.stringify(newTags) !== JSON.stringify(currentTags); |
||||||
|
if (tagsChanged) { |
||||||
|
tags = newTags; |
||||||
|
} |
||||||
|
}); |
||||||
|
|
||||||
|
/** |
||||||
|
* Adds a new tag |
||||||
|
*/ |
||||||
|
function addTag(): void { |
||||||
|
tags = [...tags, { key: "", values: [""] }]; |
||||||
|
} |
||||||
|
|
||||||
|
/** |
||||||
|
* Removes a tag at the specified index |
||||||
|
*/ |
||||||
|
function removeTag(index: number): void { |
||||||
|
const tagKey = tags[index]?.key; |
||||||
|
|
||||||
|
if (tagKey) { |
||||||
|
removedTags.add(tagKey); |
||||||
|
} |
||||||
|
|
||||||
|
tags = tags.filter((_, i) => i !== index); |
||||||
|
} |
||||||
|
|
||||||
|
/** |
||||||
|
* Adds a value to a tag |
||||||
|
*/ |
||||||
|
function addTagValue(tagIndex: number): void { |
||||||
|
tags = tags.map((tag, i) => { |
||||||
|
if (i === tagIndex) { |
||||||
|
return { ...tag, values: [...tag.values, ""] }; |
||||||
|
} |
||||||
|
return tag; |
||||||
|
}); |
||||||
|
} |
||||||
|
|
||||||
|
/** |
||||||
|
* Removes a value from a tag |
||||||
|
*/ |
||||||
|
function removeTagValue(tagIndex: number, valueIndex: number): void { |
||||||
|
tags = tags.map((tag, i) => { |
||||||
|
if (i === tagIndex) { |
||||||
|
const newValues = tag.values.filter((_, vi) => vi !== valueIndex); |
||||||
|
return { ...tag, values: newValues.length > 0 ? newValues : [""] }; |
||||||
|
} |
||||||
|
return tag; |
||||||
|
}); |
||||||
|
} |
||||||
|
|
||||||
|
/** |
||||||
|
* Updates a tag key |
||||||
|
*/ |
||||||
|
function updateTagKey(index: number, newKey: string): void { |
||||||
|
tags = tags.map((tag, i) => { |
||||||
|
if (i === index) { |
||||||
|
return { ...tag, key: newKey }; |
||||||
|
} |
||||||
|
return tag; |
||||||
|
}); |
||||||
|
} |
||||||
|
|
||||||
|
/** |
||||||
|
* Updates a tag value |
||||||
|
*/ |
||||||
|
function updateTagValue(tagIndex: number, valueIndex: number, newValue: string): void { |
||||||
|
tags = tags.map((tag, i) => { |
||||||
|
if (i === tagIndex) { |
||||||
|
const newValues = [...tag.values]; |
||||||
|
newValues[valueIndex] = newValue; |
||||||
|
return { ...tag, values: newValues }; |
||||||
|
} |
||||||
|
return tag; |
||||||
|
}); |
||||||
|
} |
||||||
|
|
||||||
|
/** |
||||||
|
* Checks if a tag is a preset tag |
||||||
|
*/ |
||||||
|
function isPresetTag(tagKey: string): boolean { |
||||||
|
return presetTags.some(p => p.key === tagKey); |
||||||
|
} |
||||||
|
|
||||||
|
/** |
||||||
|
* Gets preset tag info |
||||||
|
*/ |
||||||
|
function getPresetTagInfo(tagKey: string): PresetTag | undefined { |
||||||
|
return presetTags.find(p => p.key === tagKey); |
||||||
|
} |
||||||
|
</script> |
||||||
|
|
||||||
|
<div class="space-y-4"> |
||||||
|
<label for="tags-container" class="block font-medium mb-1 text-gray-700 dark:text-gray-300"> |
||||||
|
Tags |
||||||
|
</label> |
||||||
|
|
||||||
|
<!-- Extracted Metadata Section --> |
||||||
|
{#if extractedMetadata.length > 0} |
||||||
|
<div class="mb-4 p-3 bg-blue-50 dark:bg-blue-900/20 rounded-lg"> |
||||||
|
<h4 class="text-sm font-medium text-blue-800 dark:text-blue-200 mb-2"> |
||||||
|
Extracted Metadata (from AsciiDoc header) |
||||||
|
</h4> |
||||||
|
<div class="text-sm text-blue-700 dark:text-blue-300"> |
||||||
|
{extractedMetadata.map(([key, value]) => `${key}: ${value}`).join(', ')} |
||||||
|
</div> |
||||||
|
</div> |
||||||
|
{/if} |
||||||
|
|
||||||
|
<!-- Tags Container --> |
||||||
|
<div id="tags-container" class="space-y-2"> |
||||||
|
{#each tags as tag, i} |
||||||
|
<div class="border border-gray-300 dark:border-gray-600 rounded-lg p-3 space-y-2"> |
||||||
|
<!-- Tag Key Row --> |
||||||
|
<div class="flex gap-2 items-center"> |
||||||
|
<span class="text-sm font-medium text-gray-700 dark:text-gray-300 min-w-[60px]">Tag:</span> |
||||||
|
<input |
||||||
|
type="text" |
||||||
|
class="input input-bordered flex-1" |
||||||
|
placeholder="tag key (e.g., q, p, e)" |
||||||
|
value={tag.key} |
||||||
|
oninput={(e) => updateTagKey(i, (e.target as HTMLInputElement).value)} |
||||||
|
/> |
||||||
|
{#if isPresetTag(tag.key)} |
||||||
|
<span class="text-xs bg-blue-100 dark:bg-blue-900 text-blue-800 dark:text-blue-200 px-2 py-1 rounded"> |
||||||
|
Preset |
||||||
|
</span> |
||||||
|
{/if} |
||||||
|
<button |
||||||
|
type="button" |
||||||
|
class="btn btn-error btn-sm" |
||||||
|
onclick={() => removeTag(i)} |
||||||
|
> |
||||||
|
× |
||||||
|
</button> |
||||||
|
</div> |
||||||
|
|
||||||
|
<!-- Preset Tag Description --> |
||||||
|
{#if isPresetTag(tag.key)} |
||||||
|
{@const presetInfo = getPresetTagInfo(tag.key)} |
||||||
|
{#if presetInfo} |
||||||
|
<div class="text-xs text-gray-600 dark:text-gray-400 italic"> |
||||||
|
{presetInfo.description} |
||||||
|
{#if presetInfo.autoUpdate} |
||||||
|
(auto-updates from content) |
||||||
|
{/if} |
||||||
|
</div> |
||||||
|
{/if} |
||||||
|
{/if} |
||||||
|
|
||||||
|
<!-- Tag Values --> |
||||||
|
<div class="space-y-2"> |
||||||
|
<div class="flex items-center gap-2"> |
||||||
|
<span class="text-sm font-medium text-gray-700 dark:text-gray-300 min-w-[60px]">Values:</span> |
||||||
|
<button |
||||||
|
type="button" |
||||||
|
class="btn btn-sm btn-outline btn-primary" |
||||||
|
onclick={() => addTagValue(i)} |
||||||
|
> |
||||||
|
Add Value |
||||||
|
</button> |
||||||
|
</div> |
||||||
|
|
||||||
|
{#each tag.values as value, valueIndex} |
||||||
|
<div class="flex gap-2 items-center"> |
||||||
|
<span class="text-xs text-gray-500 dark:text-gray-400 min-w-[40px]"> |
||||||
|
{valueIndex + 1}: |
||||||
|
</span> |
||||||
|
<input |
||||||
|
type="text" |
||||||
|
class="input input-bordered flex-1" |
||||||
|
placeholder="value" |
||||||
|
value={value} |
||||||
|
oninput={(e) => updateTagValue(i, valueIndex, (e.target as HTMLInputElement).value)} |
||||||
|
/> |
||||||
|
{#if tag.values.length > 1} |
||||||
|
<button |
||||||
|
type="button" |
||||||
|
class="btn btn-sm btn-outline btn-error" |
||||||
|
onclick={() => removeTagValue(i, valueIndex)} |
||||||
|
> |
||||||
|
× |
||||||
|
</button> |
||||||
|
{/if} |
||||||
|
</div> |
||||||
|
{/each} |
||||||
|
</div> |
||||||
|
</div> |
||||||
|
{/each} |
||||||
|
|
||||||
|
<!-- Add Tag Button --> |
||||||
|
<div class="flex justify-end"> |
||||||
|
<button |
||||||
|
type="button" |
||||||
|
class="btn btn-primary btn-sm border border-primary-600 px-3 py-1" |
||||||
|
onclick={addTag} |
||||||
|
> |
||||||
|
Add Tag |
||||||
|
</button> |
||||||
|
</div> |
||||||
|
</div> |
||||||
|
</div> |
||||||
@ -0,0 +1,277 @@ |
|||||||
|
/** |
||||||
|
* Event publishing and loading services |
||||||
|
*/ |
||||||
|
|
||||||
|
import { get } from "svelte/store"; |
||||||
|
import { userStore } from "$lib/stores/userStore"; |
||||||
|
import NDK, { NDKEvent as NDKEventClass } from "@nostr-dev-kit/ndk"; |
||||||
|
import type { NDKEvent } from "$lib/utils/nostrUtils"; |
||||||
|
import { prefixNostrAddresses } from "$lib/utils/nostrUtils"; |
||||||
|
import { fetchEventWithFallback } from "$lib/utils/nostrUtils"; |
||||||
|
|
||||||
|
import { WebSocketPool } from "$lib/data_structures/websocket_pool"; |
||||||
|
import { anonymousRelays } from "$lib/consts"; |
||||||
|
import { activeInboxRelays, activeOutboxRelays } from "$lib/ndk"; |
||||||
|
import { removeMetadataFromContent } from "$lib/utils/asciidoc_metadata"; |
||||||
|
import { build30040EventSet } from "$lib/utils/event_input_utils"; |
||||||
|
import type { EventData, TagData, PublishResult, LoadEventResult } from "./types"; |
||||||
|
|
||||||
|
/** |
||||||
|
* Converts TagData array to NDK-compatible format |
||||||
|
*/ |
||||||
|
function convertTagsToNDKFormat(tags: TagData[]): string[][] { |
||||||
|
return tags |
||||||
|
.filter(tag => tag.key.trim() !== "") |
||||||
|
.map(tag => [tag.key, ...tag.values]); |
||||||
|
} |
||||||
|
|
||||||
|
/** |
||||||
|
* Publishes an event to relays |
||||||
|
*/ |
||||||
|
export async function publishEvent(ndk: any, eventData: EventData, tags: TagData[]): Promise<PublishResult> { |
||||||
|
if (!ndk) { |
||||||
|
return { success: false, error: "NDK context not available" }; |
||||||
|
} |
||||||
|
|
||||||
|
const userState = get(userStore); |
||||||
|
const pubkey = userState.pubkey; |
||||||
|
|
||||||
|
if (!pubkey) { |
||||||
|
return { success: false, error: "User not logged in." }; |
||||||
|
} |
||||||
|
|
||||||
|
const pubkeyString = String(pubkey); |
||||||
|
if (!/^[a-fA-F0-9]{64}$/.test(pubkeyString)) { |
||||||
|
return { success: false, error: "Invalid public key: must be a 64-character hex string." }; |
||||||
|
} |
||||||
|
|
||||||
|
const baseEvent = { pubkey: pubkeyString, created_at: eventData.createdAt }; |
||||||
|
let events: NDKEvent[] = []; |
||||||
|
|
||||||
|
console.log("Publishing event with kind:", eventData.kind); |
||||||
|
console.log("Content length:", eventData.content.length); |
||||||
|
console.log("Content preview:", eventData.content.substring(0, 100)); |
||||||
|
console.log("Tags:", tags); |
||||||
|
|
||||||
|
if (Number(eventData.kind) === 30040) { |
||||||
|
console.log("=== 30040 EVENT CREATION START ==="); |
||||||
|
console.log("Creating 30040 event set with content:", eventData.content); |
||||||
|
|
||||||
|
try { |
||||||
|
// Get the current d and title values from the UI
|
||||||
|
const dTagValue = tags.find(tag => tag.key === "d")?.values[0] || ""; |
||||||
|
const titleTagValue = tags.find(tag => tag.key === "title")?.values[0] || ""; |
||||||
|
|
||||||
|
// Convert multi-value tags to the format expected by build30040EventSet
|
||||||
|
// Filter out d and title tags since we'll add them manually
|
||||||
|
const compatibleTags: [string, string][] = tags |
||||||
|
.filter(tag => tag.key.trim() !== "" && tag.key !== "d" && tag.key !== "title") |
||||||
|
.map(tag => [tag.key, tag.values[0] || ""] as [string, string]); |
||||||
|
|
||||||
|
const { indexEvent, sectionEvents } = build30040EventSet( |
||||||
|
eventData.content, |
||||||
|
compatibleTags, |
||||||
|
baseEvent, |
||||||
|
ndk, |
||||||
|
); |
||||||
|
|
||||||
|
// Override the d and title tags with the UI values if they exist
|
||||||
|
const finalTags = indexEvent.tags.filter(tag => tag[0] !== "d" && tag[0] !== "title"); |
||||||
|
if (dTagValue) { |
||||||
|
finalTags.push(["d", dTagValue]); |
||||||
|
} |
||||||
|
if (titleTagValue) { |
||||||
|
finalTags.push(["title", titleTagValue]); |
||||||
|
} |
||||||
|
|
||||||
|
// Update the index event with the correct tags
|
||||||
|
indexEvent.tags = finalTags; |
||||||
|
console.log("Index event:", indexEvent); |
||||||
|
console.log("Section events:", sectionEvents); |
||||||
|
|
||||||
|
// Publish all 30041 section events first, then the 30040 index event
|
||||||
|
events = [...sectionEvents, indexEvent]; |
||||||
|
console.log("Total events to publish:", events.length); |
||||||
|
console.log("=== 30040 EVENT CREATION END ==="); |
||||||
|
} catch (error) { |
||||||
|
console.error("Error in build30040EventSet:", error); |
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
error: `Failed to build 30040 event set: ${error instanceof Error ? error.message : "Unknown error"}`
|
||||||
|
}; |
||||||
|
} |
||||||
|
} else { |
||||||
|
// Convert multi-value tags to the format expected by NDK
|
||||||
|
let eventTags = convertTagsToNDKFormat(tags); |
||||||
|
|
||||||
|
// For AsciiDoc events, remove metadata from content
|
||||||
|
let finalContent = eventData.content; |
||||||
|
if (eventData.kind === 30040 || eventData.kind === 30041) { |
||||||
|
finalContent = removeMetadataFromContent(eventData.content); |
||||||
|
} |
||||||
|
|
||||||
|
// Prefix Nostr addresses before publishing
|
||||||
|
const prefixedContent = prefixNostrAddresses(finalContent); |
||||||
|
|
||||||
|
// Create event with proper serialization
|
||||||
|
const eventDataForNDK = { |
||||||
|
kind: eventData.kind, |
||||||
|
content: prefixedContent, |
||||||
|
tags: eventTags, |
||||||
|
pubkey: pubkeyString, |
||||||
|
created_at: eventData.createdAt, |
||||||
|
}; |
||||||
|
|
||||||
|
events = [new NDKEventClass(ndk, eventDataForNDK)]; |
||||||
|
} |
||||||
|
|
||||||
|
let atLeastOne = false; |
||||||
|
let relaysPublished: string[] = []; |
||||||
|
let lastEventId: string | null = null; |
||||||
|
|
||||||
|
for (let i = 0; i < events.length; i++) { |
||||||
|
const event = events[i]; |
||||||
|
try { |
||||||
|
console.log("Publishing event:", { |
||||||
|
kind: event.kind, |
||||||
|
content: event.content, |
||||||
|
tags: event.tags, |
||||||
|
hasContent: event.content && event.content.length > 0, |
||||||
|
}); |
||||||
|
|
||||||
|
// Always sign with a plain object if window.nostr is available
|
||||||
|
// Create a completely plain object to avoid proxy cloning issues
|
||||||
|
const plainEvent = { |
||||||
|
kind: Number(event.kind), |
||||||
|
pubkey: String(event.pubkey), |
||||||
|
created_at: Number( |
||||||
|
event.created_at ?? Math.floor(Date.now() / 1000), |
||||||
|
), |
||||||
|
tags: event.tags.map((tag) => tag.map(String)), |
||||||
|
content: String(event.content), |
||||||
|
}; |
||||||
|
|
||||||
|
if ( |
||||||
|
typeof window !== "undefined" && |
||||||
|
window.nostr && |
||||||
|
window.nostr.signEvent |
||||||
|
) { |
||||||
|
const signed = await window.nostr.signEvent(plainEvent); |
||||||
|
event.sig = signed.sig; |
||||||
|
if ("id" in signed) { |
||||||
|
event.id = signed.id as string; |
||||||
|
} |
||||||
|
} else { |
||||||
|
await event.sign(); |
||||||
|
} |
||||||
|
|
||||||
|
// Use direct WebSocket publishing like CommentBox does
|
||||||
|
const signedEvent = { |
||||||
|
...plainEvent, |
||||||
|
id: event.id, |
||||||
|
sig: event.sig, |
||||||
|
}; |
||||||
|
|
||||||
|
// Try to publish to relays directly
|
||||||
|
const relays = [ |
||||||
|
...anonymousRelays, |
||||||
|
...get(activeOutboxRelays), |
||||||
|
...get(activeInboxRelays), |
||||||
|
]; |
||||||
|
let published = false; |
||||||
|
|
||||||
|
for (const relayUrl of relays) { |
||||||
|
try { |
||||||
|
const ws = await WebSocketPool.instance.acquire(relayUrl); |
||||||
|
|
||||||
|
await new Promise<void>((resolve, reject) => { |
||||||
|
const timeout = setTimeout(() => { |
||||||
|
WebSocketPool.instance.release(ws); |
||||||
|
reject(new Error("Timeout")); |
||||||
|
}, 5000); |
||||||
|
|
||||||
|
ws.onmessage = (e) => { |
||||||
|
const [type, id, ok, message] = JSON.parse(e.data); |
||||||
|
if (type === "OK" && id === signedEvent.id) { |
||||||
|
clearTimeout(timeout); |
||||||
|
if (ok) { |
||||||
|
published = true; |
||||||
|
relaysPublished.push(relayUrl); |
||||||
|
WebSocketPool.instance.release(ws); |
||||||
|
resolve(); |
||||||
|
} else { |
||||||
|
WebSocketPool.instance.release(ws); |
||||||
|
reject(new Error(message)); |
||||||
|
} |
||||||
|
} |
||||||
|
}; |
||||||
|
|
||||||
|
// Send the event to the relay
|
||||||
|
ws.send(JSON.stringify(["EVENT", signedEvent])); |
||||||
|
}); |
||||||
|
if (published) break; |
||||||
|
} catch (e) { |
||||||
|
console.error(`Failed to publish to ${relayUrl}:`, e); |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
if (published) { |
||||||
|
atLeastOne = true; |
||||||
|
// For 30040, set lastEventId to the index event (last in array)
|
||||||
|
if (Number(eventData.kind) === 30040) { |
||||||
|
if (i === events.length - 1) { |
||||||
|
lastEventId = event.id; |
||||||
|
} |
||||||
|
} else { |
||||||
|
lastEventId = event.id; |
||||||
|
} |
||||||
|
} |
||||||
|
} catch (signError) { |
||||||
|
console.error("Error signing/publishing event:", signError); |
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
error: `Failed to sign event: ${signError instanceof Error ? signError.message : "Unknown error"}`
|
||||||
|
}; |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
if (atLeastOne) { |
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
eventId: lastEventId || undefined, |
||||||
|
relays: relaysPublished
|
||||||
|
}; |
||||||
|
} else { |
||||||
|
return { success: false, error: "Failed to publish to any relay." }; |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
/** |
||||||
|
* Loads an event by its hex ID |
||||||
|
*/ |
||||||
|
export async function loadEvent(ndk: any, eventId: string): Promise<LoadEventResult | null> { |
||||||
|
if (!ndk) { |
||||||
|
throw new Error("NDK context not available"); |
||||||
|
} |
||||||
|
|
||||||
|
const foundEvent = await fetchEventWithFallback(ndk, eventId, 10000); |
||||||
|
|
||||||
|
if (foundEvent) { |
||||||
|
// Convert NDK event format to our format
|
||||||
|
const eventData: EventData = { |
||||||
|
kind: foundEvent.kind, // Use the actual kind from the event
|
||||||
|
content: foundEvent.content || "", // Preserve content exactly as-is
|
||||||
|
createdAt: Math.floor(Date.now() / 1000), // Use current time for replacement
|
||||||
|
}; |
||||||
|
|
||||||
|
// Convert NDK tags format to our format
|
||||||
|
const tags: TagData[] = foundEvent.tags.map((tag: string[]) => ({ |
||||||
|
key: tag[0] || "", |
||||||
|
values: tag.slice(1) |
||||||
|
})); |
||||||
|
|
||||||
|
return { eventData, tags }; |
||||||
|
} |
||||||
|
|
||||||
|
return null; |
||||||
|
} |
||||||
@ -0,0 +1,63 @@ |
|||||||
|
/** |
||||||
|
* Type definitions for the EventInput component system |
||||||
|
*/ |
||||||
|
|
||||||
|
export interface EventData { |
||||||
|
kind: number; |
||||||
|
content: string; |
||||||
|
createdAt: number; |
||||||
|
} |
||||||
|
|
||||||
|
export interface TagData { |
||||||
|
key: string; |
||||||
|
values: string[]; |
||||||
|
} |
||||||
|
|
||||||
|
export interface ValidationResult { |
||||||
|
valid: boolean; |
||||||
|
reason?: string; |
||||||
|
warning?: string; |
||||||
|
} |
||||||
|
|
||||||
|
export interface PublishResult { |
||||||
|
success: boolean; |
||||||
|
eventId?: string; |
||||||
|
relays?: string[]; |
||||||
|
error?: string; |
||||||
|
} |
||||||
|
|
||||||
|
export interface LoadEventResult { |
||||||
|
eventData: EventData; |
||||||
|
tags: TagData[]; |
||||||
|
} |
||||||
|
|
||||||
|
export interface EventPreview { |
||||||
|
type: 'standard_event' | '30040_index_event' | 'error'; |
||||||
|
event?: { |
||||||
|
id: string; |
||||||
|
pubkey: string; |
||||||
|
created_at: number; |
||||||
|
kind: number; |
||||||
|
tags: string[][]; |
||||||
|
content: string; |
||||||
|
sig: string; |
||||||
|
}; |
||||||
|
message?: string; |
||||||
|
} |
||||||
|
|
||||||
|
export interface PresetTag { |
||||||
|
key: string; |
||||||
|
defaultValue: string; |
||||||
|
required: boolean; |
||||||
|
autoUpdate: boolean; |
||||||
|
description: string; |
||||||
|
} |
||||||
|
|
||||||
|
export interface KindConfig { |
||||||
|
kind: number; |
||||||
|
name: string; |
||||||
|
description: string; |
||||||
|
presetTags: PresetTag[]; |
||||||
|
requiresContent: boolean; |
||||||
|
contentValidation?: (content: string) => ValidationResult; |
||||||
|
} |
||||||
@ -0,0 +1,90 @@ |
|||||||
|
/** |
||||||
|
* Event validation utilities |
||||||
|
*/ |
||||||
|
|
||||||
|
import { get } from "svelte/store"; |
||||||
|
import { userStore } from "$lib/stores/userStore"; |
||||||
|
import type { EventData, TagData, ValidationResult } from "./types"; |
||||||
|
import { |
||||||
|
validateNotAsciidoc, |
||||||
|
validateAsciiDoc, |
||||||
|
validate30040EventSet, |
||||||
|
} from "$lib/utils/event_input_utils"; |
||||||
|
|
||||||
|
/** |
||||||
|
* Validates an event and its tags |
||||||
|
*/ |
||||||
|
export function validateEvent(eventData: EventData, tags: TagData[]): ValidationResult { |
||||||
|
const userState = get(userStore); |
||||||
|
|
||||||
|
const pubkey = userState.pubkey; |
||||||
|
if (!pubkey) { |
||||||
|
return { valid: false, reason: "Not logged in." }; |
||||||
|
} |
||||||
|
|
||||||
|
// Content validation - 30040 events don't require content
|
||||||
|
if (eventData.kind !== 30040 && !eventData.content.trim()) { |
||||||
|
return { valid: false, reason: "Content required." }; |
||||||
|
} |
||||||
|
|
||||||
|
// Kind-specific validation
|
||||||
|
if (eventData.kind === 30023) { |
||||||
|
const v = validateNotAsciidoc(eventData.content); |
||||||
|
if (!v.valid) return v; |
||||||
|
} |
||||||
|
|
||||||
|
if (eventData.kind === 30040) { |
||||||
|
// Check for required tags
|
||||||
|
const versionTag = tags.find(t => t.key === "version"); |
||||||
|
const dTag = tags.find(t => t.key === "d"); |
||||||
|
const titleTag = tags.find(t => t.key === "title"); |
||||||
|
|
||||||
|
if (!versionTag || !versionTag.values[0] || versionTag.values[0].trim() === "") { |
||||||
|
return { valid: false, reason: "30040 events require a 'version' tag." }; |
||||||
|
} |
||||||
|
|
||||||
|
if (!dTag || !dTag.values[0] || dTag.values[0].trim() === "") { |
||||||
|
return { valid: false, reason: "30040 events require a 'd' tag." }; |
||||||
|
} |
||||||
|
|
||||||
|
if (!titleTag || !titleTag.values[0] || titleTag.values[0].trim() === "") { |
||||||
|
return { valid: false, reason: "30040 events require a 'title' tag." }; |
||||||
|
} |
||||||
|
|
||||||
|
// Validate content format if present
|
||||||
|
if (eventData.content.trim()) { |
||||||
|
const v = validate30040EventSet(eventData.content); |
||||||
|
if (!v.valid) return v; |
||||||
|
if (v.warning) return { valid: true, warning: v.warning }; |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
if (eventData.kind === 30041 || eventData.kind === 30818) { |
||||||
|
const v = validateAsciiDoc(eventData.content); |
||||||
|
if (!v.valid) return v; |
||||||
|
} |
||||||
|
|
||||||
|
return { valid: true }; |
||||||
|
} |
||||||
|
|
||||||
|
/** |
||||||
|
* Validates that a kind is within valid range |
||||||
|
*/ |
||||||
|
export function isValidKind(kind: number | string): boolean { |
||||||
|
const n = Number(kind); |
||||||
|
return Number.isInteger(n) && n >= 0 && n <= 65535; |
||||||
|
} |
||||||
|
|
||||||
|
/** |
||||||
|
* Validates that a tag has a valid key |
||||||
|
*/ |
||||||
|
export function isValidTagKey(key: string): boolean { |
||||||
|
return key.trim().length > 0; |
||||||
|
} |
||||||
|
|
||||||
|
/** |
||||||
|
* Validates that a tag has at least one value |
||||||
|
*/ |
||||||
|
export function isValidTag(tag: TagData): boolean { |
||||||
|
return isValidTagKey(tag.key) && tag.values.some(v => v.trim().length > 0); |
||||||
|
} |
||||||
@ -0,0 +1,236 @@ |
|||||||
|
# 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 @@ |
|||||||
|
export type SearchType = "id" | "d" | "t" | "n" | "q"; |
||||||
@ -0,0 +1,12 @@ |
|||||||
|
export interface UserProfile { |
||||||
|
name?: string; |
||||||
|
display_name?: string; |
||||||
|
about?: string; |
||||||
|
picture?: string; |
||||||
|
banner?: string; |
||||||
|
website?: string; |
||||||
|
lud16?: string; |
||||||
|
nip05?: string; |
||||||
|
isInUserLists?: boolean; |
||||||
|
listKinds?: number[]; |
||||||
|
} |
||||||
@ -0,0 +1,90 @@ |
|||||||
|
/** |
||||||
|
* Service class for handling event search operations |
||||||
|
* AI-NOTE: 2025-01-24 - Extracted from EventSearch component for better separation of concerns |
||||||
|
*/ |
||||||
|
export class EventSearchService { |
||||||
|
/** |
||||||
|
* Determines the search type from a query string |
||||||
|
*/ |
||||||
|
getSearchType(query: string): { type: string; term: string } | null { |
||||||
|
const lowerQuery = query.toLowerCase(); |
||||||
|
|
||||||
|
if (lowerQuery.startsWith("d:")) { |
||||||
|
const dTag = query.slice(2).trim().toLowerCase(); |
||||||
|
return dTag ? { type: "d", term: dTag } : null; |
||||||
|
} |
||||||
|
|
||||||
|
if (lowerQuery.startsWith("t:")) { |
||||||
|
const searchTerm = query.slice(2).trim(); |
||||||
|
return searchTerm ? { type: "t", term: searchTerm } : null; |
||||||
|
} |
||||||
|
|
||||||
|
if (lowerQuery.startsWith("n:")) { |
||||||
|
const searchTerm = query.slice(2).trim(); |
||||||
|
return searchTerm ? { type: "n", term: searchTerm } : null; |
||||||
|
} |
||||||
|
|
||||||
|
if (query.includes("@")) { |
||||||
|
return { type: "nip05", term: query }; |
||||||
|
} |
||||||
|
|
||||||
|
return null; |
||||||
|
} |
||||||
|
|
||||||
|
/** |
||||||
|
* Checks if a search value matches the current event |
||||||
|
*/ |
||||||
|
isCurrentEventMatch( |
||||||
|
searchValue: string, |
||||||
|
event: any, |
||||||
|
relays: string[], |
||||||
|
): boolean { |
||||||
|
const currentEventId = event.id; |
||||||
|
let currentNaddr = null; |
||||||
|
let currentNevent = null; |
||||||
|
let currentNpub = null; |
||||||
|
let currentNprofile = null; |
||||||
|
|
||||||
|
try { |
||||||
|
const { neventEncode, naddrEncode, nprofileEncode } = require( |
||||||
|
"$lib/utils", |
||||||
|
); |
||||||
|
const { getMatchingTags, toNpub } = require("$lib/utils/nostrUtils"); |
||||||
|
|
||||||
|
currentNevent = neventEncode(event, relays); |
||||||
|
} catch {} |
||||||
|
|
||||||
|
try { |
||||||
|
const { naddrEncode } = require("$lib/utils"); |
||||||
|
const { getMatchingTags } = require("$lib/utils/nostrUtils"); |
||||||
|
|
||||||
|
currentNaddr = getMatchingTags(event, "d")[0]?.[1] |
||||||
|
? naddrEncode(event, relays) |
||||||
|
: null; |
||||||
|
} catch {} |
||||||
|
|
||||||
|
try { |
||||||
|
const { toNpub } = require("$lib/utils/nostrUtils"); |
||||||
|
currentNpub = event.kind === 0 ? toNpub(event.pubkey) : null; |
||||||
|
} catch {} |
||||||
|
|
||||||
|
if ( |
||||||
|
searchValue && |
||||||
|
searchValue.startsWith("nprofile1") && |
||||||
|
event.kind === 0 |
||||||
|
) { |
||||||
|
try { |
||||||
|
const { nprofileEncode } = require("$lib/utils"); |
||||||
|
currentNprofile = nprofileEncode(event.pubkey, relays); |
||||||
|
} catch {} |
||||||
|
} |
||||||
|
|
||||||
|
return ( |
||||||
|
searchValue === currentEventId || |
||||||
|
(currentNaddr && searchValue === currentNaddr) || |
||||||
|
(currentNevent && searchValue === currentNevent) || |
||||||
|
(currentNpub && searchValue === currentNpub) || |
||||||
|
(currentNprofile && searchValue === currentNprofile) |
||||||
|
); |
||||||
|
} |
||||||
|
} |
||||||
@ -0,0 +1,70 @@ |
|||||||
|
/** |
||||||
|
* Service class for managing search state operations |
||||||
|
* AI-NOTE: 2025-01-24 - Extracted from EventSearch component for better separation of concerns |
||||||
|
*/ |
||||||
|
export class SearchStateManager { |
||||||
|
/** |
||||||
|
* Updates the search state with new values |
||||||
|
*/ |
||||||
|
updateSearchState( |
||||||
|
state: { |
||||||
|
searching: boolean; |
||||||
|
searchCompleted: boolean; |
||||||
|
searchResultCount: number | null; |
||||||
|
searchResultType: string | null; |
||||||
|
}, |
||||||
|
onLoadingChange?: (loading: boolean) => void, |
||||||
|
): void { |
||||||
|
if (onLoadingChange) { |
||||||
|
onLoadingChange(state.searching); |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
/** |
||||||
|
* Resets all search state to initial values |
||||||
|
*/ |
||||||
|
resetSearchState( |
||||||
|
callbacks: { |
||||||
|
onSearchResults: ( |
||||||
|
events: any[], |
||||||
|
secondOrder: any[], |
||||||
|
tTagEvents: any[], |
||||||
|
eventIds: Set<string>, |
||||||
|
addresses: Set<string>, |
||||||
|
) => void; |
||||||
|
cleanupSearch: () => void; |
||||||
|
clearTimeout: () => void; |
||||||
|
}, |
||||||
|
): void { |
||||||
|
callbacks.cleanupSearch(); |
||||||
|
callbacks.onSearchResults([], [], [], new Set(), new Set()); |
||||||
|
callbacks.clearTimeout(); |
||||||
|
} |
||||||
|
|
||||||
|
/** |
||||||
|
* Handles search errors with consistent error handling |
||||||
|
*/ |
||||||
|
handleSearchError( |
||||||
|
error: unknown, |
||||||
|
defaultMessage: string, |
||||||
|
callbacks: { |
||||||
|
setLocalError: (error: string | null) => void; |
||||||
|
cleanupSearch: () => void; |
||||||
|
updateSearchState: (state: any) => void; |
||||||
|
resetProcessingFlags: () => void; |
||||||
|
}, |
||||||
|
): void { |
||||||
|
const errorMessage = error instanceof Error |
||||||
|
? error.message |
||||||
|
: defaultMessage; |
||||||
|
callbacks.setLocalError(errorMessage); |
||||||
|
callbacks.cleanupSearch(); |
||||||
|
callbacks.updateSearchState({ |
||||||
|
searching: false, |
||||||
|
searchCompleted: false, |
||||||
|
searchResultCount: null, |
||||||
|
searchResultType: null, |
||||||
|
}); |
||||||
|
callbacks.resetProcessingFlags(); |
||||||
|
} |
||||||
|
} |
||||||
@ -1,11 +0,0 @@ |
|||||||
import { writable, derived } from "svelte/store"; |
|
||||||
|
|
||||||
/** |
|
||||||
* Stores the user's public key if logged in, or null otherwise. |
|
||||||
*/ |
|
||||||
export const userPubkey = writable<string | null>(null); |
|
||||||
|
|
||||||
/** |
|
||||||
* Derived store indicating if the user is logged in. |
|
||||||
*/ |
|
||||||
export const isLoggedIn = derived(userPubkey, ($userPubkey) => !!$userPubkey); |
|
||||||
@ -0,0 +1,85 @@ |
|||||||
|
import { unifiedProfileCache } from './npubCache'; |
||||||
|
import { searchCache } from './searchCache'; |
||||||
|
import { indexEventCache } from './indexEventCache'; |
||||||
|
import { clearRelaySetCache } from '../ndk'; |
||||||
|
|
||||||
|
/** |
||||||
|
* Clears all application caches |
||||||
|
*
|
||||||
|
* Clears: |
||||||
|
* - unifiedProfileCache (profile metadata) |
||||||
|
* - searchCache (search results) |
||||||
|
* - indexEventCache (index events) |
||||||
|
* - relaySetCache (relay configuration) |
||||||
|
*/ |
||||||
|
export function clearAllCaches(): void { |
||||||
|
console.log('[CacheManager] Clearing all application caches...'); |
||||||
|
|
||||||
|
// Clear in-memory caches
|
||||||
|
unifiedProfileCache.clear(); |
||||||
|
searchCache.clear(); |
||||||
|
indexEventCache.clear(); |
||||||
|
clearRelaySetCache(); |
||||||
|
|
||||||
|
// Clear localStorage caches
|
||||||
|
clearLocalStorageCaches(); |
||||||
|
|
||||||
|
console.log('[CacheManager] All caches cleared successfully'); |
||||||
|
} |
||||||
|
|
||||||
|
/** |
||||||
|
* Clears profile-specific caches to force fresh profile data |
||||||
|
* This is useful when profile pictures or metadata are stale |
||||||
|
*/ |
||||||
|
export function clearProfileCaches(): void { |
||||||
|
console.log('[CacheManager] Clearing profile-specific caches...'); |
||||||
|
|
||||||
|
// Clear unified profile cache
|
||||||
|
unifiedProfileCache.clear(); |
||||||
|
|
||||||
|
// Clear profile-related search results
|
||||||
|
// Note: searchCache doesn't have a way to clear specific types, so we clear all
|
||||||
|
// This is acceptable since profile searches are the most common
|
||||||
|
searchCache.clear(); |
||||||
|
|
||||||
|
console.log('[CacheManager] Profile caches cleared successfully'); |
||||||
|
} |
||||||
|
|
||||||
|
/** |
||||||
|
* Clears localStorage caches |
||||||
|
*/ |
||||||
|
function clearLocalStorageCaches(): void { |
||||||
|
if (typeof window === 'undefined') return; |
||||||
|
|
||||||
|
const keysToRemove: string[] = []; |
||||||
|
|
||||||
|
// Find all localStorage keys that start with 'alexandria'
|
||||||
|
for (let i = 0; i < localStorage.length; i++) { |
||||||
|
const key = localStorage.key(i); |
||||||
|
if (key && key.startsWith('alexandria')) { |
||||||
|
keysToRemove.push(key); |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
// Remove the keys
|
||||||
|
keysToRemove.forEach(key => { |
||||||
|
localStorage.removeItem(key); |
||||||
|
}); |
||||||
|
|
||||||
|
console.log(`[CacheManager] Cleared ${keysToRemove.length} localStorage items`); |
||||||
|
} |
||||||
|
|
||||||
|
/** |
||||||
|
* Gets statistics about all caches |
||||||
|
*/ |
||||||
|
export function getCacheStats(): { |
||||||
|
profileCacheSize: number; |
||||||
|
searchCacheSize: number; |
||||||
|
indexEventCacheSize: number; |
||||||
|
} { |
||||||
|
return { |
||||||
|
profileCacheSize: unifiedProfileCache.size(), |
||||||
|
searchCacheSize: searchCache.size(), |
||||||
|
indexEventCacheSize: indexEventCache.size(), |
||||||
|
}; |
||||||
|
} |
||||||
@ -0,0 +1,147 @@ |
|||||||
|
import NDK, { NDKEvent, NDKRelaySet } from "@nostr-dev-kit/ndk"; |
||||||
|
import { createSignedEvent } from "./nostrEventService.ts"; |
||||||
|
import { anonymousRelays } from "../consts.ts"; |
||||||
|
import { buildCompleteRelaySet } from "./relay_management.ts"; |
||||||
|
|
||||||
|
// 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, |
||||||
|
ndk: NDK, |
||||||
|
): Promise<string[]> { |
||||||
|
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: any) => |
||||||
|
recipientInboxRelays.includes(relay) |
||||||
|
); |
||||||
|
const senderOnlyRelays = senderOutboxRelays.filter((relay: any) => |
||||||
|
!recipientInboxRelays.includes(relay) |
||||||
|
); |
||||||
|
const recipientOnlyRelays = recipientInboxRelays.filter((relay: any) => |
||||||
|
!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, |
||||||
|
ndk: NDK, |
||||||
|
originalEvent?: NDKEvent, |
||||||
|
): Promise< |
||||||
|
{ success: boolean; eventId?: string; error?: string; relays?: string[] } |
||||||
|
> { |
||||||
|
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, |
||||||
|
ndk, |
||||||
|
); |
||||||
|
|
||||||
|
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,277 @@ |
|||||||
|
import { nip19 } from "nostr-tools"; |
||||||
|
import { |
||||||
|
processBasicTextFormatting, |
||||||
|
processBlockquotes, |
||||||
|
processEmojiShortcodes, |
||||||
|
processHashtags, |
||||||
|
processImageWithReveal, |
||||||
|
processMediaUrl, |
||||||
|
processNostrIdentifiersInText, |
||||||
|
processNostrIdentifiersWithEmbeddedEvents, |
||||||
|
processWebSocketUrls, |
||||||
|
processWikilinks, |
||||||
|
stripTrackingParams, |
||||||
|
} from "./markupServices.ts"; |
||||||
|
|
||||||
|
/* Regex constants for basic markup parsing */ |
||||||
|
|
||||||
|
// Links and media
|
||||||
|
const MARKUP_LINK = /\[([^\]]+)\]\(([^)]+)\)/g; |
||||||
|
const MARKUP_IMAGE = /!\[([^\]]*)\]\(([^)]+)\)/g; |
||||||
|
// AI-NOTE: 2025-01-24 - Added negative lookbehind (?<!\]\() to prevent processing URLs in markdown syntax
|
||||||
|
const DIRECT_LINK = /(?<!["'=])(?<!\]\()(https?:\/\/[^\s<>"]+)(?!["'])/g; |
||||||
|
|
||||||
|
// Add this helper function near the top:
|
||||||
|
function replaceAlexandriaNostrLinks(text: string): string { |
||||||
|
// Regex for Alexandria/localhost URLs
|
||||||
|
const alexandriaPattern = |
||||||
|
/^https?:\/\/((next-)?alexandria\.gitcitadel\.(eu|com)|localhost(:\d+)?)/i; |
||||||
|
// Regex for bech32 Nostr identifiers
|
||||||
|
const bech32Pattern = /(npub|nprofile|note|nevent|naddr)[a-zA-Z0-9]{20,}/; |
||||||
|
// Regex for 64-char hex
|
||||||
|
const hexPattern = /\b[a-fA-F0-9]{64}\b/; |
||||||
|
|
||||||
|
// 1. Alexandria/localhost markup links
|
||||||
|
text = text.replace( |
||||||
|
/\[([^\]]+)\]\((https?:\/\/[^\s)]+)\)/g, |
||||||
|
(match, _label, url) => { |
||||||
|
if (alexandriaPattern.test(url)) { |
||||||
|
if (/[?&]d=/.test(url)) return match; |
||||||
|
const hexMatch = url.match(hexPattern); |
||||||
|
if (hexMatch) { |
||||||
|
try { |
||||||
|
const nevent = nip19.neventEncode({ id: hexMatch[0] }); |
||||||
|
return `nostr:${nevent}`; |
||||||
|
} catch { |
||||||
|
return match; |
||||||
|
} |
||||||
|
} |
||||||
|
const bech32Match = url.match(bech32Pattern); |
||||||
|
if (bech32Match) { |
||||||
|
return `nostr:${bech32Match[0]}`; |
||||||
|
} |
||||||
|
} |
||||||
|
return match; |
||||||
|
}, |
||||||
|
); |
||||||
|
|
||||||
|
// 2. Alexandria/localhost bare URLs and non-Alexandria/localhost URLs with Nostr identifiers
|
||||||
|
text = text.replace(/https?:\/\/[^\s)\]]+/g, (url) => { |
||||||
|
if (alexandriaPattern.test(url)) { |
||||||
|
if (/[?&]d=/.test(url)) return url; |
||||||
|
const hexMatch = url.match(hexPattern); |
||||||
|
if (hexMatch) { |
||||||
|
try { |
||||||
|
const nevent = nip19.neventEncode({ id: hexMatch[0] }); |
||||||
|
return `nostr:${nevent}`; |
||||||
|
} catch { |
||||||
|
return url; |
||||||
|
} |
||||||
|
} |
||||||
|
const bech32Match = url.match(bech32Pattern); |
||||||
|
if (bech32Match) { |
||||||
|
return `nostr:${bech32Match[0]}`; |
||||||
|
} |
||||||
|
} |
||||||
|
// For non-Alexandria/localhost URLs, just return the URL as-is
|
||||||
|
return url; |
||||||
|
}); |
||||||
|
|
||||||
|
return text; |
||||||
|
} |
||||||
|
|
||||||
|
function renderListGroup(lines: string[], typeHint?: "ol" | "ul"): string { |
||||||
|
function parseList( |
||||||
|
start: number, |
||||||
|
indent: number, |
||||||
|
type: "ol" | "ul", |
||||||
|
): [string, number] { |
||||||
|
let html = ""; |
||||||
|
let i = start; |
||||||
|
html += `<${type} class="${ |
||||||
|
type === "ol" ? "list-decimal" : "list-disc" |
||||||
|
} ml-6 mb-2">`;
|
||||||
|
while (i < lines.length) { |
||||||
|
const line = lines[i]; |
||||||
|
const match = line.match(/^([ \t]*)([*+-]|\d+\.)[ \t]+(.*)$/); |
||||||
|
if (!match) break; |
||||||
|
const lineIndent = match[1].replace(/\t/g, " ").length; |
||||||
|
const isOrdered = /\d+\./.test(match[2]); |
||||||
|
const itemType = isOrdered ? "ol" : "ul"; |
||||||
|
if (lineIndent > indent) { |
||||||
|
// Nested list
|
||||||
|
const [nestedHtml, consumed] = parseList(i, lineIndent, itemType); |
||||||
|
html = html.replace(/<\/li>$/, "") + nestedHtml + "</li>"; |
||||||
|
i = consumed; |
||||||
|
continue; |
||||||
|
} |
||||||
|
if (lineIndent < indent || itemType !== type) { |
||||||
|
break; |
||||||
|
} |
||||||
|
html += `<li class="mb-1">${match[3]}`; |
||||||
|
// Check for next line being a nested list
|
||||||
|
if (i + 1 < lines.length) { |
||||||
|
const nextMatch = lines[i + 1].match(/^([ \t]*)([*+-]|\d+\.)[ \t]+/); |
||||||
|
if (nextMatch) { |
||||||
|
const nextIndent = nextMatch[1].replace(/\t/g, " ").length; |
||||||
|
const nextType = /\d+\./.test(nextMatch[2]) ? "ol" : "ul"; |
||||||
|
if (nextIndent > lineIndent) { |
||||||
|
const [nestedHtml, consumed] = parseList( |
||||||
|
i + 1, |
||||||
|
nextIndent, |
||||||
|
nextType, |
||||||
|
); |
||||||
|
html += nestedHtml; |
||||||
|
i = consumed - 1; |
||||||
|
} |
||||||
|
} |
||||||
|
} |
||||||
|
html += "</li>"; |
||||||
|
i++; |
||||||
|
} |
||||||
|
html += `</${type}>`; |
||||||
|
return [html, i]; |
||||||
|
} |
||||||
|
if (!lines.length) return ""; |
||||||
|
const firstLine = lines[0]; |
||||||
|
const match = firstLine.match(/^([ \t]*)([*+-]|\d+\.)[ \t]+/); |
||||||
|
const indent = match ? match[1].replace(/\t/g, " ").length : 0; |
||||||
|
const type = typeHint || (match && /\d+\./.test(match[2]) ? "ol" : "ul"); |
||||||
|
const [html] = parseList(0, indent, type); |
||||||
|
return html; |
||||||
|
} |
||||||
|
|
||||||
|
function processBasicFormatting(content: string): string { |
||||||
|
if (!content) return ""; |
||||||
|
|
||||||
|
let processedText = content; |
||||||
|
|
||||||
|
try { |
||||||
|
// Sanitize Alexandria Nostr links before further processing
|
||||||
|
processedText = replaceAlexandriaNostrLinks(processedText); |
||||||
|
|
||||||
|
// Process markup images first
|
||||||
|
processedText = processedText.replace(MARKUP_IMAGE, (match, alt, url) => { |
||||||
|
// Clean the URL and alt text
|
||||||
|
const cleanUrl = url.trim(); |
||||||
|
const cleanAlt = alt ? alt.trim() : ""; |
||||||
|
return processImageWithReveal(cleanUrl, cleanAlt); |
||||||
|
}); |
||||||
|
|
||||||
|
// Process markup links
|
||||||
|
processedText = processedText.replace( |
||||||
|
MARKUP_LINK, |
||||||
|
(_match, text, url) => |
||||||
|
`<a href="${ |
||||||
|
stripTrackingParams(url) |
||||||
|
}" class="text-primary-600 dark:text-primary-500 hover:underline" target="_blank" rel="noopener noreferrer">${text}</a>`,
|
||||||
|
); |
||||||
|
|
||||||
|
// Process WebSocket URLs using shared services
|
||||||
|
processedText = processWebSocketUrls(processedText); |
||||||
|
|
||||||
|
// Process direct media URLs and auto-link all URLs
|
||||||
|
processedText = processedText.replace(DIRECT_LINK, (match) => { |
||||||
|
return processMediaUrl(match); |
||||||
|
}); |
||||||
|
|
||||||
|
// Process text formatting using shared services
|
||||||
|
processedText = processBasicTextFormatting(processedText); |
||||||
|
|
||||||
|
// Process hashtags using shared services
|
||||||
|
processedText = processHashtags(processedText); |
||||||
|
|
||||||
|
// --- Improved List Grouping and Parsing ---
|
||||||
|
const lines = processedText.split("\n"); |
||||||
|
let output = ""; |
||||||
|
let buffer: string[] = []; |
||||||
|
let inList = false; |
||||||
|
for (let i = 0; i < lines.length; i++) { |
||||||
|
const line = lines[i]; |
||||||
|
if (/^([ \t]*)([*+-]|\d+\.)[ \t]+/.test(line)) { |
||||||
|
buffer.push(line); |
||||||
|
inList = true; |
||||||
|
} else { |
||||||
|
if (inList) { |
||||||
|
const firstLine = buffer[0]; |
||||||
|
const isOrdered = /^\s*\d+\.\s+/.test(firstLine); |
||||||
|
output += renderListGroup(buffer, isOrdered ? "ol" : "ul"); |
||||||
|
buffer = []; |
||||||
|
inList = false; |
||||||
|
} |
||||||
|
output += (output && !output.endsWith("\n") ? "\n" : "") + line + "\n"; |
||||||
|
} |
||||||
|
} |
||||||
|
if (buffer.length) { |
||||||
|
const firstLine = buffer[0]; |
||||||
|
const isOrdered = /^\s*\d+\.\s+/.test(firstLine); |
||||||
|
output += renderListGroup(buffer, isOrdered ? "ol" : "ul"); |
||||||
|
} |
||||||
|
processedText = output; |
||||||
|
// --- End Improved List Grouping and Parsing ---
|
||||||
|
} catch (e: unknown) { |
||||||
|
console.error("Error in processBasicFormatting:", e); |
||||||
|
} |
||||||
|
|
||||||
|
return processedText; |
||||||
|
} |
||||||
|
|
||||||
|
/** |
||||||
|
* Parse markup with support for embedded Nostr events |
||||||
|
* AI-NOTE: 2025-01-24 - Enhanced markup parser that supports nested Nostr event embedding |
||||||
|
* Up to 3 levels of nesting are supported, after which events are shown as links |
||||||
|
*/ |
||||||
|
export async function parseEmbeddedMarkup( |
||||||
|
text: string, |
||||||
|
nestingLevel: number = 0, |
||||||
|
): Promise<string> { |
||||||
|
if (!text) return ""; |
||||||
|
|
||||||
|
try { |
||||||
|
// Process basic text formatting first
|
||||||
|
let processedText = processBasicFormatting(text); |
||||||
|
|
||||||
|
// Process emoji shortcuts
|
||||||
|
processedText = processEmojiShortcodes(processedText); |
||||||
|
|
||||||
|
// Process blockquotes
|
||||||
|
processedText = processBlockquotes(processedText); |
||||||
|
|
||||||
|
// Process paragraphs - split by double newlines and wrap in p tags
|
||||||
|
// Skip wrapping if content already contains block-level elements
|
||||||
|
const blockLevelEls = |
||||||
|
/(<div[^>]*class=["'][^"']*math-block[^"']*["'])|<(div|h[1-6]|blockquote|table|pre|ul|ol|hr|img)/i; |
||||||
|
processedText = processedText |
||||||
|
.split(/\n\n+/) |
||||||
|
.map((para) => para.trim()) |
||||||
|
.filter((para) => para.length > 0) |
||||||
|
.map((para) => { |
||||||
|
// Skip wrapping if para already contains block-level elements, math blocks, or images
|
||||||
|
if (blockLevelEls.test(para)) { |
||||||
|
return para; |
||||||
|
} |
||||||
|
|
||||||
|
return `<p class="my-1">${para}</p>`; |
||||||
|
}) |
||||||
|
.join("\n"); |
||||||
|
|
||||||
|
// Process profile identifiers (npub, nprofile) first using the regular processor
|
||||||
|
processedText = await processNostrIdentifiersInText(processedText); |
||||||
|
|
||||||
|
// Then process event identifiers with embedded events (only event-related identifiers)
|
||||||
|
processedText = processNostrIdentifiersWithEmbeddedEvents( |
||||||
|
processedText, |
||||||
|
nestingLevel, |
||||||
|
); |
||||||
|
|
||||||
|
// Replace wikilinks
|
||||||
|
processedText = processWikilinks(processedText); |
||||||
|
|
||||||
|
return processedText; |
||||||
|
} catch (e: unknown) { |
||||||
|
console.error("Error in parseEmbeddedMarkup:", e); |
||||||
|
return `<div class="text-red-500">Error processing markup: ${ |
||||||
|
(e as Error)?.message ?? "Unknown error" |
||||||
|
}</div>`;
|
||||||
|
} |
||||||
|
} |
||||||
@ -0,0 +1,321 @@ |
|||||||
|
import NDK from "@nostr-dev-kit/ndk"; |
||||||
|
import { |
||||||
|
createProfileLink, |
||||||
|
getUserMetadata, |
||||||
|
NOSTR_PROFILE_REGEX, |
||||||
|
} from "../nostrUtils.ts"; |
||||||
|
|
||||||
|
import * as emoji from "node-emoji"; |
||||||
|
|
||||||
|
// Media URL patterns
|
||||||
|
const IMAGE_EXTENSIONS = /\.(jpg|jpeg|gif|png|webp|svg)$/i; |
||||||
|
const VIDEO_URL_REGEX = /https?:\/\/[^\s<]+\.(?:mp4|webm|mov|avi)(?:[^\s<]*)?/i; |
||||||
|
const AUDIO_URL_REGEX = /https?:\/\/[^\s<]+\.(?:mp3|wav|ogg|m4a)(?:[^\s<]*)?/i; |
||||||
|
const YOUTUBE_URL_REGEX = |
||||||
|
/https?:\/\/(?:www\.)?(?:youtube\.com\/(?:watch\?v=|embed\/)|youtu\.be\/|youtube-nocookie\.com\/embed\/)([a-zA-Z0-9_-]{11})(?:[^\s<]*)?/; |
||||||
|
|
||||||
|
/** |
||||||
|
* Shared service for processing images with expand functionality |
||||||
|
*/ |
||||||
|
export function processImageWithReveal( |
||||||
|
src: string, |
||||||
|
alt: string = "Image", |
||||||
|
): string { |
||||||
|
if (!src || !IMAGE_EXTENSIONS.test(src.split("?")[0])) { |
||||||
|
return `<img src="${src}" alt="${alt}">`; |
||||||
|
} |
||||||
|
|
||||||
|
return `<div class="relative inline-block w-[300px] h-48 my-2 group">
|
||||||
|
<img
|
||||||
|
src="${src}"
|
||||||
|
alt="${alt}"
|
||||||
|
class="w-full h-full object-contain rounded-lg shadow-lg"
|
||||||
|
loading="lazy"
|
||||||
|
decoding="async" |
||||||
|
/> |
||||||
|
|
||||||
|
<!-- Expand button --> |
||||||
|
<button class="absolute top-2 right-2 bg-black/60 hover:bg-black/80 backdrop-blur-sm text-white rounded-full w-8 h-8 flex items-center justify-center transition-all duration-300 shadow-lg hover:scale-110 z-20"
|
||||||
|
onclick="window.open('${src}', '_blank')"
|
||||||
|
title="Open image in full size" |
||||||
|
aria-label="Open image in full size"> |
||||||
|
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"> |
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M10 6H6a2 2 0 00-2 2v10a2 2 0 002 2h10a2 2 0 002-2v-4M14 4h6m0 0v6m0-6L10 14"></path> |
||||||
|
</svg> |
||||||
|
</button> |
||||||
|
</div>`;
|
||||||
|
} |
||||||
|
|
||||||
|
/** |
||||||
|
* Shared service for processing media URLs |
||||||
|
*/ |
||||||
|
export function processMediaUrl(url: string, alt?: string): string { |
||||||
|
const clean = stripTrackingParams(url); |
||||||
|
|
||||||
|
if (YOUTUBE_URL_REGEX.test(clean)) { |
||||||
|
const videoId = extractYouTubeVideoId(clean); |
||||||
|
if (videoId) { |
||||||
|
return `<iframe class="w-full aspect-video rounded-lg shadow-lg my-2" src="https://www.youtube-nocookie.com/embed/${videoId}" title="${ |
||||||
|
alt || "YouTube video" |
||||||
|
}" frameborder="0" allow="fullscreen" sandbox="allow-scripts allow-same-origin allow-presentation"></iframe>`;
|
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
if (VIDEO_URL_REGEX.test(clean)) { |
||||||
|
return `<video controls class="max-w-full rounded-lg shadow-lg my-2" preload="none" playsinline><source src="${clean}">${ |
||||||
|
alt || "Video" |
||||||
|
}</video>`;
|
||||||
|
} |
||||||
|
|
||||||
|
if (AUDIO_URL_REGEX.test(clean)) { |
||||||
|
return `<audio controls class="w-full my-2" preload="none"><source src="${clean}">${ |
||||||
|
alt || "Audio" |
||||||
|
}</audio>`;
|
||||||
|
} |
||||||
|
|
||||||
|
if (IMAGE_EXTENSIONS.test(clean.split("?")[0])) { |
||||||
|
return processImageWithReveal(clean, alt || "Embedded media"); |
||||||
|
} |
||||||
|
|
||||||
|
// Default to clickable link
|
||||||
|
return `<a href="${clean}" target="_blank" rel="noopener noreferrer" class="text-blue-500 hover:text-blue-600 dark:text-blue-400 dark:hover:text-blue-300">${clean}</a>`; |
||||||
|
} |
||||||
|
|
||||||
|
/** |
||||||
|
* Shared service for processing nostr identifiers |
||||||
|
*/ |
||||||
|
export async function processNostrIdentifiersInText( |
||||||
|
text: string, |
||||||
|
ndk?: NDK, |
||||||
|
): Promise<string> { |
||||||
|
let processedText = text; |
||||||
|
|
||||||
|
// Find all profile-related nostr addresses (only npub and nprofile)
|
||||||
|
const matches = Array.from(processedText.matchAll(NOSTR_PROFILE_REGEX)); |
||||||
|
|
||||||
|
// Process them in reverse order to avoid index shifting issues
|
||||||
|
for (let i = matches.length - 1; i >= 0; i--) { |
||||||
|
const match = matches[i]; |
||||||
|
const [fullMatch] = match; |
||||||
|
const matchIndex = match.index ?? 0; |
||||||
|
|
||||||
|
// Skip if part of a URL
|
||||||
|
const before = processedText.slice( |
||||||
|
Math.max(0, matchIndex - 12), |
||||||
|
matchIndex, |
||||||
|
); |
||||||
|
if (/https?:\/\/$|www\.$/i.test(before)) { |
||||||
|
continue; |
||||||
|
} |
||||||
|
|
||||||
|
// Process the nostr identifier directly
|
||||||
|
let identifier = fullMatch; |
||||||
|
if (!identifier.startsWith("nostr:")) { |
||||||
|
identifier = "nostr:" + identifier; |
||||||
|
} |
||||||
|
|
||||||
|
// Get user metadata and create link
|
||||||
|
let metadata; |
||||||
|
if (ndk) { |
||||||
|
metadata = await getUserMetadata(identifier, ndk); |
||||||
|
} else { |
||||||
|
// Fallback when NDK is not available - just use the identifier
|
||||||
|
metadata = { name: identifier.slice(0, 8) + "..." + identifier.slice(-4) }; |
||||||
|
} |
||||||
|
const displayText = metadata.displayName || metadata.name; |
||||||
|
const link = createProfileLink(identifier, displayText); |
||||||
|
|
||||||
|
// Replace the match in the text
|
||||||
|
processedText = processedText.slice(0, matchIndex) + link + |
||||||
|
processedText.slice(matchIndex + fullMatch.length); |
||||||
|
} |
||||||
|
|
||||||
|
return processedText; |
||||||
|
} |
||||||
|
|
||||||
|
/** |
||||||
|
* Shared service for processing nostr identifiers with embedded events |
||||||
|
* Replaces nostr: links with embedded event placeholders |
||||||
|
* Only processes event-related identifiers (nevent, naddr, note), not profile identifiers (npub, nprofile) |
||||||
|
*/ |
||||||
|
export function processNostrIdentifiersWithEmbeddedEvents( |
||||||
|
text: string, |
||||||
|
nestingLevel: number = 0, |
||||||
|
): string { |
||||||
|
const eventPattern = /nostr:(note|nevent|naddr)[a-zA-Z0-9]{20,}/g; |
||||||
|
let processedText = text; |
||||||
|
|
||||||
|
// Maximum nesting level allowed
|
||||||
|
const MAX_NESTING_LEVEL = 3; |
||||||
|
|
||||||
|
// Find all event-related nostr addresses
|
||||||
|
const matches = Array.from(processedText.matchAll(eventPattern)); |
||||||
|
|
||||||
|
// Process them in reverse order to avoid index shifting issues
|
||||||
|
for (let i = matches.length - 1; i >= 0; i--) { |
||||||
|
const match = matches[i]; |
||||||
|
const [fullMatch] = match; |
||||||
|
const matchIndex = match.index ?? 0; |
||||||
|
|
||||||
|
let replacement: string; |
||||||
|
|
||||||
|
if (nestingLevel >= MAX_NESTING_LEVEL) { |
||||||
|
// At max nesting level, just show the link
|
||||||
|
replacement = |
||||||
|
`<a href="/events?id=${fullMatch}" class="text-primary-600 dark:text-primary-500 hover:underline break-all">${fullMatch}</a>`; |
||||||
|
} else { |
||||||
|
// Create a placeholder for embedded event
|
||||||
|
const componentId = `embedded-event-${ |
||||||
|
Math.random().toString(36).substr(2, 9) |
||||||
|
}`;
|
||||||
|
replacement = |
||||||
|
`<div class="embedded-event-placeholder" data-nostr-id="${fullMatch}" data-nesting-level="${nestingLevel}" id="${componentId}"></div>`; |
||||||
|
} |
||||||
|
|
||||||
|
// Replace the match in the text
|
||||||
|
processedText = processedText.slice(0, matchIndex) + replacement + |
||||||
|
processedText.slice(matchIndex + fullMatch.length); |
||||||
|
} |
||||||
|
|
||||||
|
return processedText; |
||||||
|
} |
||||||
|
|
||||||
|
/** |
||||||
|
* Shared service for processing emoji shortcodes |
||||||
|
*/ |
||||||
|
export function processEmojiShortcodes(text: string): string { |
||||||
|
return emoji.emojify(text); |
||||||
|
} |
||||||
|
|
||||||
|
/** |
||||||
|
* Shared service for processing WebSocket URLs |
||||||
|
*/ |
||||||
|
export function processWebSocketUrls(text: string): string { |
||||||
|
const wssUrlRegex = /wss:\/\/[^\s<>"]+/g; |
||||||
|
return text.replace(wssUrlRegex, (match) => { |
||||||
|
const cleanUrl = match.slice(6).replace(/\/+$/, ""); |
||||||
|
return `<a href="https://nostrudel.ninja/#/r/wss%3A%2F%2F${cleanUrl}%2F" target="_blank" rel="noopener noreferrer" class="text-primary-600 dark:text-primary-500 hover:underline">${match}</a>`; |
||||||
|
}); |
||||||
|
} |
||||||
|
|
||||||
|
/** |
||||||
|
* Shared service for processing hashtags |
||||||
|
*/ |
||||||
|
export function processHashtags(text: string): string { |
||||||
|
const hashtagRegex = /(?<![^\s])#([a-zA-Z0-9_]+)(?!\w)/g; |
||||||
|
return text.replace( |
||||||
|
hashtagRegex, |
||||||
|
'<button class="text-primary-600 dark:text-primary-500 hover:underline cursor-pointer" onclick="window.location.href=\'/events?t=$1\'">#$1</button>', |
||||||
|
); |
||||||
|
} |
||||||
|
|
||||||
|
/** |
||||||
|
* Shared service for processing basic text formatting |
||||||
|
*/ |
||||||
|
export function processBasicTextFormatting(text: string): string { |
||||||
|
// Bold: **text** or *text*
|
||||||
|
text = text.replace( |
||||||
|
/(\*\*|[*])((?:[^*\n]|\*(?!\*))+)\1/g, |
||||||
|
"<strong>$2</strong>", |
||||||
|
); |
||||||
|
|
||||||
|
// Italic: _text_ or __text__
|
||||||
|
text = text.replace(/\b(_[^_\n]+_|\b__[^_\n]+__)\b/g, (match) => { |
||||||
|
const text = match.replace(/^_+|_+$/g, ""); |
||||||
|
return `<em>${text}</em>`; |
||||||
|
}); |
||||||
|
|
||||||
|
// Strikethrough: ~~text~~ or ~text~
|
||||||
|
text = text.replace( |
||||||
|
/~~([^~\n]+)~~|~([^~\n]+)~/g, |
||||||
|
(_match, doubleText, singleText) => { |
||||||
|
const text = doubleText || singleText; |
||||||
|
return `<del class="line-through">${text}</del>`; |
||||||
|
}, |
||||||
|
); |
||||||
|
|
||||||
|
return text; |
||||||
|
} |
||||||
|
|
||||||
|
/** |
||||||
|
* Shared service for processing blockquotes |
||||||
|
*/ |
||||||
|
export function processBlockquotes(text: string): string { |
||||||
|
const blockquoteRegex = /^([ \t]*>[ \t]?.*)(?:\n\1[ \t]*(?!>).*)*$/gm; |
||||||
|
return text.replace(blockquoteRegex, (match) => { |
||||||
|
const lines = match.split("\n").map((line) => { |
||||||
|
return line.replace(/^[ \t]*>[ \t]?/, "").trim(); |
||||||
|
}); |
||||||
|
return `<blockquote class="pl-4 border-l-4 border-gray-300 dark:border-gray-600 my-4">${ |
||||||
|
lines.join("\n") |
||||||
|
}</blockquote>`;
|
||||||
|
}); |
||||||
|
} |
||||||
|
|
||||||
|
// Helper functions
|
||||||
|
export function stripTrackingParams(url: string): string { |
||||||
|
try { |
||||||
|
const urlObj = new URL(url); |
||||||
|
// Remove common tracking parameters
|
||||||
|
const trackingParams = [ |
||||||
|
"utm_source", |
||||||
|
"utm_medium", |
||||||
|
"utm_campaign", |
||||||
|
"utm_term", |
||||||
|
"utm_content", |
||||||
|
"fbclid", |
||||||
|
"gclid", |
||||||
|
]; |
||||||
|
trackingParams.forEach((param) => urlObj.searchParams.delete(param)); |
||||||
|
return urlObj.toString(); |
||||||
|
} catch { |
||||||
|
return url; |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
function extractYouTubeVideoId(url: string): string | null { |
||||||
|
const match = url.match( |
||||||
|
/(?:youtube\.com\/(?:watch\?v=|embed\/)|youtu\.be\/|youtube-nocookie\.com\/embed\/)([a-zA-Z0-9_-]{11})/, |
||||||
|
); |
||||||
|
return match ? match[1] : null; |
||||||
|
} |
||||||
|
|
||||||
|
/** |
||||||
|
* Normalizes a string for use as a d-tag by converting to lowercase, |
||||||
|
* replacing non-alphanumeric characters with dashes, and removing |
||||||
|
* leading/trailing dashes. |
||||||
|
*/ |
||||||
|
function normalizeDTag(input: string): string { |
||||||
|
return input |
||||||
|
.toLowerCase() |
||||||
|
.replace(/[^\p{L}\p{N}]/gu, "-") |
||||||
|
.replace(/-+/g, "-") |
||||||
|
.replace(/^-|-$/g, ""); |
||||||
|
} |
||||||
|
|
||||||
|
/** |
||||||
|
* Shared service for processing wikilinks in the format [[target]] or [[target|display]] |
||||||
|
*/ |
||||||
|
export function processWikilinks(text: string): string { |
||||||
|
// [[target page]] or [[target page|display text]]
|
||||||
|
return text.replace( |
||||||
|
/\[\[([^\]|]+)(?:\|([^\]]+))?\]\]/g, |
||||||
|
(_match, target, label) => { |
||||||
|
const normalized = normalizeDTag(target.trim()); |
||||||
|
const display = (label || target).trim(); |
||||||
|
const url = `/events?d=${normalized}`; |
||||||
|
return `<a class="wikilink text-primary-600 dark:text-primary-500 hover:underline" data-dtag="${normalized}" data-url="${url}" href="${url}">${display}</a>`; |
||||||
|
}, |
||||||
|
); |
||||||
|
} |
||||||
|
|
||||||
|
/** |
||||||
|
* Shared service for processing AsciiDoc anchor tags |
||||||
|
*/ |
||||||
|
export function processAsciiDocAnchors(text: string): string { |
||||||
|
return text.replace(/<a id="([^"]+)"><\/a>/g, (_match, id) => { |
||||||
|
const normalized = normalizeDTag(id.trim()); |
||||||
|
const url = `/events?d=${normalized}`; |
||||||
|
return `<a class="wikilink text-primary-600 dark:text-primary-500 hover:underline" data-dtag="${normalized}" data-url="${url}" href="${url}">${id}</a>`; |
||||||
|
}); |
||||||
|
} |
||||||
@ -1,51 +1,398 @@ |
|||||||
import type { NostrProfile } from "./nostrUtils"; |
import type { NostrProfile } from "./search_types"; |
||||||
|
import NDK, { NDKEvent } from "@nostr-dev-kit/ndk"; |
||||||
|
import { fetchEventWithFallback } from "./nostrUtils"; |
||||||
|
import { nip19 } from "nostr-tools"; |
||||||
|
|
||||||
export type NpubMetadata = NostrProfile; |
export type NpubMetadata = NostrProfile; |
||||||
|
|
||||||
class NpubCache { |
interface CacheEntry { |
||||||
private cache: Record<string, NpubMetadata> = {}; |
profile: NpubMetadata; |
||||||
|
timestamp: number; |
||||||
|
pubkey: string; |
||||||
|
relaySource?: string; |
||||||
|
} |
||||||
|
|
||||||
|
class UnifiedProfileCache { |
||||||
|
private cache: Map<string, CacheEntry> = new Map(); |
||||||
|
private readonly storageKey = "alexandria_unified_profile_cache"; |
||||||
|
private readonly maxAge = 2 * 60 * 60 * 1000; // 2 hours in milliseconds - shorter for fresher data
|
||||||
|
|
||||||
|
constructor() { |
||||||
|
this.loadFromStorage(); |
||||||
|
} |
||||||
|
|
||||||
|
private loadFromStorage(): void { |
||||||
|
try { |
||||||
|
if (typeof window !== "undefined") { |
||||||
|
const stored = localStorage.getItem(this.storageKey); |
||||||
|
if (stored) { |
||||||
|
const data = JSON.parse(stored) as Record<string, CacheEntry>; |
||||||
|
const now = Date.now(); |
||||||
|
|
||||||
|
// Filter out expired entries
|
||||||
|
for (const [key, entry] of Object.entries(data)) { |
||||||
|
if (entry.timestamp && (now - entry.timestamp) < this.maxAge) { |
||||||
|
this.cache.set(key, entry); |
||||||
|
} |
||||||
|
} |
||||||
|
} |
||||||
|
} |
||||||
|
} catch (error) { |
||||||
|
console.warn("Failed to load unified profile cache from storage:", error); |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
get(key: string): NpubMetadata | undefined { |
private saveToStorage(): void { |
||||||
return this.cache[key]; |
try { |
||||||
|
if (typeof window !== "undefined") { |
||||||
|
const data: Record<string, CacheEntry> = {}; |
||||||
|
for (const [key, entry] of this.cache.entries()) { |
||||||
|
data[key] = entry; |
||||||
} |
} |
||||||
|
localStorage.setItem(this.storageKey, JSON.stringify(data)); |
||||||
|
} |
||||||
|
} catch (error) { |
||||||
|
console.warn("Failed to save unified profile cache to storage:", error); |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
/** |
||||||
|
* Get profile data, fetching fresh data if needed |
||||||
|
*/ |
||||||
|
async getProfile(identifier: string, ndk?: NDK, force = false): Promise<NpubMetadata> { |
||||||
|
const cleanId = identifier.replace(/^nostr:/, ""); |
||||||
|
|
||||||
|
// Check cache first (unless forced)
|
||||||
|
if (!force && this.cache.has(cleanId)) { |
||||||
|
const entry = this.cache.get(cleanId)!; |
||||||
|
const now = Date.now(); |
||||||
|
|
||||||
|
// Return cached data if not expired
|
||||||
|
if ((now - entry.timestamp) < this.maxAge) { |
||||||
|
console.log("UnifiedProfileCache: Returning cached profile:", cleanId); |
||||||
|
return entry.profile; |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
// Fetch fresh data
|
||||||
|
return this.fetchAndCacheProfile(cleanId, ndk); |
||||||
|
} |
||||||
|
|
||||||
|
/** |
||||||
|
* Fetch profile from all available relays and cache it |
||||||
|
*/ |
||||||
|
private async fetchAndCacheProfile(identifier: string, ndk?: NDK): Promise<NpubMetadata> { |
||||||
|
const fallback = { name: `${identifier.slice(0, 8)}...${identifier.slice(-4)}` }; |
||||||
|
|
||||||
set(key: string, value: NpubMetadata): void { |
try { |
||||||
this.cache[key] = value; |
if (!ndk) { |
||||||
|
console.warn("UnifiedProfileCache: No NDK instance available"); |
||||||
|
return fallback; |
||||||
} |
} |
||||||
|
|
||||||
has(key: string): boolean { |
const decoded = nip19.decode(identifier); |
||||||
return key in this.cache; |
if (!decoded) { |
||||||
|
console.warn("UnifiedProfileCache: Failed to decode identifier:", identifier); |
||||||
|
return fallback; |
||||||
} |
} |
||||||
|
|
||||||
delete(key: string): boolean { |
// Handle different identifier types
|
||||||
if (key in this.cache) { |
let pubkey: string; |
||||||
delete this.cache[key]; |
if (decoded.type === "npub") { |
||||||
|
pubkey = decoded.data; |
||||||
|
} else if (decoded.type === "nprofile") { |
||||||
|
pubkey = decoded.data.pubkey; |
||||||
|
} else { |
||||||
|
console.warn("UnifiedProfileCache: Unsupported identifier type:", decoded.type); |
||||||
|
return fallback; |
||||||
|
} |
||||||
|
|
||||||
|
console.log("UnifiedProfileCache: Fetching fresh profile for pubkey:", pubkey); |
||||||
|
|
||||||
|
// Use fetchEventWithFallback to search ALL available relays
|
||||||
|
const profileEvent = await fetchEventWithFallback(ndk, { |
||||||
|
kinds: [0], |
||||||
|
authors: [pubkey], |
||||||
|
}); |
||||||
|
|
||||||
|
if (!profileEvent || !profileEvent.content) { |
||||||
|
console.warn("UnifiedProfileCache: No profile event found for:", pubkey); |
||||||
|
return fallback; |
||||||
|
} |
||||||
|
|
||||||
|
const profile = JSON.parse(profileEvent.content); |
||||||
|
const metadata: NostrProfile = { |
||||||
|
name: profile?.name || fallback.name, |
||||||
|
displayName: profile?.displayName || profile?.display_name, |
||||||
|
nip05: profile?.nip05, |
||||||
|
picture: profile?.picture || profile?.image, |
||||||
|
about: profile?.about, |
||||||
|
banner: profile?.banner, |
||||||
|
website: profile?.website, |
||||||
|
lud16: profile?.lud16, |
||||||
|
}; |
||||||
|
|
||||||
|
// Cache the fresh data
|
||||||
|
const entry: CacheEntry = { |
||||||
|
profile: metadata, |
||||||
|
timestamp: Date.now(), |
||||||
|
pubkey: pubkey, |
||||||
|
relaySource: profileEvent.relay?.url, |
||||||
|
}; |
||||||
|
|
||||||
|
this.cache.set(identifier, entry); |
||||||
|
this.cache.set(pubkey, entry); // Also cache by pubkey for convenience
|
||||||
|
this.saveToStorage(); |
||||||
|
|
||||||
|
console.log("UnifiedProfileCache: Cached fresh profile:", metadata); |
||||||
|
return metadata; |
||||||
|
|
||||||
|
} catch (e) { |
||||||
|
console.error("UnifiedProfileCache: Error fetching profile:", e); |
||||||
|
return fallback; |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
/** |
||||||
|
* Get cached profile without fetching (synchronous) |
||||||
|
*/ |
||||||
|
getCached(identifier: string): NpubMetadata | undefined { |
||||||
|
const cleanId = identifier.replace(/^nostr:/, ""); |
||||||
|
const entry = this.cache.get(cleanId); |
||||||
|
|
||||||
|
if (entry) { |
||||||
|
const now = Date.now(); |
||||||
|
if ((now - entry.timestamp) < this.maxAge) { |
||||||
|
return entry.profile; |
||||||
|
} else { |
||||||
|
// Remove expired entry
|
||||||
|
this.cache.delete(cleanId); |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
return undefined; |
||||||
|
} |
||||||
|
|
||||||
|
/** |
||||||
|
* Set profile data in cache |
||||||
|
*/ |
||||||
|
set(identifier: string, profile: NpubMetadata, pubkey?: string, relaySource?: string): void { |
||||||
|
const cleanId = identifier.replace(/^nostr:/, ""); |
||||||
|
const entry: CacheEntry = { |
||||||
|
profile, |
||||||
|
timestamp: Date.now(), |
||||||
|
pubkey: pubkey || cleanId, |
||||||
|
relaySource, |
||||||
|
}; |
||||||
|
|
||||||
|
this.cache.set(cleanId, entry); |
||||||
|
if (pubkey && pubkey !== cleanId) { |
||||||
|
this.cache.set(pubkey, entry); |
||||||
|
} |
||||||
|
this.saveToStorage(); |
||||||
|
} |
||||||
|
|
||||||
|
/** |
||||||
|
* Check if profile is cached and valid |
||||||
|
*/ |
||||||
|
has(identifier: string): boolean { |
||||||
|
const cleanId = identifier.replace(/^nostr:/, ""); |
||||||
|
const entry = this.cache.get(cleanId); |
||||||
|
|
||||||
|
if (entry) { |
||||||
|
const now = Date.now(); |
||||||
|
if ((now - entry.timestamp) < this.maxAge) { |
||||||
return true; |
return true; |
||||||
|
} else { |
||||||
|
// Remove expired entry
|
||||||
|
this.cache.delete(cleanId); |
||||||
} |
} |
||||||
|
} |
||||||
|
|
||||||
return false; |
return false; |
||||||
} |
} |
||||||
|
|
||||||
deleteMany(keys: string[]): number { |
/** |
||||||
let deleted = 0; |
* Remove profile from cache |
||||||
for (const key of keys) { |
*/ |
||||||
if (this.delete(key)) { |
delete(identifier: string): boolean { |
||||||
deleted++; |
const cleanId = identifier.replace(/^nostr:/, ""); |
||||||
|
const entry = this.cache.get(cleanId); |
||||||
|
|
||||||
|
if (entry) { |
||||||
|
this.cache.delete(cleanId); |
||||||
|
if (entry.pubkey && entry.pubkey !== cleanId) { |
||||||
|
this.cache.delete(entry.pubkey); |
||||||
} |
} |
||||||
|
this.saveToStorage(); |
||||||
|
return true; |
||||||
} |
} |
||||||
return deleted; |
|
||||||
|
return false; |
||||||
} |
} |
||||||
|
|
||||||
|
/** |
||||||
|
* Clear all cached profiles |
||||||
|
*/ |
||||||
clear(): void { |
clear(): void { |
||||||
this.cache = {}; |
this.cache.clear(); |
||||||
|
this.saveToStorage(); |
||||||
} |
} |
||||||
|
|
||||||
|
/** |
||||||
|
* Get cache size |
||||||
|
*/ |
||||||
size(): number { |
size(): number { |
||||||
return Object.keys(this.cache).length; |
return this.cache.size; |
||||||
} |
} |
||||||
|
|
||||||
|
/** |
||||||
|
* Get all cached profiles |
||||||
|
*/ |
||||||
getAll(): Record<string, NpubMetadata> { |
getAll(): Record<string, NpubMetadata> { |
||||||
return { ...this.cache }; |
const result: Record<string, NpubMetadata> = {}; |
||||||
|
for (const [key, entry] of this.cache.entries()) { |
||||||
|
result[key] = entry.profile; |
||||||
|
} |
||||||
|
return result; |
||||||
|
} |
||||||
|
|
||||||
|
/** |
||||||
|
* Clean up expired entries |
||||||
|
*/ |
||||||
|
cleanup(): void { |
||||||
|
const now = Date.now(); |
||||||
|
const expiredKeys: string[] = []; |
||||||
|
|
||||||
|
for (const [key, entry] of this.cache.entries()) { |
||||||
|
if ((now - entry.timestamp) >= this.maxAge) { |
||||||
|
expiredKeys.push(key); |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
expiredKeys.forEach(key => this.cache.delete(key)); |
||||||
|
|
||||||
|
if (expiredKeys.length > 0) { |
||||||
|
this.saveToStorage(); |
||||||
|
console.log(`UnifiedProfileCache: Cleaned up ${expiredKeys.length} expired entries`); |
||||||
|
} |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
// Export the unified cache instance
|
||||||
|
export const unifiedProfileCache = new UnifiedProfileCache(); |
||||||
|
|
||||||
|
// Clean up expired entries every 30 minutes
|
||||||
|
if (typeof window !== "undefined") { |
||||||
|
setInterval(() => { |
||||||
|
unifiedProfileCache.cleanup(); |
||||||
|
}, 30 * 60 * 1000); |
||||||
|
} |
||||||
|
|
||||||
|
// Legacy compatibility - keep the old npubCache for backward compatibility
|
||||||
|
// but make it use the unified cache internally
|
||||||
|
export const npubCache = { |
||||||
|
get: (key: string) => unifiedProfileCache.getCached(key), |
||||||
|
set: (key: string, value: NpubMetadata) => unifiedProfileCache.set(key, value), |
||||||
|
has: (key: string) => unifiedProfileCache.has(key), |
||||||
|
delete: (key: string) => unifiedProfileCache.delete(key), |
||||||
|
clear: () => unifiedProfileCache.clear(), |
||||||
|
size: () => unifiedProfileCache.size(), |
||||||
|
getAll: () => unifiedProfileCache.getAll(), |
||||||
|
}; |
||||||
|
|
||||||
|
// Legacy compatibility for old profileCache functions
|
||||||
|
export async function getDisplayName(pubkey: string, ndk: NDK): Promise<string> { |
||||||
|
const profile = await unifiedProfileCache.getProfile(pubkey, ndk); |
||||||
|
return profile.displayName || profile.name || `${pubkey.slice(0, 8)}...${pubkey.slice(-4)}`; |
||||||
|
} |
||||||
|
|
||||||
|
export function getDisplayNameSync(pubkey: string): string { |
||||||
|
const profile = unifiedProfileCache.getCached(pubkey); |
||||||
|
return profile?.displayName || profile?.name || `${pubkey.slice(0, 8)}...${pubkey.slice(-4)}`; |
||||||
|
} |
||||||
|
|
||||||
|
export async function batchFetchProfiles( |
||||||
|
pubkeys: string[], |
||||||
|
ndk: NDK, |
||||||
|
onProgress?: (fetched: number, total: number) => void, |
||||||
|
): Promise<NDKEvent[]> { |
||||||
|
const allProfileEvents: NDKEvent[] = []; |
||||||
|
|
||||||
|
if (onProgress) onProgress(0, pubkeys.length); |
||||||
|
|
||||||
|
// Fetch profiles in parallel using the unified cache
|
||||||
|
const fetchPromises = pubkeys.map(async (pubkey, index) => { |
||||||
|
try { |
||||||
|
const profile = await unifiedProfileCache.getProfile(pubkey, ndk); |
||||||
|
if (onProgress) onProgress(index + 1, pubkeys.length); |
||||||
|
|
||||||
|
// Create a mock NDKEvent for compatibility
|
||||||
|
const event = new NDKEvent(ndk); |
||||||
|
event.content = JSON.stringify(profile); |
||||||
|
event.pubkey = pubkey; |
||||||
|
return event; |
||||||
|
} catch (e) { |
||||||
|
console.error(`Failed to fetch profile for ${pubkey}:`, e); |
||||||
|
return null; |
||||||
} |
} |
||||||
|
}); |
||||||
|
|
||||||
|
const results = await Promise.allSettled(fetchPromises); |
||||||
|
results.forEach(result => { |
||||||
|
if (result.status === 'fulfilled' && result.value) { |
||||||
|
allProfileEvents.push(result.value); |
||||||
|
} |
||||||
|
}); |
||||||
|
|
||||||
|
return allProfileEvents; |
||||||
|
} |
||||||
|
|
||||||
|
export function extractPubkeysFromEvents(events: NDKEvent[]): Set<string> { |
||||||
|
const pubkeys = new Set<string>(); |
||||||
|
|
||||||
|
events.forEach((event) => { |
||||||
|
// Add author pubkey
|
||||||
|
if (event.pubkey) { |
||||||
|
pubkeys.add(event.pubkey); |
||||||
} |
} |
||||||
|
|
||||||
export const npubCache = new NpubCache(); |
// Add pubkeys from p tags
|
||||||
|
const pTags = event.getMatchingTags("p"); |
||||||
|
pTags.forEach((tag) => { |
||||||
|
if (tag[1]) { |
||||||
|
pubkeys.add(tag[1]); |
||||||
|
} |
||||||
|
}); |
||||||
|
|
||||||
|
// Extract pubkeys from content (nostr:npub1... format)
|
||||||
|
const npubPattern = /nostr:npub1[a-z0-9]{58}/g; |
||||||
|
const matches = event.content?.match(npubPattern) || []; |
||||||
|
matches.forEach((match) => { |
||||||
|
try { |
||||||
|
const npub = match.replace("nostr:", ""); |
||||||
|
const decoded = nip19.decode(npub); |
||||||
|
if (decoded.type === "npub") { |
||||||
|
pubkeys.add(decoded.data as string); |
||||||
|
} |
||||||
|
} catch (e) { |
||||||
|
// Invalid npub, ignore
|
||||||
|
} |
||||||
|
}); |
||||||
|
}); |
||||||
|
|
||||||
|
return pubkeys; |
||||||
|
} |
||||||
|
|
||||||
|
export function clearProfileCache(): void { |
||||||
|
unifiedProfileCache.clear(); |
||||||
|
} |
||||||
|
|
||||||
|
export function replacePubkeysWithDisplayNames(text: string): string { |
||||||
|
// Match hex pubkeys (64 characters)
|
||||||
|
const pubkeyRegex = /\b[0-9a-fA-F]{64}\b/g; |
||||||
|
|
||||||
|
return text.replace(pubkeyRegex, (match) => { |
||||||
|
return getDisplayNameSync(match); |
||||||
|
}); |
||||||
|
} |
||||||
|
|||||||
@ -1,252 +0,0 @@ |
|||||||
import type { NDKEvent } from "@nostr-dev-kit/ndk"; |
|
||||||
import { ndkInstance } from "$lib/ndk"; |
|
||||||
import { get } from "svelte/store"; |
|
||||||
import { nip19 } from "nostr-tools"; |
|
||||||
|
|
||||||
interface ProfileData { |
|
||||||
display_name?: string; |
|
||||||
name?: string; |
|
||||||
picture?: string; |
|
||||||
about?: string; |
|
||||||
} |
|
||||||
|
|
||||||
// Cache for user profiles
|
|
||||||
const profileCache = new Map<string, ProfileData>(); |
|
||||||
|
|
||||||
/** |
|
||||||
* Fetches profile data for a pubkey |
|
||||||
* @param pubkey - The public key to fetch profile for |
|
||||||
* @returns Profile data or null if not found |
|
||||||
*/ |
|
||||||
async function fetchProfile(pubkey: string): Promise<ProfileData | null> { |
|
||||||
try { |
|
||||||
const ndk = get(ndkInstance); |
|
||||||
const profileEvents = await ndk.fetchEvents({ |
|
||||||
kinds: [0], |
|
||||||
authors: [pubkey], |
|
||||||
limit: 1 |
|
||||||
}); |
|
||||||
|
|
||||||
if (profileEvents.size === 0) { |
|
||||||
return null; |
|
||||||
} |
|
||||||
|
|
||||||
// Get the most recent profile event
|
|
||||||
const profileEvent = Array.from(profileEvents)[0]; |
|
||||||
|
|
||||||
try { |
|
||||||
const content = JSON.parse(profileEvent.content); |
|
||||||
return content as ProfileData; |
|
||||||
} catch (e) { |
|
||||||
console.error("Failed to parse profile content:", e); |
|
||||||
return null; |
|
||||||
} |
|
||||||
} catch (e) { |
|
||||||
console.error("Failed to fetch profile:", e); |
|
||||||
return null; |
|
||||||
} |
|
||||||
} |
|
||||||
|
|
||||||
/** |
|
||||||
* Gets the display name for a pubkey, using cache |
|
||||||
* @param pubkey - The public key to get display name for |
|
||||||
* @returns Display name, name, or shortened pubkey |
|
||||||
*/ |
|
||||||
export async function getDisplayName(pubkey: string): Promise<string> { |
|
||||||
// Check cache first
|
|
||||||
if (profileCache.has(pubkey)) { |
|
||||||
const profile = profileCache.get(pubkey)!; |
|
||||||
return profile.display_name || profile.name || shortenPubkey(pubkey); |
|
||||||
} |
|
||||||
|
|
||||||
// Fetch profile
|
|
||||||
const profile = await fetchProfile(pubkey); |
|
||||||
if (profile) { |
|
||||||
profileCache.set(pubkey, profile); |
|
||||||
return profile.display_name || profile.name || shortenPubkey(pubkey); |
|
||||||
} |
|
||||||
|
|
||||||
// Fallback to shortened pubkey
|
|
||||||
return shortenPubkey(pubkey); |
|
||||||
} |
|
||||||
|
|
||||||
/** |
|
||||||
* Batch fetches profiles for multiple pubkeys |
|
||||||
* @param pubkeys - Array of public keys to fetch profiles for |
|
||||||
* @param onProgress - Optional callback for progress updates |
|
||||||
* @returns Array of profile events |
|
||||||
*/ |
|
||||||
export async function batchFetchProfiles( |
|
||||||
pubkeys: string[],
|
|
||||||
onProgress?: (fetched: number, total: number) => void |
|
||||||
): Promise<NDKEvent[]> { |
|
||||||
const allProfileEvents: NDKEvent[] = []; |
|
||||||
|
|
||||||
// Filter out already cached pubkeys
|
|
||||||
const uncachedPubkeys = pubkeys.filter(pk => !profileCache.has(pk)); |
|
||||||
|
|
||||||
if (uncachedPubkeys.length === 0) { |
|
||||||
if (onProgress) onProgress(pubkeys.length, pubkeys.length); |
|
||||||
return allProfileEvents; |
|
||||||
} |
|
||||||
|
|
||||||
try { |
|
||||||
const ndk = get(ndkInstance); |
|
||||||
|
|
||||||
// Report initial progress
|
|
||||||
const cachedCount = pubkeys.length - uncachedPubkeys.length; |
|
||||||
if (onProgress) onProgress(cachedCount, pubkeys.length); |
|
||||||
|
|
||||||
// Batch fetch in chunks to avoid overwhelming relays
|
|
||||||
const CHUNK_SIZE = 50; |
|
||||||
let fetchedCount = cachedCount; |
|
||||||
|
|
||||||
for (let i = 0; i < uncachedPubkeys.length; i += CHUNK_SIZE) { |
|
||||||
const chunk = uncachedPubkeys.slice(i, Math.min(i + CHUNK_SIZE, uncachedPubkeys.length)); |
|
||||||
|
|
||||||
const profileEvents = await ndk.fetchEvents({ |
|
||||||
kinds: [0], |
|
||||||
authors: chunk |
|
||||||
}); |
|
||||||
|
|
||||||
// Process each profile event
|
|
||||||
profileEvents.forEach((event: NDKEvent) => { |
|
||||||
try { |
|
||||||
const content = JSON.parse(event.content); |
|
||||||
profileCache.set(event.pubkey, content as ProfileData); |
|
||||||
allProfileEvents.push(event); |
|
||||||
fetchedCount++; |
|
||||||
} catch (e) { |
|
||||||
console.error("Failed to parse profile content:", e); |
|
||||||
} |
|
||||||
}); |
|
||||||
|
|
||||||
// Update progress
|
|
||||||
if (onProgress) { |
|
||||||
onProgress(fetchedCount, pubkeys.length); |
|
||||||
} |
|
||||||
} |
|
||||||
|
|
||||||
// Final progress update
|
|
||||||
if (onProgress) onProgress(pubkeys.length, pubkeys.length); |
|
||||||
} catch (e) { |
|
||||||
console.error("Failed to batch fetch profiles:", e); |
|
||||||
} |
|
||||||
|
|
||||||
return allProfileEvents; |
|
||||||
} |
|
||||||
|
|
||||||
/** |
|
||||||
* Gets display name synchronously from cache |
|
||||||
* @param pubkey - The public key to get display name for |
|
||||||
* @returns Display name, name, or shortened pubkey |
|
||||||
*/ |
|
||||||
export function getDisplayNameSync(pubkey: string): string { |
|
||||||
if (profileCache.has(pubkey)) { |
|
||||||
const profile = profileCache.get(pubkey)!; |
|
||||||
return profile.display_name || profile.name || shortenPubkey(pubkey); |
|
||||||
} |
|
||||||
return shortenPubkey(pubkey); |
|
||||||
} |
|
||||||
|
|
||||||
/** |
|
||||||
* Shortens a pubkey for display |
|
||||||
* @param pubkey - The public key to shorten |
|
||||||
* @returns Shortened pubkey (first 8 chars...last 4 chars) |
|
||||||
*/ |
|
||||||
function shortenPubkey(pubkey: string): string { |
|
||||||
if (pubkey.length <= 12) return pubkey; |
|
||||||
return `${pubkey.slice(0, 8)}...${pubkey.slice(-4)}`; |
|
||||||
} |
|
||||||
|
|
||||||
/** |
|
||||||
* Clears the profile cache |
|
||||||
*/ |
|
||||||
export function clearProfileCache(): void { |
|
||||||
profileCache.clear(); |
|
||||||
} |
|
||||||
|
|
||||||
/** |
|
||||||
* Extracts all pubkeys from events (authors and p tags) |
|
||||||
* @param events - Array of events to extract pubkeys from |
|
||||||
* @returns Set of unique pubkeys |
|
||||||
*/ |
|
||||||
export function extractPubkeysFromEvents(events: NDKEvent[]): Set<string> { |
|
||||||
const pubkeys = new Set<string>(); |
|
||||||
|
|
||||||
events.forEach(event => { |
|
||||||
// Add author pubkey
|
|
||||||
if (event.pubkey) { |
|
||||||
pubkeys.add(event.pubkey); |
|
||||||
} |
|
||||||
|
|
||||||
// Add pubkeys from p tags
|
|
||||||
const pTags = event.getMatchingTags("p"); |
|
||||||
pTags.forEach(tag => { |
|
||||||
if (tag[1]) { |
|
||||||
pubkeys.add(tag[1]); |
|
||||||
} |
|
||||||
}); |
|
||||||
|
|
||||||
// Extract pubkeys from content (nostr:npub1... format)
|
|
||||||
const npubPattern = /nostr:npub1[a-z0-9]{58}/g; |
|
||||||
const matches = event.content?.match(npubPattern) || []; |
|
||||||
matches.forEach(match => { |
|
||||||
try { |
|
||||||
const npub = match.replace('nostr:', ''); |
|
||||||
const decoded = nip19.decode(npub); |
|
||||||
if (decoded.type === 'npub') { |
|
||||||
pubkeys.add(decoded.data as string); |
|
||||||
} |
|
||||||
} catch (e) { |
|
||||||
// Invalid npub, ignore
|
|
||||||
} |
|
||||||
}); |
|
||||||
}); |
|
||||||
|
|
||||||
return pubkeys; |
|
||||||
} |
|
||||||
|
|
||||||
/** |
|
||||||
* Replaces pubkeys in content with display names |
|
||||||
* @param content - The content to process |
|
||||||
* @returns Content with pubkeys replaced by display names |
|
||||||
*/ |
|
||||||
export function replaceContentPubkeys(content: string): string { |
|
||||||
if (!content) return content; |
|
||||||
|
|
||||||
// Replace nostr:npub1... references
|
|
||||||
const npubPattern = /nostr:npub[a-z0-9]{58}/g; |
|
||||||
let result = content; |
|
||||||
|
|
||||||
const matches = content.match(npubPattern) || []; |
|
||||||
matches.forEach(match => { |
|
||||||
try { |
|
||||||
const npub = match.replace('nostr:', ''); |
|
||||||
const decoded = nip19.decode(npub); |
|
||||||
if (decoded.type === 'npub') { |
|
||||||
const pubkey = decoded.data as string; |
|
||||||
const displayName = getDisplayNameSync(pubkey); |
|
||||||
result = result.replace(match, `@${displayName}`); |
|
||||||
} |
|
||||||
} catch (e) { |
|
||||||
// Invalid npub, leave as is
|
|
||||||
} |
|
||||||
}); |
|
||||||
|
|
||||||
return result; |
|
||||||
} |
|
||||||
|
|
||||||
/** |
|
||||||
* Replaces pubkey references in text with display names |
|
||||||
* @param text - Text that may contain pubkey references |
|
||||||
* @returns Text with pubkeys replaced by display names |
|
||||||
*/ |
|
||||||
export function replacePubkeysWithDisplayNames(text: string): string { |
|
||||||
// Match hex pubkeys (64 characters)
|
|
||||||
const pubkeyRegex = /\b[0-9a-fA-F]{64}\b/g; |
|
||||||
|
|
||||||
return text.replace(pubkeyRegex, (match) => { |
|
||||||
return getDisplayNameSync(match); |
|
||||||
}); |
|
||||||
} |
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Loading…
Reference in new issue