148 changed files with 16893 additions and 7384 deletions
File diff suppressed because it is too large
Load Diff
@ -0,0 +1,311 @@
@@ -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 @@
@@ -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 @@
@@ -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 @@
@@ -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 @@
@@ -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 @@
@@ -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 @@
@@ -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 @@
@@ -0,0 +1 @@
|
||||
export type SearchType = "id" | "d" | "t" | "n" | "q"; |
||||
@ -0,0 +1,12 @@
@@ -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 @@
@@ -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 @@
@@ -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 @@
@@ -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 @@
@@ -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,277 @@
@@ -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 @@
@@ -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,283 +0,0 @@
@@ -1,283 +0,0 @@
|
||||
import { parseBasicmarkup } from "$lib/utils/markup/basicMarkupParser"; |
||||
import type { NDKEvent } from "$lib/utils/nostrUtils"; |
||||
import { getUserMetadata, NDKRelaySetFromNDK, toNpub } from "$lib/utils/nostrUtils"; |
||||
import { get } from "svelte/store"; |
||||
import { ndkInstance } from "$lib/ndk"; |
||||
import { searchRelays } from "$lib/consts"; |
||||
import { userStore, type UserState } from "$lib/stores/userStore"; |
||||
import { buildCompleteRelaySet } from "$lib/utils/relay_management"; |
||||
import { neventEncode } from "$lib/utils"; |
||||
import { nip19 } from "nostr-tools"; |
||||
import type NDK from "@nostr-dev-kit/ndk"; |
||||
|
||||
// AI-NOTE: Notification-specific utility functions that don't exist elsewhere
|
||||
|
||||
/** |
||||
* Truncates content to a specified length |
||||
*/ |
||||
export function truncateContent(content: string, maxLength: number = 300): string { |
||||
if (content.length <= maxLength) return content; |
||||
return content.slice(0, maxLength) + "..."; |
||||
} |
||||
|
||||
/** |
||||
* Truncates rendered HTML content while preserving quote boxes |
||||
*/ |
||||
export function truncateRenderedContent(renderedHtml: string, maxLength: number = 300): string { |
||||
if (renderedHtml.length <= maxLength) return renderedHtml; |
||||
|
||||
const hasQuoteBoxes = renderedHtml.includes('jump-to-message'); |
||||
|
||||
if (hasQuoteBoxes) { |
||||
const quoteBoxPattern = /<div class="block w-fit my-2 px-3 py-2 bg-gray-200[^>]*onclick="window\.dispatchEvent\(new CustomEvent\('jump-to-message'[^>]*>[^<]*<\/div>/g; |
||||
const quoteBoxes = renderedHtml.match(quoteBoxPattern) || []; |
||||
|
||||
let textOnly = renderedHtml.replace(quoteBoxPattern, '|||QUOTEBOX|||'); |
||||
|
||||
if (textOnly.length > maxLength) { |
||||
const availableLength = maxLength - (quoteBoxes.join('').length); |
||||
if (availableLength > 50) { |
||||
textOnly = textOnly.slice(0, availableLength) + "..."; |
||||
} else { |
||||
textOnly = textOnly.slice(0, 50) + "..."; |
||||
} |
||||
} |
||||
|
||||
let result = textOnly; |
||||
quoteBoxes.forEach(box => { |
||||
result = result.replace('|||QUOTEBOX|||', box); |
||||
}); |
||||
|
||||
return result; |
||||
} else { |
||||
if (renderedHtml.includes('<')) { |
||||
const truncated = renderedHtml.slice(0, maxLength); |
||||
const lastTagStart = truncated.lastIndexOf('<'); |
||||
const lastTagEnd = truncated.lastIndexOf('>'); |
||||
|
||||
if (lastTagStart > lastTagEnd) { |
||||
return renderedHtml.slice(0, lastTagStart) + "..."; |
||||
} |
||||
return truncated + "..."; |
||||
} else { |
||||
return renderedHtml.slice(0, maxLength) + "..."; |
||||
} |
||||
} |
||||
} |
||||
|
||||
/** |
||||
* Parses content using basic markup parser |
||||
*/ |
||||
export async function parseContent(content: string): Promise<string> { |
||||
if (!content) return ""; |
||||
return await parseBasicmarkup(content); |
||||
} |
||||
|
||||
/** |
||||
* Parses repost content and renders it as an embedded event |
||||
*/ |
||||
export async function parseRepostContent(content: string): Promise<string> { |
||||
if (!content) return ""; |
||||
|
||||
try { |
||||
// Try to parse the content as JSON (repost events contain the original event as JSON)
|
||||
const originalEvent = JSON.parse(content); |
||||
|
||||
// Extract the original event's content
|
||||
const originalContent = originalEvent.content || ""; |
||||
const originalAuthor = originalEvent.pubkey || ""; |
||||
const originalCreatedAt = originalEvent.created_at || 0; |
||||
|
||||
// Parse the original content with basic markup
|
||||
const parsedOriginalContent = await parseBasicmarkup(originalContent); |
||||
|
||||
// Create an embedded event display
|
||||
const formattedDate = originalCreatedAt ? new Date(originalCreatedAt * 1000).toLocaleDateString() : "Unknown date"; |
||||
const shortAuthor = originalAuthor ? `${originalAuthor.slice(0, 8)}...${originalAuthor.slice(-4)}` : "Unknown"; |
||||
|
||||
return ` |
||||
<div class="embedded-repost bg-gray-50 dark:bg-gray-800 border border-gray-200 dark:border-gray-700 rounded-lg p-3 my-2"> |
||||
<div class="flex items-center gap-2 mb-2 text-xs text-gray-600 dark:text-gray-400"> |
||||
<span class="font-medium">Reposted by:</span> |
||||
<span class="font-mono">${shortAuthor}</span> |
||||
<span>•</span> |
||||
<span>${formattedDate}</span> |
||||
</div> |
||||
<div class="text-sm text-gray-800 dark:text-gray-200 leading-relaxed"> |
||||
${parsedOriginalContent} |
||||
</div> |
||||
</div> |
||||
`;
|
||||
} catch (error) { |
||||
// If JSON parsing fails, fall back to basic markup
|
||||
console.warn("Failed to parse repost content as JSON, falling back to basic markup:", error); |
||||
return await parseBasicmarkup(content); |
||||
} |
||||
} |
||||
|
||||
/** |
||||
* Renders quoted content for a message |
||||
*/ |
||||
export async function renderQuotedContent(message: NDKEvent, publicMessages: NDKEvent[]): Promise<string> { |
||||
const qTags = message.getMatchingTags("q"); |
||||
if (qTags.length === 0) return ""; |
||||
|
||||
const qTag = qTags[0]; |
||||
const eventId = qTag[1]; |
||||
|
||||
if (eventId) { |
||||
// Validate eventId format (should be 64 character hex string)
|
||||
const isValidEventId = /^[a-fA-F0-9]{64}$/.test(eventId); |
||||
|
||||
// First try to find in local messages
|
||||
let quotedMessage = publicMessages.find(msg => msg.id === eventId); |
||||
|
||||
// If not found locally, fetch from relays
|
||||
if (!quotedMessage) { |
||||
try { |
||||
const ndk: NDK | undefined = get(ndkInstance); |
||||
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(`[renderQuotedContent] Failed to fetch quoted event ${eventId}:`, error); |
||||
} |
||||
} |
||||
|
||||
if (quotedMessage) { |
||||
const quotedContent = quotedMessage.content ? quotedMessage.content.slice(0, 200) : "No content"; |
||||
const parsedContent = await parseBasicmarkup(quotedContent); |
||||
return `<div class="block w-fit my-2 px-3 py-2 bg-gray-200 dark:bg-gray-700 border-l-2 border-gray-400 dark:border-gray-500 rounded cursor-pointer hover:bg-gray-300 dark:hover:bg-gray-600 transition-colors text-sm text-gray-600 dark:text-gray-300" onclick="window.dispatchEvent(new CustomEvent('jump-to-message', { detail: '${eventId}' }))">${parsedContent}</div>`; |
||||
} else { |
||||
// Fallback to nevent link - only if eventId is valid
|
||||
if (isValidEventId) { |
||||
try { |
||||
const nevent = nip19.neventEncode({ id: eventId }); |
||||
return `<div class="block w-fit my-2 px-3 py-2 bg-gray-200 dark:bg-gray-700 border-l-2 border-gray-400 dark:border-gray-500 rounded cursor-pointer hover:bg-gray-300 dark:hover:bg-gray-600 transition-colors text-sm text-gray-600 dark:text-gray-300" onclick="window.location.href='/events?id=${nevent}'">Quoted message not found. Click to view event ${eventId.slice(0, 8)}...</div>`; |
||||
} catch (error) { |
||||
console.warn(`[renderQuotedContent] Failed to encode nevent for ${eventId}:`, error); |
||||
// Fall back to just showing the event ID without a link
|
||||
return `<div class="block w-fit my-2 px-3 py-2 bg-gray-200 dark:bg-gray-700 border-l-2 border-gray-400 dark:border-gray-500 rounded text-sm text-gray-600 dark:text-gray-300">Quoted message not found. Event ID: ${eventId.slice(0, 8)}...</div>`; |
||||
} |
||||
} else { |
||||
// Invalid event ID format
|
||||
return `<div class="block w-fit my-2 px-3 py-2 bg-gray-200 dark:bg-gray-700 border-l-2 border-gray-400 dark:border-gray-500 rounded text-sm text-gray-600 dark:text-gray-300">Invalid quoted message reference</div>`; |
||||
} |
||||
} |
||||
} |
||||
|
||||
return "";
|
||||
} |
||||
|
||||
/** |
||||
* Gets notification type based on event kind |
||||
*/ |
||||
export function getNotificationType(event: NDKEvent): string { |
||||
switch (event.kind) { |
||||
case 1: return "Reply"; |
||||
case 1111: return "Custom Reply"; |
||||
case 9802: return "Highlight"; |
||||
case 6: return "Repost"; |
||||
case 16: return "Generic Repost"; |
||||
case 24: return "Public Message"; |
||||
default: return `Kind ${event.kind}`; |
||||
} |
||||
} |
||||
|
||||
/** |
||||
* Fetches author profiles for a list of events |
||||
*/ |
||||
export async function fetchAuthorProfiles(events: NDKEvent[]): Promise<Map<string, { name?: string; displayName?: string; picture?: string }>> { |
||||
const authorProfiles = new Map<string, { name?: string; displayName?: string; picture?: string }>(); |
||||
const uniquePubkeys = new Set<string>(); |
||||
|
||||
events.forEach(event => { |
||||
if (event.pubkey) uniquePubkeys.add(event.pubkey); |
||||
}); |
||||
|
||||
const profilePromises = Array.from(uniquePubkeys).map(async (pubkey) => { |
||||
try { |
||||
const npub = toNpub(pubkey); |
||||
if (!npub) return; |
||||
|
||||
// Try cache first
|
||||
let profile = await getUserMetadata(npub, false); |
||||
if (profile && (profile.name || profile.displayName || profile.picture)) { |
||||
authorProfiles.set(pubkey, profile); |
||||
return; |
||||
} |
||||
|
||||
// Try search relays
|
||||
for (const relay of searchRelays) { |
||||
try { |
||||
const ndk: NDK | undefined = get(ndkInstance); |
||||
if (!ndk) break; |
||||
|
||||
const relaySet = NDKRelaySetFromNDK.fromRelayUrls([relay], ndk); |
||||
const profileEvent = await ndk.fetchEvent( |
||||
{ kinds: [0], authors: [pubkey] }, |
||||
undefined, |
||||
relaySet |
||||
); |
||||
|
||||
if (profileEvent) { |
||||
const profileData = JSON.parse(profileEvent.content); |
||||
authorProfiles.set(pubkey, { |
||||
name: profileData.name, |
||||
displayName: profileData.display_name || profileData.displayName, |
||||
picture: profileData.picture || profileData.image |
||||
}); |
||||
return; |
||||
} |
||||
} catch (error) { |
||||
console.warn(`[fetchAuthorProfiles] Failed to fetch profile from ${relay}:`, error); |
||||
} |
||||
} |
||||
|
||||
// Try all available relays as fallback
|
||||
try { |
||||
const ndk: NDK | undefined = get(ndkInstance); |
||||
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; |
||||
} |
||||
@ -1,252 +0,0 @@
@@ -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