120 changed files with 13634 additions and 4107 deletions
@ -1,17 +1,17 @@ |
|||||||
version: 39 |
version: 39 |
||||||
jobs: |
jobs: |
||||||
- name: Github Push |
- name: Github Push |
||||||
steps: |
steps: |
||||||
- !PushRepository |
- !PushRepository |
||||||
name: gc-alexandria |
name: gc-alexandria |
||||||
remoteUrl: https://github.com/ShadowySupercode/gc-alexandria |
remoteUrl: https://github.com/ShadowySupercode/gc-alexandria |
||||||
passwordSecret: github_access_token |
passwordSecret: github_access_token |
||||||
force: false |
force: false |
||||||
condition: ALL_PREVIOUS_STEPS_WERE_SUCCESSFUL |
condition: ALL_PREVIOUS_STEPS_WERE_SUCCESSFUL |
||||||
triggers: |
triggers: |
||||||
- !BranchUpdateTrigger {} |
- !BranchUpdateTrigger {} |
||||||
- !TagCreateTrigger {} |
- !TagCreateTrigger {} |
||||||
retryCondition: never |
retryCondition: never |
||||||
maxRetries: 3 |
maxRetries: 3 |
||||||
retryDelay: 30 |
retryDelay: 30 |
||||||
timeout: 14400 |
timeout: 14400 |
||||||
|
|||||||
@ -1,3 +1,3 @@ |
|||||||
{ |
{ |
||||||
"plugins":["prettier-plugin-svelte"] |
"plugins": ["prettier-plugin-svelte"] |
||||||
} |
} |
||||||
|
|||||||
@ -1,13 +0,0 @@ |
|||||||
FROM node:23-alpine AS build |
|
||||||
|
|
||||||
WORKDIR /app |
|
||||||
|
|
||||||
COPY . ./ |
|
||||||
COPY package.json ./ |
|
||||||
COPY package-lock.json ./ |
|
||||||
RUN npm install |
|
||||||
RUN npm run build |
|
||||||
|
|
||||||
EXPOSE 80 |
|
||||||
FROM nginx:1.27.4 |
|
||||||
COPY --from=build /app/build /usr/share/nginx/html |
|
||||||
@ -1,8 +1,8 @@ |
|||||||
identifier: Alexandria |
identifier: Alexandria |
||||||
maintainers: |
maintainers: |
||||||
- npub1m3xdppkd0njmrqe2ma8a6ys39zvgp5k8u22mev8xsnqp4nh80srqhqa5sf |
- npub1m3xdppkd0njmrqe2ma8a6ys39zvgp5k8u22mev8xsnqp4nh80srqhqa5sf |
||||||
- npub1l5sga6xg72phsz5422ykujprejwud075ggrr3z2hwyrfgr7eylqstegx9z |
- npub1l5sga6xg72phsz5422ykujprejwud075ggrr3z2hwyrfgr7eylqstegx9z |
||||||
- npub1wqfzz2p880wq0tumuae9lfwyhs8uz35xd0kr34zrvrwyh3kvrzuskcqsyn |
- npub1wqfzz2p880wq0tumuae9lfwyhs8uz35xd0kr34zrvrwyh3kvrzuskcqsyn |
||||||
relays: |
relays: |
||||||
- wss://theforest.nostr1.com |
- wss://theforest.nostr1.com |
||||||
- wss://thecitadel.nostr1.com |
- wss://thecitadel.nostr1.com |
||||||
|
|||||||
@ -0,0 +1,448 @@ |
|||||||
|
<script lang='ts'> |
||||||
|
import { getTitleTagForEvent, getDTagForEvent, requiresDTag, hasDTag, validateNotAsciidoc, validateAsciiDoc, build30040EventSet, titleToDTag, validate30040EventSet, get30040EventDescription, analyze30040Event, get30040FixGuidance } from '$lib/utils/event_input_utils'; |
||||||
|
import { get } from 'svelte/store'; |
||||||
|
import { ndkInstance } from '$lib/ndk'; |
||||||
|
import { userPubkey } from '$lib/stores/authStore.Svelte'; |
||||||
|
import { NDKEvent as NDKEventClass } from '@nostr-dev-kit/ndk'; |
||||||
|
import type { NDKEvent } from '$lib/utils/nostrUtils'; |
||||||
|
import { prefixNostrAddresses } from '$lib/utils/nostrUtils'; |
||||||
|
import { standardRelays } from '$lib/consts'; |
||||||
|
import { Button } from "flowbite-svelte"; |
||||||
|
import { nip19 } from "nostr-tools"; |
||||||
|
import { goto } from "$app/navigation"; |
||||||
|
|
||||||
|
let kind = $state<number>(30023); |
||||||
|
let tags = $state<[string, string][]>([]); |
||||||
|
let content = $state(''); |
||||||
|
let createdAt = $state<number>(Math.floor(Date.now() / 1000)); |
||||||
|
let loading = $state(false); |
||||||
|
let error = $state<string | null>(null); |
||||||
|
let success = $state<string | null>(null); |
||||||
|
let publishedRelays = $state<string[]>([]); |
||||||
|
|
||||||
|
let title = $state(''); |
||||||
|
let dTag = $state(''); |
||||||
|
let titleManuallyEdited = $state(false); |
||||||
|
let dTagManuallyEdited = $state(false); |
||||||
|
let dTagError = $state(''); |
||||||
|
let lastPublishedEventId = $state<string | null>(null); |
||||||
|
|
||||||
|
/** |
||||||
|
* Extracts the first Markdown/AsciiDoc header as the title. |
||||||
|
*/ |
||||||
|
function extractTitleFromContent(content: string): string { |
||||||
|
// Match Markdown (# Title) or AsciiDoc (= Title) headers |
||||||
|
const match = content.match(/^(#|=)\s*(.+)$/m); |
||||||
|
return match ? match[2].trim() : ''; |
||||||
|
} |
||||||
|
|
||||||
|
function handleContentInput(e: Event) { |
||||||
|
content = (e.target as HTMLTextAreaElement).value; |
||||||
|
if (!titleManuallyEdited) { |
||||||
|
const extracted = extractTitleFromContent(content); |
||||||
|
console.log('Content input - extracted title:', extracted); |
||||||
|
title = extracted; |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
function handleTitleInput(e: Event) { |
||||||
|
title = (e.target as HTMLInputElement).value; |
||||||
|
titleManuallyEdited = true; |
||||||
|
} |
||||||
|
|
||||||
|
function handleDTagInput(e: Event) { |
||||||
|
dTag = (e.target as HTMLInputElement).value; |
||||||
|
dTagManuallyEdited = true; |
||||||
|
} |
||||||
|
|
||||||
|
$effect(() => { |
||||||
|
console.log('Effect running - title:', title, 'dTagManuallyEdited:', dTagManuallyEdited); |
||||||
|
if (!dTagManuallyEdited) { |
||||||
|
const newDTag = titleToDTag(title); |
||||||
|
console.log('Setting dTag to:', newDTag); |
||||||
|
dTag = newDTag; |
||||||
|
} |
||||||
|
}); |
||||||
|
|
||||||
|
function updateTag(index: number, key: string, value: string): void { |
||||||
|
tags = tags.map((t, i) => i === index ? [key, value] : t); |
||||||
|
} |
||||||
|
function addTag(): void { |
||||||
|
tags = [...tags, ['', '']]; |
||||||
|
} |
||||||
|
function removeTag(index: number): void { |
||||||
|
tags = tags.filter((_, i) => i !== index); |
||||||
|
} |
||||||
|
|
||||||
|
function isValidKind(kind: number | string): boolean { |
||||||
|
const n = Number(kind); |
||||||
|
return Number.isInteger(n) && n >= 0 && n <= 65535; |
||||||
|
} |
||||||
|
|
||||||
|
function validate(): { valid: boolean; reason?: string } { |
||||||
|
const currentUserPubkey = get(userPubkey as any); |
||||||
|
if (!currentUserPubkey) return { valid: false, reason: 'Not logged in.' }; |
||||||
|
const pubkey = String(currentUserPubkey); |
||||||
|
if (!content.trim()) return { valid: false, reason: 'Content required.' }; |
||||||
|
if (kind === 30023) { |
||||||
|
const v = validateNotAsciidoc(content); |
||||||
|
if (!v.valid) return v; |
||||||
|
} |
||||||
|
if (kind === 30040) { |
||||||
|
const v = validate30040EventSet(content); |
||||||
|
if (!v.valid) return v; |
||||||
|
} |
||||||
|
if (kind === 30041 || kind === 30818) { |
||||||
|
const v = validateAsciiDoc(content); |
||||||
|
if (!v.valid) return v; |
||||||
|
} |
||||||
|
return { valid: true }; |
||||||
|
} |
||||||
|
|
||||||
|
function handleSubmit(e: Event) { |
||||||
|
e.preventDefault(); |
||||||
|
dTagError = ''; |
||||||
|
if (requiresDTag(kind) && (!dTag || dTag.trim() === '')) { |
||||||
|
dTagError = 'A d-tag is required.'; |
||||||
|
return; |
||||||
|
} |
||||||
|
handlePublish(); |
||||||
|
} |
||||||
|
|
||||||
|
async function handlePublish(): Promise<void> { |
||||||
|
error = null; |
||||||
|
success = null; |
||||||
|
publishedRelays = []; |
||||||
|
loading = true; |
||||||
|
createdAt = Math.floor(Date.now() / 1000); |
||||||
|
|
||||||
|
try { |
||||||
|
const ndk = get(ndkInstance); |
||||||
|
const currentUserPubkey = get(userPubkey as any); |
||||||
|
if (!ndk || !currentUserPubkey) { |
||||||
|
error = 'NDK or pubkey missing.'; |
||||||
|
loading = false; |
||||||
|
return; |
||||||
|
} |
||||||
|
const pubkey = String(currentUserPubkey); |
||||||
|
|
||||||
|
if (!/^[a-fA-F0-9]{64}$/.test(pubkey)) { |
||||||
|
error = 'Invalid public key: must be a 64-character hex string.'; |
||||||
|
loading = false; |
||||||
|
return; |
||||||
|
} |
||||||
|
|
||||||
|
// Validate before proceeding |
||||||
|
const validation = validate(); |
||||||
|
if (!validation.valid) { |
||||||
|
error = validation.reason || 'Validation failed.'; |
||||||
|
loading = false; |
||||||
|
return; |
||||||
|
} |
||||||
|
|
||||||
|
const baseEvent = { pubkey, created_at: createdAt }; |
||||||
|
let events: NDKEvent[] = []; |
||||||
|
|
||||||
|
console.log('Publishing event with kind:', kind); |
||||||
|
console.log('Content length:', content.length); |
||||||
|
console.log('Content preview:', content.substring(0, 100)); |
||||||
|
console.log('Tags:', tags); |
||||||
|
console.log('Title:', title); |
||||||
|
console.log('DTag:', dTag); |
||||||
|
|
||||||
|
if (Number(kind) === 30040) { |
||||||
|
console.log('=== 30040 EVENT CREATION START ==='); |
||||||
|
console.log('Creating 30040 event set with content:', content); |
||||||
|
try { |
||||||
|
const { indexEvent, sectionEvents } = build30040EventSet(content, tags, baseEvent); |
||||||
|
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); |
||||||
|
|
||||||
|
// Debug the index event to ensure it's correct |
||||||
|
const indexEventData = { |
||||||
|
content: indexEvent.content, |
||||||
|
tags: indexEvent.tags.map(tag => [tag[0], tag[1]] as [string, string]), |
||||||
|
kind: indexEvent.kind || 30040 |
||||||
|
}; |
||||||
|
const analysis = debug30040Event(indexEventData); |
||||||
|
if (!analysis.valid) { |
||||||
|
console.warn('30040 index event has issues:', analysis.issues); |
||||||
|
} |
||||||
|
console.log('=== 30040 EVENT CREATION END ==='); |
||||||
|
} catch (error) { |
||||||
|
console.error('Error in build30040EventSet:', error); |
||||||
|
error = `Failed to build 30040 event set: ${error instanceof Error ? error.message : 'Unknown error'}`; |
||||||
|
loading = false; |
||||||
|
return; |
||||||
|
} |
||||||
|
} else { |
||||||
|
let eventTags = [...tags]; |
||||||
|
|
||||||
|
// Ensure d-tag exists and has a value for addressable events |
||||||
|
if (requiresDTag(kind)) { |
||||||
|
const dTagIndex = eventTags.findIndex(([k]) => k === 'd'); |
||||||
|
const dTagValue = dTag.trim() || getDTagForEvent(kind, content, ''); |
||||||
|
|
||||||
|
if (dTagValue) { |
||||||
|
if (dTagIndex >= 0) { |
||||||
|
// Update existing d-tag |
||||||
|
eventTags[dTagIndex] = ['d', dTagValue]; |
||||||
|
} else { |
||||||
|
// Add new d-tag |
||||||
|
eventTags = [...eventTags, ['d', dTagValue]]; |
||||||
|
} |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
// Add title tag if we have a title |
||||||
|
const titleValue = title.trim() || getTitleTagForEvent(kind, content); |
||||||
|
if (titleValue) { |
||||||
|
eventTags = [...eventTags, ['title', titleValue]]; |
||||||
|
} |
||||||
|
|
||||||
|
// Prefix Nostr addresses before publishing |
||||||
|
const prefixedContent = prefixNostrAddresses(content); |
||||||
|
|
||||||
|
// Create event with proper serialization |
||||||
|
const eventData = { |
||||||
|
kind, |
||||||
|
content: prefixedContent, |
||||||
|
tags: eventTags, |
||||||
|
pubkey, |
||||||
|
created_at: createdAt, |
||||||
|
}; |
||||||
|
|
||||||
|
events = [new NDKEventClass(ndk, eventData)]; |
||||||
|
} |
||||||
|
|
||||||
|
let atLeastOne = false; |
||||||
|
let relaysPublished: string[] = []; |
||||||
|
|
||||||
|
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 => [String(tag[0]), String(tag[1])]), |
||||||
|
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 = ['wss://relay.damus.io', 'wss://relay.nostr.band', 'wss://nos.lol', ...standardRelays]; |
||||||
|
let published = false; |
||||||
|
|
||||||
|
for (const relayUrl of relays) { |
||||||
|
try { |
||||||
|
const ws = new WebSocket(relayUrl); |
||||||
|
await new Promise<void>((resolve, reject) => { |
||||||
|
const timeout = setTimeout(() => { |
||||||
|
ws.close(); |
||||||
|
reject(new Error("Timeout")); |
||||||
|
}, 5000); |
||||||
|
|
||||||
|
ws.onopen = () => { |
||||||
|
ws.send(JSON.stringify(["EVENT", signedEvent])); |
||||||
|
}; |
||||||
|
|
||||||
|
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); |
||||||
|
ws.close(); |
||||||
|
resolve(); |
||||||
|
} else { |
||||||
|
ws.close(); |
||||||
|
reject(new Error(message)); |
||||||
|
} |
||||||
|
} |
||||||
|
}; |
||||||
|
|
||||||
|
ws.onerror = () => { |
||||||
|
clearTimeout(timeout); |
||||||
|
ws.close(); |
||||||
|
reject(new Error("WebSocket error")); |
||||||
|
}; |
||||||
|
}); |
||||||
|
if (published) break; |
||||||
|
} catch (e) { |
||||||
|
console.error(`Failed to publish to ${relayUrl}:`, e); |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
if (published) { |
||||||
|
atLeastOne = true; |
||||||
|
// For 30040, set lastPublishedEventId to the index event (last in array) |
||||||
|
if (Number(kind) === 30040) { |
||||||
|
if (i === events.length - 1) { |
||||||
|
lastPublishedEventId = event.id; |
||||||
|
} |
||||||
|
} else { |
||||||
|
lastPublishedEventId = event.id; |
||||||
|
} |
||||||
|
} |
||||||
|
} catch (signError) { |
||||||
|
console.error('Error signing/publishing event:', signError); |
||||||
|
error = `Failed to sign event: ${signError instanceof Error ? signError.message : 'Unknown error'}`; |
||||||
|
loading = false; |
||||||
|
return; |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
loading = false; |
||||||
|
if (atLeastOne) { |
||||||
|
publishedRelays = relaysPublished; |
||||||
|
success = `Published to ${relaysPublished.length} relay(s).`; |
||||||
|
} else { |
||||||
|
error = 'Failed to publish to any relay.'; |
||||||
|
} |
||||||
|
} catch (err) { |
||||||
|
console.error('Error in handlePublish:', err); |
||||||
|
error = `Publishing failed: ${err instanceof Error ? err.message : 'Unknown error'}`; |
||||||
|
loading = false; |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
/** |
||||||
|
* Debug function to analyze a 30040 event and provide guidance. |
||||||
|
*/ |
||||||
|
function debug30040Event(eventData: { content: string; tags: [string, string][]; kind: number }) { |
||||||
|
const analysis = analyze30040Event(eventData); |
||||||
|
console.log('30040 Event Analysis:', analysis); |
||||||
|
if (!analysis.valid) { |
||||||
|
console.log('Guidance:', get30040FixGuidance()); |
||||||
|
} |
||||||
|
return analysis; |
||||||
|
} |
||||||
|
|
||||||
|
function viewPublishedEvent() { |
||||||
|
if (lastPublishedEventId) { |
||||||
|
goto(`/events?id=${encodeURIComponent(lastPublishedEventId)}`); |
||||||
|
} |
||||||
|
} |
||||||
|
</script> |
||||||
|
|
||||||
|
<div class='w-full max-w-2xl mx-auto my-8 p-6 bg-white dark:bg-gray-900 rounded-lg shadow-lg'> |
||||||
|
<h2 class='text-xl font-bold mb-4'>Publish Nostr Event</h2> |
||||||
|
<form class='space-y-4' onsubmit={handleSubmit}> |
||||||
|
<div> |
||||||
|
<label class='block font-medium mb-1' for='event-kind'>Kind</label> |
||||||
|
<input id='event-kind' type='text' class='input input-bordered w-full' bind:value={kind} required /> |
||||||
|
{#if !isValidKind(kind)} |
||||||
|
<div class="text-red-600 text-sm mt-1"> |
||||||
|
Kind must be an integer between 0 and 65535 (NIP-01). |
||||||
|
</div> |
||||||
|
{/if} |
||||||
|
{#if kind === 30040} |
||||||
|
<div class="text-blue-600 text-sm mt-1 bg-blue-50 dark:bg-blue-900 p-2 rounded"> |
||||||
|
<strong>30040 - Publication Index:</strong> {get30040EventDescription()} |
||||||
|
</div> |
||||||
|
{/if} |
||||||
|
</div> |
||||||
|
<div> |
||||||
|
<label class='block font-medium mb-1' for='tags-container'>Tags</label> |
||||||
|
<div id='tags-container' class='space-y-2'> |
||||||
|
{#each tags as [key, value], i} |
||||||
|
<div class='flex gap-2'> |
||||||
|
<input type='text' class='input input-bordered flex-1' placeholder='tag' bind:value={tags[i][0]} oninput={e => updateTag(i, (e.target as HTMLInputElement).value, tags[i][1])} /> |
||||||
|
<input type='text' class='input input-bordered flex-1' placeholder='value' bind:value={tags[i][1]} oninput={e => updateTag(i, tags[i][0], (e.target as HTMLInputElement).value)} /> |
||||||
|
<button type='button' class='btn btn-error btn-sm' onclick={() => removeTag(i)} disabled={tags.length === 1}>×</button> |
||||||
|
</div> |
||||||
|
{/each} |
||||||
|
<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> |
||||||
|
<div> |
||||||
|
<label class='block font-medium mb-1' for='event-content'>Content</label> |
||||||
|
<textarea |
||||||
|
id='event-content' |
||||||
|
bind:value={content} |
||||||
|
oninput={handleContentInput} |
||||||
|
placeholder='Content (start with a header for the title)' |
||||||
|
class='textarea textarea-bordered w-full h-40' |
||||||
|
required |
||||||
|
></textarea> |
||||||
|
</div> |
||||||
|
<div> |
||||||
|
<label class='block font-medium mb-1' for='event-title'>Title</label> |
||||||
|
<input |
||||||
|
type='text' |
||||||
|
id='event-title' |
||||||
|
bind:value={title} |
||||||
|
oninput={handleTitleInput} |
||||||
|
placeholder='Title (auto-filled from header)' |
||||||
|
class='input input-bordered w-full' |
||||||
|
/> |
||||||
|
</div> |
||||||
|
<div> |
||||||
|
<label class='block font-medium mb-1' for='event-d-tag'>d-tag</label> |
||||||
|
<input |
||||||
|
type='text' |
||||||
|
id='event-d-tag' |
||||||
|
bind:value={dTag} |
||||||
|
oninput={handleDTagInput} |
||||||
|
placeholder='d-tag (auto-generated from title)' |
||||||
|
class='input input-bordered w-full' |
||||||
|
required={requiresDTag(kind)} |
||||||
|
/> |
||||||
|
{#if dTagError} |
||||||
|
<div class='text-red-600 text-sm mt-1'>{dTagError}</div> |
||||||
|
{/if} |
||||||
|
</div> |
||||||
|
<div class='flex justify-end'> |
||||||
|
<button type='submit' class='btn btn-primary border border-primary-600 px-4 py-2' disabled={loading}>Publish</button> |
||||||
|
</div> |
||||||
|
{#if loading} |
||||||
|
<span class='ml-2 text-gray-500'>Publishing...</span> |
||||||
|
{/if} |
||||||
|
{#if error} |
||||||
|
<div class='mt-2 text-red-600'>{error}</div> |
||||||
|
{/if} |
||||||
|
{#if success} |
||||||
|
<div class='mt-2 text-green-600'>{success}</div> |
||||||
|
<div class='text-xs text-gray-500'>Relays: {publishedRelays.join(', ')}</div> |
||||||
|
{#if lastPublishedEventId} |
||||||
|
<div class='mt-2 text-green-700'> |
||||||
|
Event ID: <span class='font-mono'>{lastPublishedEventId}</span> |
||||||
|
<Button onclick={viewPublishedEvent} class='text-primary-600 dark:text-primary-500 hover:underline ml-2'> |
||||||
|
View your event |
||||||
|
</Button> |
||||||
|
</div> |
||||||
|
{/if} |
||||||
|
{/if} |
||||||
|
</form> |
||||||
|
</div> |
||||||
@ -1,12 +0,0 @@ |
|||||||
<script lang="ts"> |
|
||||||
import type { NDKEvent } from "@nostr-dev-kit/ndk"; |
|
||||||
export let showModal; |
|
||||||
export let event: NDKEvent; |
|
||||||
// let str: string = JSON.stringify(event); |
|
||||||
</script> |
|
||||||
|
|
||||||
{#if showModal} |
|
||||||
<div class="backdrop"> |
|
||||||
<div class="Modal">{event.id}</div> |
|
||||||
</div> |
|
||||||
{/if} |
|
||||||
@ -0,0 +1,167 @@ |
|||||||
|
<script lang="ts"> |
||||||
|
import { Button, Alert } from "flowbite-svelte"; |
||||||
|
import { |
||||||
|
ndkInstance, |
||||||
|
ndkSignedIn, |
||||||
|
testRelayConnection, |
||||||
|
checkWebSocketSupport, |
||||||
|
checkEnvironmentForWebSocketDowngrade, |
||||||
|
} from "$lib/ndk"; |
||||||
|
import { standardRelays, anonymousRelays } from "$lib/consts"; |
||||||
|
import { onMount } from "svelte"; |
||||||
|
import { feedType } from "$lib/stores"; |
||||||
|
import { inboxRelays, outboxRelays } from "$lib/ndk"; |
||||||
|
import { FeedType } from "$lib/consts"; |
||||||
|
|
||||||
|
interface RelayStatus { |
||||||
|
url: string; |
||||||
|
connected: boolean; |
||||||
|
requiresAuth: boolean; |
||||||
|
error?: string; |
||||||
|
testing: boolean; |
||||||
|
} |
||||||
|
|
||||||
|
let relayStatuses = $state<RelayStatus[]>([]); |
||||||
|
let testing = $state(false); |
||||||
|
|
||||||
|
async function runRelayTests() { |
||||||
|
testing = true; |
||||||
|
const ndk = $ndkInstance; |
||||||
|
if (!ndk) { |
||||||
|
testing = false; |
||||||
|
return; |
||||||
|
} |
||||||
|
|
||||||
|
let relaysToTest: string[] = []; |
||||||
|
|
||||||
|
if ($feedType === FeedType.UserRelays && $ndkSignedIn) { |
||||||
|
// Use user's relays (inbox + outbox), deduplicated |
||||||
|
const userRelays = new Set([...$inboxRelays, ...$outboxRelays]); |
||||||
|
relaysToTest = Array.from(userRelays); |
||||||
|
} else { |
||||||
|
// Use default relays (standard + anonymous), deduplicated |
||||||
|
relaysToTest = Array.from( |
||||||
|
new Set([...standardRelays, ...anonymousRelays]), |
||||||
|
); |
||||||
|
} |
||||||
|
|
||||||
|
console.log("[RelayStatus] Relays to test:", relaysToTest); |
||||||
|
|
||||||
|
relayStatuses = relaysToTest.map((url) => ({ |
||||||
|
url, |
||||||
|
connected: false, |
||||||
|
requiresAuth: false, |
||||||
|
testing: true, |
||||||
|
})); |
||||||
|
|
||||||
|
const results = await Promise.allSettled( |
||||||
|
relaysToTest.map(async (url) => { |
||||||
|
console.log("[RelayStatus] Testing relay:", url); |
||||||
|
try { |
||||||
|
return await testRelayConnection(url, ndk); |
||||||
|
} catch (error) { |
||||||
|
return { |
||||||
|
connected: false, |
||||||
|
requiresAuth: false, |
||||||
|
error: error instanceof Error ? error.message : "Unknown error", |
||||||
|
}; |
||||||
|
} |
||||||
|
}), |
||||||
|
); |
||||||
|
|
||||||
|
relayStatuses = relayStatuses.map((status, index) => { |
||||||
|
const result = results[index]; |
||||||
|
if (result.status === "fulfilled") { |
||||||
|
return { |
||||||
|
...status, |
||||||
|
...result.value, |
||||||
|
testing: false, |
||||||
|
}; |
||||||
|
} else { |
||||||
|
return { |
||||||
|
...status, |
||||||
|
connected: false, |
||||||
|
requiresAuth: false, |
||||||
|
error: "Test failed", |
||||||
|
testing: false, |
||||||
|
}; |
||||||
|
} |
||||||
|
}); |
||||||
|
|
||||||
|
testing = false; |
||||||
|
} |
||||||
|
|
||||||
|
$effect(() => { |
||||||
|
// Re-run relay tests when feed type, login state, or relay lists change |
||||||
|
void runRelayTests(); |
||||||
|
}); |
||||||
|
|
||||||
|
onMount(() => { |
||||||
|
checkWebSocketSupport(); |
||||||
|
checkEnvironmentForWebSocketDowngrade(); |
||||||
|
// Run initial relay tests |
||||||
|
void runRelayTests(); |
||||||
|
}); |
||||||
|
|
||||||
|
function getStatusColor(status: RelayStatus): string { |
||||||
|
if (status.testing) return "text-yellow-600"; |
||||||
|
if (status.connected) return "text-green-600"; |
||||||
|
if (status.requiresAuth && !$ndkSignedIn) return "text-orange-600"; |
||||||
|
return "text-red-600"; |
||||||
|
} |
||||||
|
|
||||||
|
function getStatusText(status: RelayStatus): string { |
||||||
|
if (status.testing) return "Testing..."; |
||||||
|
if (status.connected) return "Connected"; |
||||||
|
if (status.requiresAuth && !$ndkSignedIn) return "Requires Authentication"; |
||||||
|
if (status.error) return `Error: ${status.error}`; |
||||||
|
return "Failed to Connect"; |
||||||
|
} |
||||||
|
</script> |
||||||
|
|
||||||
|
<div class="space-y-4"> |
||||||
|
<div class="flex items-center justify-between"> |
||||||
|
<h3 class="text-lg font-medium">Relay Connection Status</h3> |
||||||
|
<Button size="sm" onclick={runRelayTests} disabled={testing}> |
||||||
|
{testing ? "Testing..." : "Refresh"} |
||||||
|
</Button> |
||||||
|
</div> |
||||||
|
|
||||||
|
{#if !$ndkSignedIn} |
||||||
|
<Alert color="yellow"> |
||||||
|
<span class="font-medium">Anonymous Mode</span> |
||||||
|
<p class="mt-1 text-sm"> |
||||||
|
You are not signed in. Some relays require authentication and may not be |
||||||
|
accessible. Sign in to access all relays. |
||||||
|
</p> |
||||||
|
</Alert> |
||||||
|
{/if} |
||||||
|
|
||||||
|
<div class="space-y-2"> |
||||||
|
{#each relayStatuses as status} |
||||||
|
<div class="flex items-center justify-between p-3 border rounded-lg"> |
||||||
|
<div class="flex-1"> |
||||||
|
<div class="font-medium">{status.url}</div> |
||||||
|
<div class="text-sm {getStatusColor(status)}"> |
||||||
|
{getStatusText(status)} |
||||||
|
</div> |
||||||
|
</div> |
||||||
|
<div |
||||||
|
class="w-3 h-3 rounded-full {getStatusColor(status).replace( |
||||||
|
'text-', |
||||||
|
'bg-', |
||||||
|
)}" |
||||||
|
></div> |
||||||
|
</div> |
||||||
|
{/each} |
||||||
|
</div> |
||||||
|
|
||||||
|
{#if relayStatuses.some((s) => s.requiresAuth && !$ndkSignedIn)} |
||||||
|
<Alert color="orange"> |
||||||
|
<span class="font-medium">Authentication Required</span> |
||||||
|
<p class="mt-1 text-sm"> |
||||||
|
Some relays require authentication. Sign in to access these relays. |
||||||
|
</p> |
||||||
|
</Alert> |
||||||
|
{/if} |
||||||
|
</div> |
||||||
@ -1,24 +1,28 @@ |
|||||||
<script lang="ts"> |
<script lang="ts"> |
||||||
import type { NDKEvent } from '@nostr-dev-kit/ndk'; |
import type { NDKEvent } from "@nostr-dev-kit/ndk"; |
||||||
import {nip19} from 'nostr-tools'; |
import { nip19 } from "nostr-tools"; |
||||||
export let notes: NDKEvent[] = []; |
export let notes: NDKEvent[] = []; |
||||||
// check if notes is empty |
// check if notes is empty |
||||||
if (notes.length === 0) { |
if (notes.length === 0) { |
||||||
console.debug('notes is empty'); |
console.debug("notes is empty"); |
||||||
} |
} |
||||||
</script> |
</script> |
||||||
|
|
||||||
<div class="toc"> |
<div class="toc"> |
||||||
<h2>Table of contents</h2> |
<h2>Table of contents</h2> |
||||||
<ul> |
<ul> |
||||||
{#each notes as note} |
{#each notes as note} |
||||||
<li><a href="#{nip19.noteEncode(note.id)}">{note.getMatchingTags('title')[0][1]}</a></li> |
<li> |
||||||
{/each} |
<a href="#{nip19.noteEncode(note.id)}" |
||||||
</ul> |
>{note.getMatchingTags("title")[0][1]}</a |
||||||
|
> |
||||||
|
</li> |
||||||
|
{/each} |
||||||
|
</ul> |
||||||
</div> |
</div> |
||||||
|
|
||||||
<style> |
<style> |
||||||
.toc h2 { |
.toc h2 { |
||||||
text-align: center; |
text-align: center; |
||||||
} |
} |
||||||
</style> |
</style> |
||||||
|
|||||||
@ -1,99 +1,116 @@ |
|||||||
<script lang='ts'> |
<script lang="ts"> |
||||||
import CopyToClipboard from "$components/util/CopyToClipboard.svelte"; |
import CopyToClipboard from "$components/util/CopyToClipboard.svelte"; |
||||||
import { logout, ndkInstance } from '$lib/ndk'; |
import { logout, ndkInstance } from "$lib/ndk"; |
||||||
import { ArrowRightToBracketOutline, UserOutline, FileSearchOutline } from "flowbite-svelte-icons"; |
import { |
||||||
import { Avatar, Popover } from "flowbite-svelte"; |
ArrowRightToBracketOutline, |
||||||
import type { NDKUserProfile } from "@nostr-dev-kit/ndk"; |
UserOutline, |
||||||
|
FileSearchOutline, |
||||||
|
} from "flowbite-svelte-icons"; |
||||||
|
import { Avatar, Popover } from "flowbite-svelte"; |
||||||
|
import type { NDKUserProfile } from "@nostr-dev-kit/ndk"; |
||||||
|
import { goto } from "$app/navigation"; |
||||||
|
|
||||||
const externalProfileDestination = './events?id=' |
let { pubkey, isNav = false } = $props(); |
||||||
|
|
||||||
let { pubkey, isNav = false } = $props(); |
let profile = $state<NDKUserProfile | null>(null); |
||||||
|
let pfp = $derived(profile?.image); |
||||||
|
let username = $derived(profile?.name); |
||||||
|
let tag = $derived(profile?.name); |
||||||
|
let npub = $state<string | undefined>(undefined); |
||||||
|
|
||||||
let profile = $state<NDKUserProfile | null>(null); |
$effect(() => { |
||||||
let pfp = $derived(profile?.image); |
const user = $ndkInstance.getUser({ pubkey: pubkey ?? undefined }); |
||||||
let username = $derived(profile?.name); |
|
||||||
let tag = $derived(profile?.name); |
|
||||||
let npub = $state<string | undefined >(undefined); |
|
||||||
|
|
||||||
$effect(() => { |
npub = user.npub; |
||||||
const user = $ndkInstance |
|
||||||
.getUser({ pubkey: pubkey ?? undefined }); |
|
||||||
|
|
||||||
npub = user.npub; |
user.fetchProfile().then((userProfile) => { |
||||||
|
|
||||||
user.fetchProfile() |
|
||||||
.then(userProfile => { |
|
||||||
profile = userProfile; |
profile = userProfile; |
||||||
}); |
}); |
||||||
}); |
}); |
||||||
|
|
||||||
|
async function handleSignOutClick() { |
||||||
|
logout($ndkInstance.activeUser!); |
||||||
|
profile = null; |
||||||
|
} |
||||||
|
|
||||||
async function handleSignOutClick() { |
function handleViewProfile() { |
||||||
logout($ndkInstance.activeUser!); |
if (npub) { |
||||||
profile = null; |
goto(`/events?id=${encodeURIComponent(npub)}`); |
||||||
} |
} |
||||||
|
} |
||||||
|
|
||||||
function shortenNpub(long: string|undefined) { |
function shortenNpub(long: string | undefined) { |
||||||
if (!long) return ''; |
if (!long) return ""; |
||||||
return long.slice(0, 8) + '…' + long.slice(-4); |
return long.slice(0, 8) + "…" + long.slice(-4); |
||||||
} |
} |
||||||
</script> |
</script> |
||||||
|
|
||||||
<div class="relative"> |
<div class="relative"> |
||||||
{#if profile} |
{#if profile} |
||||||
<div class="group"> |
<div class="group"> |
||||||
<Avatar |
<Avatar |
||||||
rounded |
rounded |
||||||
class='h-6 w-6 cursor-pointer' |
class="h-6 w-6 cursor-pointer" |
||||||
src={pfp} |
src={pfp} |
||||||
alt={username} |
alt={username} |
||||||
id="profile-avatar" |
id="profile-avatar" |
||||||
/> |
/> |
||||||
{#key username || tag} |
{#key username || tag} |
||||||
<Popover |
<Popover |
||||||
placement="bottom" |
placement="bottom" |
||||||
triggeredBy="#profile-avatar" |
triggeredBy="#profile-avatar" |
||||||
class='popover-leather w-[180px]' |
class="popover-leather w-[180px]" |
||||||
trigger='hover' |
trigger="hover" |
||||||
> |
> |
||||||
<div class='flex flex-row justify-between space-x-4'> |
<div class="flex flex-row justify-between space-x-4"> |
||||||
<div class='flex flex-col'> |
<div class="flex flex-col"> |
||||||
{#if username} |
{#if username} |
||||||
<h3 class='text-lg font-bold'>{username}</h3> |
<h3 class="text-lg font-bold">{username}</h3> |
||||||
{#if isNav}<h4 class='text-base'>@{tag}</h4>{/if} |
{#if isNav}<h4 class="text-base">@{tag}</h4>{/if} |
||||||
{/if} |
{/if} |
||||||
<ul class="space-y-2 mt-2"> |
<ul class="space-y-2 mt-2"> |
||||||
<li> |
<li> |
||||||
<CopyToClipboard displayText={shortenNpub(npub)} copyText={npub} /> |
<CopyToClipboard |
||||||
</li> |
displayText={shortenNpub(npub)} |
||||||
<li> |
copyText={npub} |
||||||
<a class='hover:text-primary-400 dark:hover:text-primary-500 text-nowrap mt-3 m-0' href='{externalProfileDestination}{npub}' target='_blank'> |
/> |
||||||
<UserOutline class='mr-1 !h-6 !w-6 inline !fill-none dark:!fill-none' /><span class='underline'>View profile</span> |
</li> |
||||||
</a> |
|
||||||
</li> |
|
||||||
{#if isNav} |
|
||||||
<li> |
<li> |
||||||
<button |
<button |
||||||
id='sign-out-button' |
class="hover:text-primary-400 dark:hover:text-primary-500 text-nowrap mt-3 m-0 text-left" |
||||||
class='btn-leather text-nowrap mt-3 flex self-stretch align-middle hover:text-primary-400 dark:hover:text-primary-500' |
onclick={handleViewProfile} |
||||||
onclick={handleSignOutClick} |
|
||||||
> |
> |
||||||
<ArrowRightToBracketOutline class='mr-1 !h-6 !w-6 inline !fill-none dark:!fill-none' /> Sign out |
<UserOutline |
||||||
|
class="mr-1 !h-6 !w-6 inline !fill-none dark:!fill-none" |
||||||
|
/><span class="underline">View profile</span> |
||||||
</button> |
</button> |
||||||
</li> |
</li> |
||||||
{:else} |
{#if isNav} |
||||||
<!-- li> |
<li> |
||||||
|
<button |
||||||
|
id="sign-out-button" |
||||||
|
class="btn-leather text-nowrap mt-3 flex self-stretch align-middle hover:text-primary-400 dark:hover:text-primary-500" |
||||||
|
onclick={handleSignOutClick} |
||||||
|
> |
||||||
|
<ArrowRightToBracketOutline |
||||||
|
class="mr-1 !h-6 !w-6 inline !fill-none dark:!fill-none" |
||||||
|
/> Sign out |
||||||
|
</button> |
||||||
|
</li> |
||||||
|
{:else} |
||||||
|
<!-- li> |
||||||
<button |
<button |
||||||
class='btn-leather text-nowrap mt-3 flex self-stretch align-middle hover:text-primary-400 dark:hover:text-primary-500' |
class='btn-leather text-nowrap mt-3 flex self-stretch align-middle hover:text-primary-400 dark:hover:text-primary-500' |
||||||
> |
> |
||||||
<FileSearchOutline class='mr-1 !h-6 inline !fill-none dark:!fill-none' /> More content |
<FileSearchOutline class='mr-1 !h-6 inline !fill-none dark:!fill-none' /> More content |
||||||
</button> |
</button> |
||||||
</li --> |
</li --> |
||||||
{/if} |
{/if} |
||||||
</ul> |
</ul> |
||||||
|
</div> |
||||||
</div> |
</div> |
||||||
</div> |
</Popover> |
||||||
</Popover> |
{/key} |
||||||
{/key} |
</div> |
||||||
</div> |
|
||||||
{/if} |
{/if} |
||||||
</div> |
</div> |
||||||
|
|||||||
@ -0,0 +1,80 @@ |
|||||||
|
<script lang="ts"> |
||||||
|
import type { NDKEvent } from "$lib/utils/nostrUtils"; |
||||||
|
import { getMatchingTags } from "$lib/utils/nostrUtils"; |
||||||
|
import { naddrEncode } from "$lib/utils"; |
||||||
|
import { getEventType } from "$lib/utils/mime"; |
||||||
|
import { standardRelays } from "$lib/consts"; |
||||||
|
import { goto } from "$app/navigation"; |
||||||
|
|
||||||
|
let { event, className = "" } = $props<{ |
||||||
|
event: NDKEvent; |
||||||
|
className?: string; |
||||||
|
}>(); |
||||||
|
|
||||||
|
function getDeferralNaddr(event: NDKEvent): string | undefined { |
||||||
|
// Look for a 'deferral' tag, e.g. ['deferral', 'naddr1...'] |
||||||
|
return getMatchingTags(event, "deferral")[0]?.[1]; |
||||||
|
} |
||||||
|
|
||||||
|
function isAddressableEvent(event: NDKEvent): boolean { |
||||||
|
return getEventType(event.kind || 0) === "addressable"; |
||||||
|
} |
||||||
|
|
||||||
|
function getNaddrAddress(event: NDKEvent): string | null { |
||||||
|
if (!isAddressableEvent(event)) { |
||||||
|
return null; |
||||||
|
} |
||||||
|
try { |
||||||
|
return naddrEncode(event, standardRelays); |
||||||
|
} catch { |
||||||
|
return null; |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
function getViewPublicationNaddr(event: NDKEvent): string | null { |
||||||
|
// First, check for a-tags with 'defer' - these indicate the event is deferring to someone else's version |
||||||
|
const aTags = getMatchingTags(event, "a"); |
||||||
|
for (const tag of aTags) { |
||||||
|
if (tag.length >= 2 && tag.includes("defer")) { |
||||||
|
// This is a deferral to someone else's addressable event |
||||||
|
return tag[1]; // Return the addressable event address |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
// For deferred events with deferral tag, use the deferral naddr instead of the event's own naddr |
||||||
|
const deferralNaddr = getDeferralNaddr(event); |
||||||
|
if (deferralNaddr) { |
||||||
|
return deferralNaddr; |
||||||
|
} |
||||||
|
|
||||||
|
// Otherwise, use the event's own naddr if it's addressable |
||||||
|
return getNaddrAddress(event); |
||||||
|
} |
||||||
|
|
||||||
|
function navigateToPublication() { |
||||||
|
const naddrAddress = getViewPublicationNaddr(event); |
||||||
|
console.log("ViewPublicationLink: navigateToPublication called", { |
||||||
|
eventKind: event.kind, |
||||||
|
naddrAddress, |
||||||
|
isAddressable: isAddressableEvent(event) |
||||||
|
}); |
||||||
|
if (naddrAddress) { |
||||||
|
console.log("ViewPublicationLink: Navigating to publication:", naddrAddress); |
||||||
|
goto(`/publication?id=${encodeURIComponent(naddrAddress)}`); |
||||||
|
} else { |
||||||
|
console.log("ViewPublicationLink: No naddr address found for event"); |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
let naddrAddress = $derived(getViewPublicationNaddr(event)); |
||||||
|
</script> |
||||||
|
|
||||||
|
{#if naddrAddress} |
||||||
|
<button |
||||||
|
class="inline-flex items-center px-3 py-2 text-sm font-medium text-white bg-primary-600 hover:bg-primary-700 focus:bg-primary-700 focus:outline-none focus:ring-2 focus:ring-primary-500 rounded-lg transition-colors {className}" |
||||||
|
onclick={navigateToPublication} |
||||||
|
tabindex="0" |
||||||
|
> |
||||||
|
View Publication |
||||||
|
</button> |
||||||
|
{/if} |
||||||
@ -1,23 +1,43 @@ |
|||||||
export const wikiKind = 30818; |
export const wikiKind = 30818; |
||||||
export const indexKind = 30040; |
export const indexKind = 30040; |
||||||
export const zettelKinds = [ 30041, 30818 ]; |
export const zettelKinds = [30041, 30818]; |
||||||
export const communityRelay = [ 'wss://theforest.nostr1.com' ]; |
export const communityRelay = "wss://theforest.nostr1.com"; |
||||||
export const standardRelays = [ 'wss://thecitadel.nostr1.com', 'wss://theforest.nostr1.com' ]; |
export const profileRelay = "wss://profiles.nostr1.com"; |
||||||
export const fallbackRelays = [
|
export const standardRelays = [ |
||||||
'wss://purplepag.es', |
"wss://thecitadel.nostr1.com", |
||||||
'wss://indexer.coracle.social', |
"wss://theforest.nostr1.com", |
||||||
'wss://relay.noswhere.com', |
"wss://profiles.nostr1.com", |
||||||
'wss://relay.damus.io', |
// Removed gitcitadel.nostr1.com as it's causing connection issues
|
||||||
'wss://relay.nostr.band', |
//'wss://thecitadel.gitcitadel.eu',
|
||||||
'wss://relay.lumina.rocks', |
//'wss://theforest.gitcitadel.eu',
|
||||||
'wss://nostr.wine', |
]; |
||||||
'wss://nostr.land' |
|
||||||
|
// Non-auth relays for anonymous users
|
||||||
|
export const anonymousRelays = [ |
||||||
|
"wss://thecitadel.nostr1.com", |
||||||
|
"wss://theforest.nostr1.com", |
||||||
|
"wss://profiles.nostr1.com", |
||||||
|
"wss://freelay.sovbit.host", |
||||||
|
]; |
||||||
|
export const fallbackRelays = [ |
||||||
|
"wss://purplepag.es", |
||||||
|
"wss://indexer.coracle.social", |
||||||
|
"wss://relay.noswhere.com", |
||||||
|
"wss://aggr.nostr.land", |
||||||
|
"wss://nostr.land", |
||||||
|
"wss://nostr.wine", |
||||||
|
"wss://nostr.sovbit.host", |
||||||
|
"wss://freelay.sovbit.host", |
||||||
|
"wss://nostr21.com", |
||||||
|
"wss://greensoul.space", |
||||||
|
"wss://relay.damus.io", |
||||||
|
"wss://relay.nostr.band", |
||||||
]; |
]; |
||||||
|
|
||||||
export enum FeedType { |
export enum FeedType { |
||||||
StandardRelays = 'standard', |
StandardRelays = "standard", |
||||||
UserRelays = 'user', |
UserRelays = "user", |
||||||
} |
} |
||||||
|
|
||||||
export const loginStorageKey = 'alexandria/login/pubkey'; |
export const loginStorageKey = "alexandria/login/pubkey"; |
||||||
export const feedTypeStorageKey = 'alexandria/feed/type'; |
export const feedTypeStorageKey = "alexandria/feed/type"; |
||||||
|
|||||||
@ -1,19 +1,32 @@ |
|||||||
<script module lang='ts'> |
<script module lang="ts"> |
||||||
import { createProfileLink, createProfileLinkWithVerification, toNpub } from '$lib/utils/nostrUtils'; |
import { |
||||||
|
toNpub, |
||||||
|
} from "$lib/utils/nostrUtils"; |
||||||
|
import { goto } from "$app/navigation"; |
||||||
|
|
||||||
export { userBadge }; |
export { userBadge }; |
||||||
</script> |
</script> |
||||||
|
|
||||||
{#snippet userBadge(identifier: string, displayText: string | undefined)} |
{#snippet userBadge(identifier: string, displayText: string | undefined)} |
||||||
{#if toNpub(identifier)} |
{#if toNpub(identifier)} |
||||||
{#await createProfileLinkWithVerification(toNpub(identifier) as string, displayText)} |
{@const npub = toNpub(identifier) as string} |
||||||
{@html createProfileLink(toNpub(identifier) as string, displayText)} |
{@const cleanId = npub.replace(/^nostr:/, "")} |
||||||
{:then html} |
{@const defaultText = `${cleanId.slice(0, 8)}...${cleanId.slice(-4)}`} |
||||||
{@html html} |
{@const displayTextFinal = displayText || defaultText} |
||||||
{:catch} |
|
||||||
{@html createProfileLink(toNpub(identifier) as string, displayText)} |
<button |
||||||
{/await} |
class="npub-badge hover:underline" |
||||||
|
onclick={() => goto(`/events?id=${encodeURIComponent(cleanId)}`)} |
||||||
|
onkeydown={(e) => { |
||||||
|
if (e.key === 'Enter' || e.key === ' ') { |
||||||
|
e.preventDefault(); |
||||||
|
goto(`/events?id=${encodeURIComponent(cleanId)}`); |
||||||
|
} |
||||||
|
}} |
||||||
|
> |
||||||
|
@{displayTextFinal} |
||||||
|
</button> |
||||||
{:else} |
{:else} |
||||||
{displayText ?? ''} |
{displayText ?? ""} |
||||||
{/if} |
{/if} |
||||||
{/snippet} |
{/snippet} |
||||||
|
|||||||
@ -0,0 +1,11 @@ |
|||||||
|
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);
|
||||||
@ -1,4 +1,4 @@ |
|||||||
import { writable } from 'svelte/store'; |
import { writable } from "svelte/store"; |
||||||
|
|
||||||
// Initialize with empty array, will be populated from user preferences
|
// Initialize with empty array, will be populated from user preferences
|
||||||
export const userRelays = writable<string[]>([]);
|
export const userRelays = writable<string[]>([]); |
||||||
|
|||||||
@ -0,0 +1,65 @@ |
|||||||
|
import { communityRelay } from '$lib/consts'; |
||||||
|
import { RELAY_CONSTANTS, SEARCH_LIMITS } from './search_constants'; |
||||||
|
|
||||||
|
// Cache for pubkeys with kind 1 events on communityRelay
|
||||||
|
const communityCache = new Map<string, boolean>(); |
||||||
|
|
||||||
|
/** |
||||||
|
* Check if a pubkey has posted to the community relay |
||||||
|
*/ |
||||||
|
export async function checkCommunity(pubkey: string): Promise<boolean> { |
||||||
|
if (communityCache.has(pubkey)) { |
||||||
|
return communityCache.get(pubkey)!; |
||||||
|
} |
||||||
|
|
||||||
|
try { |
||||||
|
const relayUrl = communityRelay; |
||||||
|
const ws = new WebSocket(relayUrl); |
||||||
|
return await new Promise((resolve) => { |
||||||
|
ws.onopen = () => { |
||||||
|
ws.send(JSON.stringify([ |
||||||
|
'REQ', RELAY_CONSTANTS.COMMUNITY_REQUEST_ID, {
|
||||||
|
kinds: RELAY_CONSTANTS.COMMUNITY_REQUEST_KINDS,
|
||||||
|
authors: [pubkey],
|
||||||
|
limit: SEARCH_LIMITS.COMMUNITY_CHECK
|
||||||
|
} |
||||||
|
])); |
||||||
|
}; |
||||||
|
ws.onmessage = (event) => { |
||||||
|
const data = JSON.parse(event.data); |
||||||
|
if (data[0] === 'EVENT' && data[2]?.kind === 1) { |
||||||
|
communityCache.set(pubkey, true); |
||||||
|
ws.close(); |
||||||
|
resolve(true); |
||||||
|
} else if (data[0] === 'EOSE') { |
||||||
|
communityCache.set(pubkey, false); |
||||||
|
ws.close(); |
||||||
|
resolve(false); |
||||||
|
} |
||||||
|
}; |
||||||
|
ws.onerror = () => { |
||||||
|
communityCache.set(pubkey, false); |
||||||
|
ws.close(); |
||||||
|
resolve(false); |
||||||
|
}; |
||||||
|
}); |
||||||
|
} catch { |
||||||
|
communityCache.set(pubkey, false); |
||||||
|
return false; |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
/** |
||||||
|
* Check community status for multiple profiles |
||||||
|
*/ |
||||||
|
export async function checkCommunityStatus(profiles: Array<{ pubkey?: string }>): Promise<Record<string, boolean>> { |
||||||
|
const communityStatus: Record<string, boolean> = {}; |
||||||
|
|
||||||
|
for (const profile of profiles) { |
||||||
|
if (profile.pubkey) { |
||||||
|
communityStatus[profile.pubkey] = await checkCommunity(profile.pubkey); |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
return communityStatus; |
||||||
|
}
|
||||||
@ -0,0 +1,400 @@ |
|||||||
|
import type { NDKEvent } from './nostrUtils'; |
||||||
|
import { get } from 'svelte/store'; |
||||||
|
import { ndkInstance } from '$lib/ndk'; |
||||||
|
import { NDKEvent as NDKEventClass } from '@nostr-dev-kit/ndk'; |
||||||
|
import { EVENT_KINDS } from './search_constants'; |
||||||
|
|
||||||
|
// =========================
|
||||||
|
// Validation
|
||||||
|
// =========================
|
||||||
|
|
||||||
|
/** |
||||||
|
* Returns true if the event kind requires a d-tag (kinds 30000-39999). |
||||||
|
*/ |
||||||
|
export function requiresDTag(kind: number): boolean { |
||||||
|
return kind >= EVENT_KINDS.ADDRESSABLE.MIN && kind <= EVENT_KINDS.ADDRESSABLE.MAX; |
||||||
|
} |
||||||
|
|
||||||
|
/** |
||||||
|
* Returns true if the tags array contains at least one d-tag with a non-empty value. |
||||||
|
*/ |
||||||
|
export function hasDTag(tags: [string, string][]): boolean { |
||||||
|
return tags.some(([k, v]) => k === 'd' && v && v.trim() !== ''); |
||||||
|
} |
||||||
|
|
||||||
|
/** |
||||||
|
* Returns true if the content contains AsciiDoc headers (lines starting with '=' or '=='). |
||||||
|
*/ |
||||||
|
function containsAsciiDocHeaders(content: string): boolean { |
||||||
|
return /^={1,}\s+/m.test(content); |
||||||
|
} |
||||||
|
|
||||||
|
/** |
||||||
|
* Validates that content does NOT contain AsciiDoc headers (for kind 30023). |
||||||
|
* Returns { valid, reason }. |
||||||
|
*/ |
||||||
|
export function validateNotAsciidoc(content: string): { valid: boolean; reason?: string } { |
||||||
|
if (containsAsciiDocHeaders(content)) { |
||||||
|
return { |
||||||
|
valid: false, |
||||||
|
reason: 'Kind 30023 must not contain AsciiDoc headers (lines starting with = or ==).', |
||||||
|
}; |
||||||
|
} |
||||||
|
return { valid: true }; |
||||||
|
} |
||||||
|
|
||||||
|
/** |
||||||
|
* Validates AsciiDoc content. Must start with '=' and contain at least one '==' section header. |
||||||
|
* Returns { valid, reason }. |
||||||
|
*/ |
||||||
|
export function validateAsciiDoc(content: string): { valid: boolean; reason?: string } { |
||||||
|
if (!content.trim().startsWith('=')) { |
||||||
|
return { valid: false, reason: 'AsciiDoc must start with a document title ("=").' }; |
||||||
|
} |
||||||
|
if (!/^==\s+/m.test(content)) { |
||||||
|
return { valid: false, reason: 'AsciiDoc must contain at least one section header ("==").' }; |
||||||
|
} |
||||||
|
return { valid: true }; |
||||||
|
} |
||||||
|
|
||||||
|
/** |
||||||
|
* Validates that a 30040 event set will be created correctly. |
||||||
|
* Returns { valid, reason }. |
||||||
|
*/ |
||||||
|
export function validate30040EventSet(content: string): { valid: boolean; reason?: string } { |
||||||
|
// First validate as AsciiDoc
|
||||||
|
const asciiDocValidation = validateAsciiDoc(content); |
||||||
|
if (!asciiDocValidation.valid) { |
||||||
|
return asciiDocValidation; |
||||||
|
} |
||||||
|
|
||||||
|
// Check that we have at least one section
|
||||||
|
const sectionsResult = splitAsciiDocSections(content); |
||||||
|
if (sectionsResult.sections.length === 0) { |
||||||
|
return { valid: false, reason: '30040 events must contain at least one section.' }; |
||||||
|
} |
||||||
|
|
||||||
|
// Check that we have a document title
|
||||||
|
const documentTitle = extractAsciiDocDocumentHeader(content); |
||||||
|
if (!documentTitle) { |
||||||
|
return { valid: false, reason: '30040 events must have a document title (line starting with "=").' }; |
||||||
|
} |
||||||
|
|
||||||
|
// Check that the content will result in an empty 30040 event
|
||||||
|
// The 30040 event should have empty content, with all content split into 30041 events
|
||||||
|
if (!content.trim().startsWith('=')) { |
||||||
|
return { valid: false, reason: '30040 events must start with a document title ("=").' }; |
||||||
|
} |
||||||
|
|
||||||
|
return { valid: true }; |
||||||
|
} |
||||||
|
|
||||||
|
// =========================
|
||||||
|
// Extraction & Normalization
|
||||||
|
// =========================
|
||||||
|
|
||||||
|
/** |
||||||
|
* Normalize a string for use as a d-tag: lowercase, hyphens, alphanumeric only. |
||||||
|
*/ |
||||||
|
function normalizeDTagValue(header: string): string { |
||||||
|
return header |
||||||
|
.toLowerCase() |
||||||
|
.replace(/[^\p{L}\p{N}]+/gu, '-') |
||||||
|
.replace(/^-+|-+$/g, ''); |
||||||
|
} |
||||||
|
|
||||||
|
/** |
||||||
|
* Converts a title string to a valid d-tag (lowercase, hyphens, no punctuation). |
||||||
|
*/ |
||||||
|
export function titleToDTag(title: string): string { |
||||||
|
return title |
||||||
|
.toLowerCase() |
||||||
|
.replace(/[^a-z0-9]+/g, '-') // Replace non-alphanumeric with hyphens
|
||||||
|
.replace(/^-+|-+$/g, ''); // Trim leading/trailing hyphens
|
||||||
|
} |
||||||
|
|
||||||
|
/** |
||||||
|
* Extracts the first AsciiDoc document header (line starting with '= '). |
||||||
|
*/ |
||||||
|
function extractAsciiDocDocumentHeader(content: string): string | null { |
||||||
|
const match = content.match(/^=\s+(.+)$/m); |
||||||
|
return match ? match[1].trim() : null; |
||||||
|
} |
||||||
|
|
||||||
|
/** |
||||||
|
* Extracts all section headers (lines starting with '== '). |
||||||
|
*/ |
||||||
|
function extractAsciiDocSectionHeaders(content: string): string[] { |
||||||
|
return Array.from(content.matchAll(/^==\s+(.+)$/gm)).map(m => m[1].trim()); |
||||||
|
} |
||||||
|
|
||||||
|
/** |
||||||
|
* Extracts the topmost Markdown # header (line starting with '# '). |
||||||
|
*/ |
||||||
|
function extractMarkdownTopHeader(content: string): string | null { |
||||||
|
const match = content.match(/^#\s+(.+)$/m); |
||||||
|
return match ? match[1].trim() : null; |
||||||
|
} |
||||||
|
|
||||||
|
/** |
||||||
|
* Splits AsciiDoc content into sections at each '==' header. Returns array of section strings. |
||||||
|
* Document title (= header) is excluded from sections and only used for the index event title. |
||||||
|
* Section headers (==) are discarded from content. |
||||||
|
* Text between document header and first section becomes a "Preamble" section. |
||||||
|
*/ |
||||||
|
function splitAsciiDocSections(content: string): { sections: string[]; sectionHeaders: string[]; hasPreamble: boolean } { |
||||||
|
const lines = content.split(/\r?\n/); |
||||||
|
const sections: string[] = []; |
||||||
|
const sectionHeaders: string[] = []; |
||||||
|
let current: string[] = []; |
||||||
|
let foundFirstSection = false; |
||||||
|
let hasPreamble = false; |
||||||
|
let preambleContent: string[] = []; |
||||||
|
|
||||||
|
for (const line of lines) { |
||||||
|
// Skip document title lines (= header)
|
||||||
|
if (/^=\s+/.test(line)) { |
||||||
|
continue; |
||||||
|
} |
||||||
|
|
||||||
|
// If we encounter a section header (==) and we have content, start a new section
|
||||||
|
if (/^==\s+/.test(line)) { |
||||||
|
if (current.length > 0) { |
||||||
|
sections.push(current.join('\n').trim()); |
||||||
|
current = []; |
||||||
|
} |
||||||
|
|
||||||
|
// Extract section header for title tag
|
||||||
|
const headerMatch = line.match(/^==\s+(.+)$/); |
||||||
|
if (headerMatch) { |
||||||
|
sectionHeaders.push(headerMatch[1].trim()); |
||||||
|
} |
||||||
|
|
||||||
|
foundFirstSection = true; |
||||||
|
} else if (foundFirstSection) { |
||||||
|
// Only add lines to current section if we've found the first section
|
||||||
|
current.push(line); |
||||||
|
} else { |
||||||
|
// Text before first section becomes preamble
|
||||||
|
if (line.trim() !== '') { |
||||||
|
preambleContent.push(line); |
||||||
|
} |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
// Add the last section
|
||||||
|
if (current.length > 0) { |
||||||
|
sections.push(current.join('\n').trim()); |
||||||
|
} |
||||||
|
|
||||||
|
// Add preamble as first section if it exists
|
||||||
|
if (preambleContent.length > 0) { |
||||||
|
sections.unshift(preambleContent.join('\n').trim()); |
||||||
|
sectionHeaders.unshift('Preamble'); |
||||||
|
hasPreamble = true; |
||||||
|
} |
||||||
|
|
||||||
|
return { sections, sectionHeaders, hasPreamble }; |
||||||
|
} |
||||||
|
|
||||||
|
// =========================
|
||||||
|
// Event Construction
|
||||||
|
// =========================
|
||||||
|
|
||||||
|
/** |
||||||
|
* Returns the current NDK instance from the store. |
||||||
|
*/ |
||||||
|
function getNdk() { |
||||||
|
return get(ndkInstance); |
||||||
|
} |
||||||
|
|
||||||
|
/** |
||||||
|
* Builds a set of events for a 30040 publication: one 30040 index event and one 30041 event per section. |
||||||
|
* Each 30041 gets a d-tag (normalized section header) and a title tag (raw section header). |
||||||
|
* The 30040 index event references all 30041s by their d-tag. |
||||||
|
*/ |
||||||
|
export function build30040EventSet( |
||||||
|
content: string, |
||||||
|
tags: [string, string][], |
||||||
|
baseEvent: Partial<NDKEvent> & { pubkey: string; created_at: number } |
||||||
|
): { indexEvent: NDKEvent; sectionEvents: NDKEvent[] } { |
||||||
|
console.log('=== build30040EventSet called ==='); |
||||||
|
console.log('Input content:', content); |
||||||
|
console.log('Input tags:', tags); |
||||||
|
console.log('Input baseEvent:', baseEvent); |
||||||
|
|
||||||
|
const ndk = getNdk(); |
||||||
|
console.log('NDK instance:', ndk); |
||||||
|
|
||||||
|
const sectionsResult = splitAsciiDocSections(content); |
||||||
|
const sections = sectionsResult.sections; |
||||||
|
const sectionHeaders = sectionsResult.sectionHeaders; |
||||||
|
console.log('Sections:', sections); |
||||||
|
console.log('Section headers:', sectionHeaders); |
||||||
|
|
||||||
|
const dTags = sectionHeaders.length === sections.length |
||||||
|
? sectionHeaders.map(normalizeDTagValue) |
||||||
|
: sections.map((_, i) => `section${i}`); |
||||||
|
console.log('D tags:', dTags); |
||||||
|
|
||||||
|
const sectionEvents: NDKEvent[] = sections.map((section, i) => { |
||||||
|
const header = sectionHeaders[i] || `Section ${i + 1}`; |
||||||
|
const dTag = dTags[i]; |
||||||
|
console.log(`Creating section ${i}:`, { header, dTag, content: section }); |
||||||
|
return new NDKEventClass(ndk, { |
||||||
|
kind: 30041, |
||||||
|
content: section, |
||||||
|
tags: [ |
||||||
|
...tags, |
||||||
|
['d', dTag], |
||||||
|
['title', header], |
||||||
|
], |
||||||
|
pubkey: baseEvent.pubkey, |
||||||
|
created_at: baseEvent.created_at, |
||||||
|
}); |
||||||
|
}); |
||||||
|
|
||||||
|
// Create proper a tags with format: kind:pubkey:d-tag
|
||||||
|
const aTags = dTags.map(dTag => ['a', `30041:${baseEvent.pubkey}:${dTag}`] as [string, string]); |
||||||
|
console.log('A tags:', aTags); |
||||||
|
|
||||||
|
// Extract document title for the index event
|
||||||
|
const documentTitle = extractAsciiDocDocumentHeader(content); |
||||||
|
const indexDTag = documentTitle ? normalizeDTagValue(documentTitle) : 'index'; |
||||||
|
console.log('Index event:', { documentTitle, indexDTag }); |
||||||
|
|
||||||
|
const indexTags = [ |
||||||
|
...tags, |
||||||
|
['d', indexDTag], |
||||||
|
['title', documentTitle || 'Untitled'], |
||||||
|
...aTags, |
||||||
|
]; |
||||||
|
|
||||||
|
const indexEvent: NDKEvent = new NDKEventClass(ndk, { |
||||||
|
kind: 30040, |
||||||
|
content: '', |
||||||
|
tags: indexTags, |
||||||
|
pubkey: baseEvent.pubkey, |
||||||
|
created_at: baseEvent.created_at, |
||||||
|
}); |
||||||
|
console.log('Final index event:', indexEvent); |
||||||
|
console.log('=== build30040EventSet completed ==='); |
||||||
|
return { indexEvent, sectionEvents }; |
||||||
|
} |
||||||
|
|
||||||
|
/** |
||||||
|
* Returns the appropriate title tag for a given event kind and content. |
||||||
|
* - 30041, 30818: AsciiDoc document header (first '= ' line) |
||||||
|
* - 30023: Markdown topmost '# ' header |
||||||
|
*/ |
||||||
|
export function getTitleTagForEvent(kind: number, content: string): string | null { |
||||||
|
if (kind === 30041 || kind === 30818) { |
||||||
|
return extractAsciiDocDocumentHeader(content); |
||||||
|
} |
||||||
|
if (kind === 30023) { |
||||||
|
return extractMarkdownTopHeader(content); |
||||||
|
} |
||||||
|
return null; |
||||||
|
} |
||||||
|
|
||||||
|
/** |
||||||
|
* Returns the appropriate d-tag value for a given event kind and content. |
||||||
|
* - 30023: Normalized markdown header |
||||||
|
* - 30041, 30818: Normalized AsciiDoc document header |
||||||
|
* - 30040: Uses existing d-tag or generates from content |
||||||
|
*/ |
||||||
|
export function getDTagForEvent(kind: number, content: string, existingDTag?: string): string | null { |
||||||
|
if (existingDTag && existingDTag.trim() !== '') { |
||||||
|
return existingDTag.trim(); |
||||||
|
} |
||||||
|
|
||||||
|
if (kind === 30023) { |
||||||
|
const title = extractMarkdownTopHeader(content); |
||||||
|
return title ? normalizeDTagValue(title) : null; |
||||||
|
} |
||||||
|
|
||||||
|
if (kind === 30041 || kind === 30818) { |
||||||
|
const title = extractAsciiDocDocumentHeader(content); |
||||||
|
return title ? normalizeDTagValue(title) : null; |
||||||
|
} |
||||||
|
|
||||||
|
return null; |
||||||
|
}
|
||||||
|
|
||||||
|
/** |
||||||
|
* Returns a description of what a 30040 event structure should be. |
||||||
|
*/ |
||||||
|
export function get30040EventDescription(): string { |
||||||
|
return `30040 events are publication indexes that contain:
|
||||||
|
- Empty content (metadata only) |
||||||
|
- A d-tag for the publication identifier |
||||||
|
- A title tag for the publication title |
||||||
|
- A tags referencing 30041 content events (one per section) |
||||||
|
|
||||||
|
The content is split into sections, each published as a separate 30041 event.`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** |
||||||
|
* Analyzes a 30040 event to determine if it was created correctly. |
||||||
|
* Returns { valid, issues } where issues is an array of problems found. |
||||||
|
*/ |
||||||
|
export function analyze30040Event(event: { content: string; tags: [string, string][]; kind: number }): { valid: boolean; issues: string[] } { |
||||||
|
const issues: string[] = []; |
||||||
|
|
||||||
|
// Check if it's actually a 30040 event
|
||||||
|
if (event.kind !== 30040) { |
||||||
|
issues.push('Event is not kind 30040'); |
||||||
|
return { valid: false, issues }; |
||||||
|
} |
||||||
|
|
||||||
|
// Check if content is empty (30040 should be metadata only)
|
||||||
|
if (event.content && event.content.trim() !== '') { |
||||||
|
issues.push('30040 events should have empty content (metadata only)'); |
||||||
|
issues.push('Content should be split into separate 30041 events'); |
||||||
|
} |
||||||
|
|
||||||
|
// Check for required tags
|
||||||
|
const hasTitle = event.tags.some(([k, v]) => k === 'title' && v); |
||||||
|
const hasDTag = event.tags.some(([k, v]) => k === 'd' && v); |
||||||
|
const hasATags = event.tags.some(([k, v]) => k === 'a' && v); |
||||||
|
|
||||||
|
if (!hasTitle) { |
||||||
|
issues.push('Missing title tag'); |
||||||
|
} |
||||||
|
if (!hasDTag) { |
||||||
|
issues.push('Missing d tag'); |
||||||
|
} |
||||||
|
if (!hasATags) { |
||||||
|
issues.push('Missing a tags (should reference 30041 content events)'); |
||||||
|
} |
||||||
|
|
||||||
|
// Check if a tags have the correct format (kind:pubkey:d-tag)
|
||||||
|
const aTags = event.tags.filter(([k, v]) => k === 'a' && v); |
||||||
|
for (const [, value] of aTags) { |
||||||
|
if (!value.includes(':')) { |
||||||
|
issues.push(`Invalid a tag format: ${value} (should be "kind:pubkey:d-tag")`); |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
return { valid: issues.length === 0, issues }; |
||||||
|
}
|
||||||
|
|
||||||
|
/** |
||||||
|
* Returns guidance on how to fix incorrect 30040 events. |
||||||
|
*/ |
||||||
|
export function get30040FixGuidance(): string { |
||||||
|
return `To fix a 30040 event:
|
||||||
|
|
||||||
|
1. **Content Issue**: 30040 events should have empty content. All content should be split into separate 30041 events. |
||||||
|
|
||||||
|
2. **Structure**: A proper 30040 event should contain: |
||||||
|
- Empty content |
||||||
|
- d tag: publication identifier |
||||||
|
- title tag: publication title |
||||||
|
- a tags: references to 30041 content events (format: "30041:pubkey:d-tag") |
||||||
|
|
||||||
|
3. **Process**: When creating a 30040 event: |
||||||
|
- Write your content with document title (= Title) and sections (== Section) |
||||||
|
- The system will automatically split it into one 30040 index event and multiple 30041 content events |
||||||
|
- The 30040 will have empty content and reference the 30041s via a tags`;
|
||||||
|
}
|
||||||
@ -0,0 +1,143 @@ |
|||||||
|
import { ndkInstance } from '$lib/ndk'; |
||||||
|
import { fetchEventWithFallback } from '$lib/utils/nostrUtils'; |
||||||
|
import { nip19 } from '$lib/utils/nostrUtils'; |
||||||
|
import { NDKEvent } from '@nostr-dev-kit/ndk'; |
||||||
|
import { get } from 'svelte/store'; |
||||||
|
import { wellKnownUrl, isValidNip05Address } from './search_utils'; |
||||||
|
import { TIMEOUTS, VALIDATION } from './search_constants'; |
||||||
|
|
||||||
|
/** |
||||||
|
* Search for a single event by ID or filter |
||||||
|
*/ |
||||||
|
export async function searchEvent(query: string): Promise<NDKEvent | null> { |
||||||
|
// Clean the query and normalize to lowercase
|
||||||
|
let cleanedQuery = query.replace(/^nostr:/, "").toLowerCase(); |
||||||
|
let filterOrId: any = cleanedQuery; |
||||||
|
|
||||||
|
// If it's a valid hex string, try as event id first, then as pubkey (profile)
|
||||||
|
if (new RegExp(`^[a-f0-9]{${VALIDATION.HEX_LENGTH}}$`, 'i').test(cleanedQuery)) { |
||||||
|
// Try as event id
|
||||||
|
filterOrId = cleanedQuery; |
||||||
|
const eventResult = await fetchEventWithFallback( |
||||||
|
get(ndkInstance), |
||||||
|
filterOrId, |
||||||
|
TIMEOUTS.EVENT_FETCH, |
||||||
|
); |
||||||
|
// Always try as pubkey (profile event) as well
|
||||||
|
const profileFilter = { kinds: [0], authors: [cleanedQuery] }; |
||||||
|
const profileEvent = await fetchEventWithFallback( |
||||||
|
get(ndkInstance), |
||||||
|
profileFilter, |
||||||
|
TIMEOUTS.EVENT_FETCH, |
||||||
|
); |
||||||
|
// Prefer profile if found and pubkey matches query
|
||||||
|
if ( |
||||||
|
profileEvent && |
||||||
|
profileEvent.pubkey.toLowerCase() === cleanedQuery.toLowerCase() |
||||||
|
) { |
||||||
|
return profileEvent; |
||||||
|
} else if (eventResult) { |
||||||
|
return eventResult; |
||||||
|
} |
||||||
|
} else if ( |
||||||
|
new RegExp(`^(nevent|note|naddr|npub|nprofile)[a-z0-9]{${VALIDATION.MIN_NOSTR_IDENTIFIER_LENGTH},}$`, 'i').test(cleanedQuery) |
||||||
|
) { |
||||||
|
try { |
||||||
|
const decoded = nip19.decode(cleanedQuery); |
||||||
|
if (!decoded) throw new Error("Invalid identifier"); |
||||||
|
switch (decoded.type) { |
||||||
|
case "nevent": |
||||||
|
filterOrId = decoded.data.id; |
||||||
|
break; |
||||||
|
case "note": |
||||||
|
filterOrId = decoded.data; |
||||||
|
break; |
||||||
|
case "naddr": |
||||||
|
filterOrId = { |
||||||
|
kinds: [decoded.data.kind], |
||||||
|
authors: [decoded.data.pubkey], |
||||||
|
"#d": [decoded.data.identifier], |
||||||
|
}; |
||||||
|
break; |
||||||
|
case "nprofile": |
||||||
|
filterOrId = { |
||||||
|
kinds: [0], |
||||||
|
authors: [decoded.data.pubkey], |
||||||
|
}; |
||||||
|
break; |
||||||
|
case "npub": |
||||||
|
filterOrId = { |
||||||
|
kinds: [0], |
||||||
|
authors: [decoded.data], |
||||||
|
}; |
||||||
|
break; |
||||||
|
default: |
||||||
|
filterOrId = cleanedQuery; |
||||||
|
} |
||||||
|
} catch (e) { |
||||||
|
console.error("[Search] Invalid Nostr identifier:", cleanedQuery, e); |
||||||
|
throw new Error("Invalid Nostr identifier."); |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
try { |
||||||
|
const event = await fetchEventWithFallback( |
||||||
|
get(ndkInstance), |
||||||
|
filterOrId, |
||||||
|
TIMEOUTS.EVENT_FETCH, |
||||||
|
); |
||||||
|
|
||||||
|
if (!event) { |
||||||
|
console.warn("[Search] Event not found for filterOrId:", filterOrId); |
||||||
|
return null; |
||||||
|
} else { |
||||||
|
return event; |
||||||
|
} |
||||||
|
} catch (err) { |
||||||
|
console.error("[Search] Error fetching event:", err, "Query:", query); |
||||||
|
throw new Error("Error fetching event. Please check the ID and try again."); |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
/** |
||||||
|
* Search for NIP-05 address |
||||||
|
*/ |
||||||
|
export async function searchNip05(nip05Address: string): Promise<NDKEvent | null> { |
||||||
|
// NIP-05 address pattern: user@domain
|
||||||
|
if (!isValidNip05Address(nip05Address)) { |
||||||
|
throw new Error("Invalid NIP-05 address format. Expected: user@domain"); |
||||||
|
} |
||||||
|
|
||||||
|
try { |
||||||
|
const [name, domain] = nip05Address.split("@"); |
||||||
|
|
||||||
|
const res = await fetch(wellKnownUrl(domain, name)); |
||||||
|
|
||||||
|
if (!res.ok) { |
||||||
|
throw new Error(`HTTP ${res.status}: ${res.statusText}`); |
||||||
|
} |
||||||
|
|
||||||
|
const data = await res.json(); |
||||||
|
|
||||||
|
const pubkey = data.names?.[name]; |
||||||
|
if (pubkey) { |
||||||
|
const profileFilter = { kinds: [0], authors: [pubkey] }; |
||||||
|
const profileEvent = await fetchEventWithFallback( |
||||||
|
get(ndkInstance), |
||||||
|
profileFilter, |
||||||
|
TIMEOUTS.EVENT_FETCH, |
||||||
|
); |
||||||
|
if (profileEvent) { |
||||||
|
return profileEvent; |
||||||
|
} else { |
||||||
|
throw new Error(`No profile found for ${name}@${domain} (pubkey: ${pubkey})`); |
||||||
|
} |
||||||
|
} else { |
||||||
|
throw new Error(`NIP-05 address not found: ${name}@${domain}`); |
||||||
|
} |
||||||
|
} catch (e) { |
||||||
|
console.error(`[Search] Error resolving NIP-05 address ${nip05Address}:`, e); |
||||||
|
const errorMessage = e instanceof Error ? e.message : String(e); |
||||||
|
throw new Error(`Error resolving NIP-05 address: ${errorMessage}`); |
||||||
|
} |
||||||
|
}
|
||||||
@ -0,0 +1,132 @@ |
|||||||
|
import type { NDKEvent } from "./nostrUtils"; |
||||||
|
import { CACHE_DURATIONS, TIMEOUTS } from './search_constants'; |
||||||
|
|
||||||
|
export interface IndexEventCacheEntry { |
||||||
|
events: NDKEvent[]; |
||||||
|
timestamp: number; |
||||||
|
relayUrls: string[]; |
||||||
|
} |
||||||
|
|
||||||
|
class IndexEventCache { |
||||||
|
private cache: Map<string, IndexEventCacheEntry> = new Map(); |
||||||
|
private readonly CACHE_DURATION = CACHE_DURATIONS.INDEX_EVENT_CACHE; |
||||||
|
private readonly MAX_CACHE_SIZE = 50; // Maximum number of cached relay combinations
|
||||||
|
|
||||||
|
/** |
||||||
|
* Generate a cache key based on relay URLs |
||||||
|
*/ |
||||||
|
private generateKey(relayUrls: string[]): string { |
||||||
|
return relayUrls.sort().join('|'); |
||||||
|
} |
||||||
|
|
||||||
|
/** |
||||||
|
* Check if a cached entry is still valid |
||||||
|
*/ |
||||||
|
private isExpired(entry: IndexEventCacheEntry): boolean { |
||||||
|
return Date.now() - entry.timestamp > this.CACHE_DURATION; |
||||||
|
} |
||||||
|
|
||||||
|
/** |
||||||
|
* Get cached index events for a set of relays |
||||||
|
*/ |
||||||
|
get(relayUrls: string[]): NDKEvent[] | null { |
||||||
|
const key = this.generateKey(relayUrls); |
||||||
|
const entry = this.cache.get(key); |
||||||
|
|
||||||
|
if (!entry || this.isExpired(entry)) { |
||||||
|
if (entry) { |
||||||
|
this.cache.delete(key); |
||||||
|
} |
||||||
|
return null; |
||||||
|
} |
||||||
|
|
||||||
|
console.log(`[IndexEventCache] Using cached index events for ${relayUrls.length} relays`); |
||||||
|
return entry.events; |
||||||
|
} |
||||||
|
|
||||||
|
/** |
||||||
|
* Store index events in cache |
||||||
|
*/ |
||||||
|
set(relayUrls: string[], events: NDKEvent[]): void { |
||||||
|
const key = this.generateKey(relayUrls); |
||||||
|
|
||||||
|
// Implement LRU eviction if cache is full
|
||||||
|
if (this.cache.size >= this.MAX_CACHE_SIZE) { |
||||||
|
const oldestKey = this.cache.keys().next().value; |
||||||
|
if (oldestKey) { |
||||||
|
this.cache.delete(oldestKey); |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
this.cache.set(key, { |
||||||
|
events, |
||||||
|
timestamp: Date.now(), |
||||||
|
relayUrls: [...relayUrls] |
||||||
|
}); |
||||||
|
|
||||||
|
console.log(`[IndexEventCache] Cached ${events.length} index events for ${relayUrls.length} relays`); |
||||||
|
} |
||||||
|
|
||||||
|
/** |
||||||
|
* Check if index events are cached for a set of relays |
||||||
|
*/ |
||||||
|
has(relayUrls: string[]): boolean { |
||||||
|
const key = this.generateKey(relayUrls); |
||||||
|
const entry = this.cache.get(key); |
||||||
|
return entry !== undefined && !this.isExpired(entry); |
||||||
|
} |
||||||
|
|
||||||
|
/** |
||||||
|
* Clear expired entries from cache |
||||||
|
*/ |
||||||
|
cleanup(): void { |
||||||
|
const now = Date.now(); |
||||||
|
for (const [key, entry] of this.cache.entries()) { |
||||||
|
if (this.isExpired(entry)) { |
||||||
|
this.cache.delete(key); |
||||||
|
} |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
/** |
||||||
|
* Clear all cache entries |
||||||
|
*/ |
||||||
|
clear(): void { |
||||||
|
this.cache.clear(); |
||||||
|
} |
||||||
|
|
||||||
|
/** |
||||||
|
* Get cache size |
||||||
|
*/ |
||||||
|
size(): number { |
||||||
|
return this.cache.size; |
||||||
|
} |
||||||
|
|
||||||
|
/** |
||||||
|
* Get cache statistics |
||||||
|
*/ |
||||||
|
getStats(): { size: number; totalEvents: number; oldestEntry: number | null } { |
||||||
|
let totalEvents = 0; |
||||||
|
let oldestTimestamp: number | null = null; |
||||||
|
|
||||||
|
for (const entry of this.cache.values()) { |
||||||
|
totalEvents += entry.events.length; |
||||||
|
if (oldestTimestamp === null || entry.timestamp < oldestTimestamp) { |
||||||
|
oldestTimestamp = entry.timestamp; |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
return { |
||||||
|
size: this.cache.size, |
||||||
|
totalEvents, |
||||||
|
oldestEntry: oldestTimestamp |
||||||
|
}; |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
export const indexEventCache = new IndexEventCache(); |
||||||
|
|
||||||
|
// Clean up expired entries periodically
|
||||||
|
setInterval(() => { |
||||||
|
indexEventCache.cleanup(); |
||||||
|
}, TIMEOUTS.CACHE_CLEANUP); // Check every minute
|
||||||
@ -0,0 +1,371 @@ |
|||||||
|
import { postProcessAsciidoctorHtml } from "./asciidoctorPostProcessor"; |
||||||
|
import plantumlEncoder from "plantuml-encoder"; |
||||||
|
|
||||||
|
/** |
||||||
|
* Unified post-processor for Asciidoctor HTML that handles: |
||||||
|
* - Math rendering (Asciimath/Latex, stem blocks) |
||||||
|
* - PlantUML diagrams |
||||||
|
* - BPMN diagrams |
||||||
|
* - TikZ diagrams |
||||||
|
*/ |
||||||
|
export async function postProcessAdvancedAsciidoctorHtml( |
||||||
|
html: string, |
||||||
|
): Promise<string> { |
||||||
|
if (!html) return html; |
||||||
|
try { |
||||||
|
// First apply the basic post-processing (wikilinks, nostr addresses)
|
||||||
|
let processedHtml = await postProcessAsciidoctorHtml(html); |
||||||
|
// Unified math block processing
|
||||||
|
processedHtml = fixAllMathBlocks(processedHtml); |
||||||
|
// Process PlantUML blocks
|
||||||
|
processedHtml = processPlantUMLBlocks(processedHtml); |
||||||
|
// Process BPMN blocks
|
||||||
|
processedHtml = processBPMNBlocks(processedHtml); |
||||||
|
// Process TikZ blocks
|
||||||
|
processedHtml = processTikZBlocks(processedHtml); |
||||||
|
// After all processing, apply highlight.js if available
|
||||||
|
if ( |
||||||
|
typeof window !== "undefined" && |
||||||
|
typeof window.hljs?.highlightAll === "function" |
||||||
|
) { |
||||||
|
setTimeout(() => window.hljs!.highlightAll(), 0); |
||||||
|
} |
||||||
|
if ( |
||||||
|
typeof window !== "undefined" && |
||||||
|
typeof (window as any).MathJax?.typesetPromise === "function" |
||||||
|
) { |
||||||
|
setTimeout(() => (window as any).MathJax.typesetPromise(), 0); |
||||||
|
} |
||||||
|
return processedHtml; |
||||||
|
} catch (error) { |
||||||
|
console.error("Error in postProcessAdvancedAsciidoctorHtml:", error); |
||||||
|
return html; // Return original HTML if processing fails
|
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
/** |
||||||
|
* Fixes all math blocks for MathJax rendering. |
||||||
|
* Now only processes LaTeX within inline code blocks. |
||||||
|
*/ |
||||||
|
function fixAllMathBlocks(html: string): string { |
||||||
|
// Unescape \$ to $ for math delimiters
|
||||||
|
html = html.replace(/\\\$/g, "$"); |
||||||
|
|
||||||
|
// Process inline code blocks that contain LaTeX
|
||||||
|
html = html.replace( |
||||||
|
/<code[^>]*class="[^"]*language-[^"]*"[^>]*>([\s\S]*?)<\/code>/g, |
||||||
|
(match, codeContent) => { |
||||||
|
const trimmedCode = codeContent.trim(); |
||||||
|
if (isLaTeXContent(trimmedCode)) { |
||||||
|
return `<span class="math-inline">$${trimmedCode}$</span>`; |
||||||
|
} |
||||||
|
return match; // Return original if not LaTeX
|
||||||
|
} |
||||||
|
); |
||||||
|
|
||||||
|
// Also process code blocks without language class
|
||||||
|
html = html.replace( |
||||||
|
/<code[^>]*>([\s\S]*?)<\/code>/g, |
||||||
|
(match, codeContent) => { |
||||||
|
const trimmedCode = codeContent.trim(); |
||||||
|
if (isLaTeXContent(trimmedCode)) { |
||||||
|
return `<span class="math-inline">$${trimmedCode}$</span>`; |
||||||
|
} |
||||||
|
return match; // Return original if not LaTeX
|
||||||
|
} |
||||||
|
); |
||||||
|
|
||||||
|
return html; |
||||||
|
} |
||||||
|
|
||||||
|
/** |
||||||
|
* Checks if content contains LaTeX syntax |
||||||
|
*/ |
||||||
|
function isLaTeXContent(content: string): boolean { |
||||||
|
const trimmed = content.trim(); |
||||||
|
|
||||||
|
// Check for common LaTeX patterns
|
||||||
|
const latexPatterns = [ |
||||||
|
/\\[a-zA-Z]+/, // LaTeX commands like \frac, \sum, etc.
|
||||||
|
/\\[\(\)\[\]]/, // LaTeX delimiters like \(, \), \[, \]
|
||||||
|
/\\begin\{/, // LaTeX environments
|
||||||
|
/\\end\{/, // LaTeX environments
|
||||||
|
/\$\$/, // Display math delimiters
|
||||||
|
/\$[^$]+\$/, // Inline math delimiters
|
||||||
|
/\\text\{/, // LaTeX text command
|
||||||
|
/\\mathrm\{/, // LaTeX mathrm command
|
||||||
|
/\\mathbf\{/, // LaTeX bold command
|
||||||
|
/\\mathit\{/, // LaTeX italic command
|
||||||
|
/\\sqrt/, // Square root
|
||||||
|
/\\frac/, // Fraction
|
||||||
|
/\\sum/, // Sum
|
||||||
|
/\\int/, // Integral
|
||||||
|
/\\lim/, // Limit
|
||||||
|
/\\infty/, // Infinity
|
||||||
|
/\\alpha/, // Greek letters
|
||||||
|
/\\beta/, |
||||||
|
/\\gamma/, |
||||||
|
/\\delta/, |
||||||
|
/\\theta/, |
||||||
|
/\\lambda/, |
||||||
|
/\\mu/, |
||||||
|
/\\pi/, |
||||||
|
/\\sigma/, |
||||||
|
/\\phi/, |
||||||
|
/\\omega/, |
||||||
|
/\\partial/, // Partial derivative
|
||||||
|
/\\nabla/, // Nabla
|
||||||
|
/\\cdot/, // Dot product
|
||||||
|
/\\times/, // Times
|
||||||
|
/\\div/, // Division
|
||||||
|
/\\pm/, // Plus-minus
|
||||||
|
/\\mp/, // Minus-plus
|
||||||
|
/\\leq/, // Less than or equal
|
||||||
|
/\\geq/, // Greater than or equal
|
||||||
|
/\\neq/, // Not equal
|
||||||
|
/\\approx/, // Approximately equal
|
||||||
|
/\\equiv/, // Equivalent
|
||||||
|
/\\propto/, // Proportional
|
||||||
|
/\\in/, // Element of
|
||||||
|
/\\notin/, // Not element of
|
||||||
|
/\\subset/, // Subset
|
||||||
|
/\\supset/, // Superset
|
||||||
|
/\\cup/, // Union
|
||||||
|
/\\cap/, // Intersection
|
||||||
|
/\\emptyset/, // Empty set
|
||||||
|
/\\mathbb\{/, // Blackboard bold
|
||||||
|
/\\mathcal\{/, // Calligraphic
|
||||||
|
/\\mathfrak\{/, // Fraktur
|
||||||
|
/\\mathscr\{/, // Script
|
||||||
|
]; |
||||||
|
|
||||||
|
return latexPatterns.some(pattern => pattern.test(trimmed)); |
||||||
|
} |
||||||
|
|
||||||
|
/** |
||||||
|
* Processes PlantUML blocks in HTML content |
||||||
|
*/ |
||||||
|
function processPlantUMLBlocks(html: string): string { |
||||||
|
// Only match code blocks with class 'language-plantuml' or 'plantuml'
|
||||||
|
html = html.replace( |
||||||
|
/<div class="listingblock">\s*<div class="content">\s*<pre class="highlight">\s*<code[^>]*class="[^"]*(?:language-plantuml|plantuml)[^"]*"[^>]*>([\s\S]*?)<\/code>\s*<\/pre>\s*<\/div>\s*<\/div>/g, |
||||||
|
(match, content) => { |
||||||
|
try { |
||||||
|
// Unescape HTML for PlantUML server, but escape for <code>
|
||||||
|
const rawContent = decodeHTMLEntities(content); |
||||||
|
const encoded = plantumlEncoder.encode(rawContent); |
||||||
|
const plantUMLUrl = `https://www.plantuml.com/plantuml/svg/${encoded}`; |
||||||
|
return `<div class="plantuml-block my-4">
|
||||||
|
<img src="${plantUMLUrl}" alt="PlantUML diagram"
|
||||||
|
class="plantuml-diagram max-w-full h-auto rounded-lg shadow-lg"
|
||||||
|
loading="lazy" decoding="async"> |
||||||
|
<details class="mt-2"> |
||||||
|
<summary class="cursor-pointer text-sm text-gray-600 dark:text-gray-400"> |
||||||
|
Show PlantUML source |
||||||
|
</summary> |
||||||
|
<pre class="mt-2 p-2 bg-gray-100 dark:bg-gray-900 rounded text-xs overflow-x-auto"> |
||||||
|
<code>${escapeHtml(rawContent)}</code> |
||||||
|
</pre> |
||||||
|
</details> |
||||||
|
</div>`;
|
||||||
|
} catch (error) { |
||||||
|
console.warn("Failed to process PlantUML block:", error); |
||||||
|
return match; |
||||||
|
} |
||||||
|
}, |
||||||
|
); |
||||||
|
// Fallback: match <pre> blocks whose content starts with @startuml or @start (global, robust)
|
||||||
|
html = html.replace( |
||||||
|
/<div class="listingblock">\s*<div class="content">\s*<pre>([\s\S]*?)<\/pre>\s*<\/div>\s*<\/div>/g, |
||||||
|
(match, content) => { |
||||||
|
const lines = content.trim().split("\n"); |
||||||
|
if ( |
||||||
|
lines[0].trim().startsWith("@startuml") || |
||||||
|
lines[0].trim().startsWith("@start") |
||||||
|
) { |
||||||
|
try { |
||||||
|
const rawContent = decodeHTMLEntities(content); |
||||||
|
const encoded = plantumlEncoder.encode(rawContent); |
||||||
|
const plantUMLUrl = `https://www.plantuml.com/plantuml/svg/${encoded}`; |
||||||
|
return `<div class="plantuml-block my-4">
|
||||||
|
<img src="${plantUMLUrl}" alt="PlantUML diagram"
|
||||||
|
class="plantuml-diagram max-w-full h-auto rounded-lg shadow-lg"
|
||||||
|
loading="lazy" decoding="async"> |
||||||
|
<details class="mt-2"> |
||||||
|
<summary class="cursor-pointer text-sm text-gray-600 dark:text-gray-400"> |
||||||
|
Show PlantUML source |
||||||
|
</summary> |
||||||
|
<pre class="mt-2 p-2 bg-gray-100 dark:bg-gray-900 rounded text-xs overflow-x-auto"> |
||||||
|
<code>${escapeHtml(rawContent)}</code> |
||||||
|
</pre> |
||||||
|
</details> |
||||||
|
</div>`;
|
||||||
|
} catch (error) { |
||||||
|
console.warn("Failed to process PlantUML fallback block:", error); |
||||||
|
return match; |
||||||
|
} |
||||||
|
} |
||||||
|
return match; |
||||||
|
}, |
||||||
|
); |
||||||
|
return html; |
||||||
|
} |
||||||
|
|
||||||
|
function decodeHTMLEntities(text: string): string { |
||||||
|
const textarea = document.createElement("textarea"); |
||||||
|
textarea.innerHTML = text; |
||||||
|
return textarea.value; |
||||||
|
} |
||||||
|
|
||||||
|
/** |
||||||
|
* Processes BPMN blocks in HTML content |
||||||
|
*/ |
||||||
|
function processBPMNBlocks(html: string): string { |
||||||
|
// Only match code blocks with class 'language-bpmn' or 'bpmn'
|
||||||
|
html = html.replace( |
||||||
|
/<div class="listingblock">\s*<div class="content">\s*<pre class="highlight">\s*<code[^>]*class="[^"]*(?:language-bpmn|bpmn)[^\"]*"[^>]*>([\s\S]*?)<\/code>\s*<\/pre>\s*<\/div>\s*<\/div>/g, |
||||||
|
(match, content) => { |
||||||
|
try { |
||||||
|
return `<div class="bpmn-block my-4">
|
||||||
|
<div class="bpmn-diagram p-4 bg-blue-50 dark:bg-blue-900 rounded-lg border border-blue-200 dark:border-blue-700"> |
||||||
|
<div class="text-center text-blue-600 dark:text-blue-400 mb-2"> |
||||||
|
<svg class="inline w-6 h-6 mr-2" fill="currentColor" viewBox="0 0 20 20"> |
||||||
|
<path d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z"/> |
||||||
|
</svg> |
||||||
|
BPMN Diagram |
||||||
|
</div> |
||||||
|
<details class="mt-2"> |
||||||
|
<summary class="cursor-pointer text-sm text-gray-600 dark:text-gray-400"> |
||||||
|
Show BPMN source |
||||||
|
</summary> |
||||||
|
<pre class="mt-2 p-2 bg-gray-100 dark:bg-gray-900 rounded text-xs overflow-x-auto"> |
||||||
|
<code>${escapeHtml(content)}</code> |
||||||
|
</pre> |
||||||
|
</details> |
||||||
|
</div> |
||||||
|
</div>`;
|
||||||
|
} catch (error) { |
||||||
|
console.warn("Failed to process BPMN block:", error); |
||||||
|
return match; |
||||||
|
} |
||||||
|
}, |
||||||
|
); |
||||||
|
// Fallback: match <pre> blocks whose content contains 'bpmn:' or '<?xml' and 'bpmn'
|
||||||
|
html = html.replace( |
||||||
|
/<div class="listingblock">\s*<div class="content">\s*<pre>([\s\S]*?)<\/pre>\s*<\/div>\s*<\/div>/g, |
||||||
|
(match, content) => { |
||||||
|
const text = content.trim(); |
||||||
|
if ( |
||||||
|
text.includes("bpmn:") || |
||||||
|
(text.startsWith("<?xml") && text.includes("bpmn")) |
||||||
|
) { |
||||||
|
try { |
||||||
|
return `<div class="bpmn-block my-4">
|
||||||
|
<div class="bpmn-diagram p-4 bg-blue-50 dark:bg-blue-900 rounded-lg border border-blue-200 dark:border-blue-700"> |
||||||
|
<div class="text-center text-blue-600 dark:text-blue-400 mb-2"> |
||||||
|
<svg class="inline w-6 h-6 mr-2" fill="currentColor" viewBox="0 0 20 20"> |
||||||
|
<path d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z"/> |
||||||
|
</svg> |
||||||
|
BPMN Diagram |
||||||
|
</div> |
||||||
|
<details class="mt-2"> |
||||||
|
<summary class="cursor-pointer text-sm text-gray-600 dark:text-gray-400"> |
||||||
|
Show BPMN source |
||||||
|
</summary> |
||||||
|
<pre class="mt-2 p-2 bg-gray-100 dark:bg-gray-900 rounded text-xs overflow-x-auto"> |
||||||
|
<code>${escapeHtml(content)}</code> |
||||||
|
</pre> |
||||||
|
</details> |
||||||
|
</div> |
||||||
|
</div>`;
|
||||||
|
} catch (error) { |
||||||
|
console.warn("Failed to process BPMN fallback block:", error); |
||||||
|
return match; |
||||||
|
} |
||||||
|
} |
||||||
|
return match; |
||||||
|
}, |
||||||
|
); |
||||||
|
return html; |
||||||
|
} |
||||||
|
|
||||||
|
/** |
||||||
|
* Processes TikZ blocks in HTML content |
||||||
|
*/ |
||||||
|
function processTikZBlocks(html: string): string { |
||||||
|
// Only match code blocks with class 'language-tikz' or 'tikz'
|
||||||
|
html = html.replace( |
||||||
|
/<div class="listingblock">\s*<div class="content">\s*<pre class="highlight">\s*<code[^>]*class="[^"]*(?:language-tikz|tikz)[^"]*"[^>]*>([\s\S]*?)<\/code>\s*<\/pre>\s*<\/div>\s*<\/div>/g, |
||||||
|
(match, content) => { |
||||||
|
try { |
||||||
|
return `<div class="tikz-block my-4">
|
||||||
|
<div class="tikz-diagram p-4 bg-green-50 dark:bg-green-900 rounded-lg border border-green-200 dark:border-green-700"> |
||||||
|
<div class="text-center text-green-600 dark:text-green-400 mb-2"> |
||||||
|
<svg class="inline w-6 h-6 mr-2" fill="currentColor" viewBox="0 0 20 20"> |
||||||
|
<path d="M3 4a1 1 0 011-1h12a1 1 0 011 1v2a1 1 0 01-1 1H4a1 1 0 01-1-1V4zM3 10a1 1 0 011-1h6a1 1 0 011 1v6a1 1 0 01-1 1H4a1 1 0 01-1-1v-6zM14 9a1 1 0 00-1 1v6a1 1 0 001 1h2a1 1 0 001-1v-6a1 1 0 00-1-1h-2z"/> |
||||||
|
</svg> |
||||||
|
TikZ Diagram |
||||||
|
</div> |
||||||
|
<details class="mt-2"> |
||||||
|
<summary class="cursor-pointer text-sm text-gray-600 dark:text-gray-400"> |
||||||
|
Show TikZ source |
||||||
|
</summary> |
||||||
|
<pre class="mt-2 p-2 bg-gray-100 dark:bg-gray-900 rounded text-xs overflow-x-auto"> |
||||||
|
<code>${escapeHtml(content)}</code> |
||||||
|
</pre> |
||||||
|
</details> |
||||||
|
</div> |
||||||
|
</div>`;
|
||||||
|
} catch (error) { |
||||||
|
console.warn("Failed to process TikZ block:", error); |
||||||
|
return match; |
||||||
|
} |
||||||
|
}, |
||||||
|
); |
||||||
|
// Fallback: match <pre> blocks whose content starts with \begin{tikzpicture} or contains tikz
|
||||||
|
html = html.replace( |
||||||
|
/<div class="listingblock">\s*<div class="content">\s*<pre>([\s\S]*?)<\/pre>\s*<\/div>\s*<\/div>/g, |
||||||
|
(match, content) => { |
||||||
|
const lines = content.trim().split("\n"); |
||||||
|
if ( |
||||||
|
lines[0].trim().startsWith("\\begin{tikzpicture}") || |
||||||
|
content.includes("tikz") |
||||||
|
) { |
||||||
|
try { |
||||||
|
return `<div class="tikz-block my-4">
|
||||||
|
<div class="tikz-diagram p-4 bg-green-50 dark:bg-green-900 rounded-lg border border-green-200 dark:border-green-700"> |
||||||
|
<div class="text-center text-green-600 dark:text-green-400 mb-2"> |
||||||
|
<svg class="inline w-6 h-6 mr-2" fill="currentColor" viewBox="0 0 20 20"> |
||||||
|
<path d="M3 4a1 1 0 011-1h12a1 1 0 011 1v2a1 1 0 01-1 1H4a1 1 0 01-1-1V4zM3 10a1 1 0 011-1h6a1 1 0 011 1v6a1 1 0 01-1 1H4a1 1 0 01-1-1v-6zM14 9a1 1 0 00-1 1v6a1 1 0 001 1h2a1 1 0 001-1v-6a1 1 0 00-1-1h-2z"/> |
||||||
|
</svg> |
||||||
|
TikZ Diagram |
||||||
|
</div> |
||||||
|
<details class="mt-2"> |
||||||
|
<summary class="cursor-pointer text-sm text-gray-600 dark:text-gray-400"> |
||||||
|
Show TikZ source |
||||||
|
</summary> |
||||||
|
<pre class="mt-2 p-2 bg-gray-100 dark:bg-gray-900 rounded text-xs overflow-x-auto"> |
||||||
|
<code>${escapeHtml(content)}</code> |
||||||
|
</pre> |
||||||
|
</details> |
||||||
|
</div> |
||||||
|
</div>`;
|
||||||
|
} catch (error) { |
||||||
|
console.warn("Failed to process TikZ fallback block:", error); |
||||||
|
return match; |
||||||
|
} |
||||||
|
} |
||||||
|
return match; |
||||||
|
}, |
||||||
|
); |
||||||
|
return html; |
||||||
|
} |
||||||
|
|
||||||
|
/** |
||||||
|
* Escapes HTML characters for safe display |
||||||
|
*/ |
||||||
|
function escapeHtml(text: string): string { |
||||||
|
const div = document.createElement("div"); |
||||||
|
div.textContent = text; |
||||||
|
return div.innerHTML; |
||||||
|
} |
||||||
@ -0,0 +1,213 @@ |
|||||||
|
import { renderTikZ } from "./tikzRenderer"; |
||||||
|
import asciidoctor from "asciidoctor"; |
||||||
|
|
||||||
|
// Simple math rendering using MathJax CDN
|
||||||
|
function renderMath(content: string): string { |
||||||
|
return `<div class="math-block" data-math="${encodeURIComponent(content)}">
|
||||||
|
<div class="math-content">${content}</div> |
||||||
|
<script> |
||||||
|
if (typeof MathJax !== 'undefined') { |
||||||
|
MathJax.typesetPromise([document.querySelector('.math-content')]); |
||||||
|
} |
||||||
|
</script> |
||||||
|
</div>`;
|
||||||
|
} |
||||||
|
|
||||||
|
// Simple PlantUML rendering using PlantUML server
|
||||||
|
function renderPlantUML(content: string): string { |
||||||
|
// Encode content for PlantUML server
|
||||||
|
const encoded = btoa(unescape(encodeURIComponent(content))); |
||||||
|
const plantUMLUrl = `https://www.plantuml.com/plantuml/svg/${encoded}`; |
||||||
|
|
||||||
|
return `<img src="${plantUMLUrl}" alt="PlantUML diagram" class="plantuml-diagram max-w-full h-auto rounded-lg shadow-lg my-4" loading="lazy" decoding="async">`; |
||||||
|
} |
||||||
|
|
||||||
|
/** |
||||||
|
* Creates Asciidoctor extensions for advanced content rendering |
||||||
|
* including Asciimath/Latex, PlantUML, BPMN, and TikZ |
||||||
|
*/ |
||||||
|
export function createAdvancedExtensions(): any { |
||||||
|
const Asciidoctor = asciidoctor(); |
||||||
|
const extensions = Asciidoctor.Extensions.create(); |
||||||
|
|
||||||
|
// Math rendering extension (Asciimath/Latex)
|
||||||
|
extensions.treeProcessor(function (this: any) { |
||||||
|
const dsl = this; |
||||||
|
dsl.process(function (this: any, document: any) { |
||||||
|
const treeProcessor = this; |
||||||
|
processMathBlocks(treeProcessor, document); |
||||||
|
}); |
||||||
|
}); |
||||||
|
|
||||||
|
// PlantUML rendering extension
|
||||||
|
extensions.treeProcessor(function (this: any) { |
||||||
|
const dsl = this; |
||||||
|
dsl.process(function (this: any, document: any) { |
||||||
|
const treeProcessor = this; |
||||||
|
processPlantUMLBlocks(treeProcessor, document); |
||||||
|
}); |
||||||
|
}); |
||||||
|
|
||||||
|
// TikZ rendering extension
|
||||||
|
extensions.treeProcessor(function (this: any) { |
||||||
|
const dsl = this; |
||||||
|
dsl.process(function (this: any, document: any) { |
||||||
|
const treeProcessor = this; |
||||||
|
processTikZBlocks(treeProcessor, document); |
||||||
|
}); |
||||||
|
}); |
||||||
|
|
||||||
|
// --- NEW: Support [plantuml], [tikz], [bpmn] as source blocks ---
|
||||||
|
// Helper to register a block for a given name and treat it as a source block
|
||||||
|
function registerDiagramBlock(name: string) { |
||||||
|
extensions.block(name, function (this: any) { |
||||||
|
const self = this; |
||||||
|
self.process(function (parent: any, reader: any, attrs: any) { |
||||||
|
// Read the block content
|
||||||
|
const lines = reader.getLines(); |
||||||
|
// Create a source block with the correct language and lang attributes
|
||||||
|
const block = self.createBlock(parent, "source", lines, { |
||||||
|
...attrs, |
||||||
|
language: name, |
||||||
|
lang: name, |
||||||
|
style: "source", |
||||||
|
role: name, |
||||||
|
}); |
||||||
|
block.setAttribute("language", name); |
||||||
|
block.setAttribute("lang", name); |
||||||
|
block.setAttribute("style", "source"); |
||||||
|
block.setAttribute("role", name); |
||||||
|
block.setOption("source", true); |
||||||
|
block.setOption("listing", true); |
||||||
|
block.setStyle("source"); |
||||||
|
return block; |
||||||
|
}); |
||||||
|
}); |
||||||
|
} |
||||||
|
registerDiagramBlock("plantuml"); |
||||||
|
registerDiagramBlock("tikz"); |
||||||
|
registerDiagramBlock("bpmn"); |
||||||
|
// --- END NEW ---
|
||||||
|
|
||||||
|
return extensions; |
||||||
|
} |
||||||
|
|
||||||
|
/** |
||||||
|
* Processes math blocks (stem blocks) and converts them to rendered HTML |
||||||
|
*/ |
||||||
|
function processMathBlocks(treeProcessor: any, document: any): void { |
||||||
|
const blocks = document.getBlocks(); |
||||||
|
for (const block of blocks) { |
||||||
|
if (block.getContext() === "stem") { |
||||||
|
const content = block.getContent(); |
||||||
|
if (content) { |
||||||
|
try { |
||||||
|
// Output as a single div with delimiters for MathJax
|
||||||
|
const rendered = `<div class="math-block">$$${content}$$</div>`; |
||||||
|
block.setContent(rendered); |
||||||
|
} catch (error) { |
||||||
|
console.warn("Failed to render math:", error); |
||||||
|
} |
||||||
|
} |
||||||
|
} |
||||||
|
// Inline math: context 'inline' and style 'stem' or 'latexmath'
|
||||||
|
if ( |
||||||
|
block.getContext() === "inline" && |
||||||
|
(block.getStyle() === "stem" || block.getStyle() === "latexmath") |
||||||
|
) { |
||||||
|
const content = block.getContent(); |
||||||
|
if (content) { |
||||||
|
try { |
||||||
|
const rendered = `<span class="math-inline">$${content}$</span>`; |
||||||
|
block.setContent(rendered); |
||||||
|
} catch (error) { |
||||||
|
console.warn("Failed to render inline math:", error); |
||||||
|
} |
||||||
|
} |
||||||
|
} |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
/** |
||||||
|
* Processes PlantUML blocks and converts them to rendered SVG |
||||||
|
*/ |
||||||
|
function processPlantUMLBlocks(treeProcessor: any, document: any): void { |
||||||
|
const blocks = document.getBlocks(); |
||||||
|
|
||||||
|
for (const block of blocks) { |
||||||
|
if (block.getContext() === "listing" && isPlantUMLBlock(block)) { |
||||||
|
const content = block.getContent(); |
||||||
|
if (content) { |
||||||
|
try { |
||||||
|
// Use simple PlantUML rendering
|
||||||
|
const rendered = renderPlantUML(content); |
||||||
|
|
||||||
|
// Replace the block content with the image
|
||||||
|
block.setContent(rendered); |
||||||
|
} catch (error) { |
||||||
|
console.warn("Failed to render PlantUML:", error); |
||||||
|
// Keep original content if rendering fails
|
||||||
|
} |
||||||
|
} |
||||||
|
} |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
/** |
||||||
|
* Processes TikZ blocks and converts them to rendered SVG |
||||||
|
*/ |
||||||
|
function processTikZBlocks(treeProcessor: any, document: any): void { |
||||||
|
const blocks = document.getBlocks(); |
||||||
|
|
||||||
|
for (const block of blocks) { |
||||||
|
if (block.getContext() === "listing" && isTikZBlock(block)) { |
||||||
|
const content = block.getContent(); |
||||||
|
if (content) { |
||||||
|
try { |
||||||
|
// Render TikZ to SVG
|
||||||
|
const svg = renderTikZ(content); |
||||||
|
|
||||||
|
// Replace the block content with the SVG
|
||||||
|
block.setContent(svg); |
||||||
|
} catch (error) { |
||||||
|
console.warn("Failed to render TikZ:", error); |
||||||
|
// Keep original content if rendering fails
|
||||||
|
} |
||||||
|
} |
||||||
|
} |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
/** |
||||||
|
* Checks if a block contains PlantUML content |
||||||
|
*/ |
||||||
|
function isPlantUMLBlock(block: any): boolean { |
||||||
|
const content = block.getContent() || ""; |
||||||
|
const lines = content.split("\n"); |
||||||
|
|
||||||
|
// Check for PlantUML indicators
|
||||||
|
return lines.some( |
||||||
|
(line: string) => |
||||||
|
line.trim().startsWith("@startuml") || |
||||||
|
line.trim().startsWith("@start") || |
||||||
|
line.includes("plantuml") || |
||||||
|
line.includes("uml"), |
||||||
|
); |
||||||
|
} |
||||||
|
|
||||||
|
/** |
||||||
|
* Checks if a block contains TikZ content |
||||||
|
*/ |
||||||
|
function isTikZBlock(block: any): boolean { |
||||||
|
const content = block.getContent() || ""; |
||||||
|
const lines = content.split("\n"); |
||||||
|
|
||||||
|
// Check for TikZ indicators
|
||||||
|
return lines.some( |
||||||
|
(line: string) => |
||||||
|
line.trim().startsWith("\\begin{tikzpicture}") || |
||||||
|
line.trim().startsWith("\\tikz") || |
||||||
|
line.includes("tikzpicture") || |
||||||
|
line.includes("tikz"), |
||||||
|
); |
||||||
|
} |
||||||
@ -0,0 +1,136 @@ |
|||||||
|
import { processNostrIdentifiers } from "../nostrUtils"; |
||||||
|
|
||||||
|
/** |
||||||
|
* 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, ""); |
||||||
|
} |
||||||
|
|
||||||
|
/** |
||||||
|
* Replaces wikilinks in the format [[target]] or [[target|display]] with |
||||||
|
* clickable links to the events page. |
||||||
|
*/ |
||||||
|
function replaceWikilinks(html: string): string { |
||||||
|
// [[target page]] or [[target page|display text]]
|
||||||
|
return html.replace( |
||||||
|
/\[\[([^\]|]+)(?:\|([^\]]+))?\]\]/g, |
||||||
|
(_match, target, label) => { |
||||||
|
const normalized = normalizeDTag(target.trim()); |
||||||
|
const display = (label || target).trim(); |
||||||
|
const url = `./events?d=${normalized}`; |
||||||
|
// Output as a clickable <a> with the [[display]] format and matching link colors
|
||||||
|
return `<a class="wikilink text-primary-600 dark:text-primary-500 hover:underline" data-dtag="${normalized}" data-url="${url}" href="${url}">${display}</a>`; |
||||||
|
}, |
||||||
|
); |
||||||
|
} |
||||||
|
|
||||||
|
/** |
||||||
|
* Replaces AsciiDoctor-generated empty anchor tags <a id="..."></a> with clickable wikilink-style <a> tags. |
||||||
|
*/ |
||||||
|
function replaceAsciiDocAnchors(html: string): string { |
||||||
|
return html.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>`; |
||||||
|
} |
||||||
|
); |
||||||
|
} |
||||||
|
|
||||||
|
/** |
||||||
|
* Processes nostr addresses in HTML content, but skips addresses that are |
||||||
|
* already within hyperlink tags. |
||||||
|
*/ |
||||||
|
async function processNostrAddresses(html: string): Promise<string> { |
||||||
|
// Helper to check if a match is within an existing <a> tag
|
||||||
|
function isWithinLink(text: string, index: number): boolean { |
||||||
|
// Look backwards from the match position to find the nearest <a> tag
|
||||||
|
const before = text.slice(0, index); |
||||||
|
const lastOpenTag = before.lastIndexOf("<a"); |
||||||
|
const lastCloseTag = before.lastIndexOf("</a>"); |
||||||
|
|
||||||
|
// If we find an opening <a> tag after the last closing </a> tag, we're inside a link
|
||||||
|
return lastOpenTag > lastCloseTag; |
||||||
|
} |
||||||
|
|
||||||
|
// Process nostr addresses that are not within existing links
|
||||||
|
const nostrPattern = |
||||||
|
/nostr:(npub|nprofile|note|nevent|naddr)[a-zA-Z0-9]{20,}/g; |
||||||
|
let processedHtml = html; |
||||||
|
|
||||||
|
// Find all nostr addresses
|
||||||
|
const matches = Array.from(processedHtml.matchAll(nostrPattern)); |
||||||
|
|
||||||
|
// 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 already within a link
|
||||||
|
if (isWithinLink(processedHtml, matchIndex)) { |
||||||
|
continue; |
||||||
|
} |
||||||
|
|
||||||
|
// Process the nostr identifier
|
||||||
|
const processedMatch = await processNostrIdentifiers(fullMatch); |
||||||
|
|
||||||
|
// Replace the match in the HTML
|
||||||
|
processedHtml = |
||||||
|
processedHtml.slice(0, matchIndex) + |
||||||
|
processedMatch + |
||||||
|
processedHtml.slice(matchIndex + fullMatch.length); |
||||||
|
} |
||||||
|
|
||||||
|
return processedHtml; |
||||||
|
} |
||||||
|
|
||||||
|
/** |
||||||
|
* Fixes AsciiDoctor stem blocks for MathJax rendering. |
||||||
|
* Joins split spans and wraps content in $$...$$ for block math. |
||||||
|
*/ |
||||||
|
function fixStemBlocks(html: string): string { |
||||||
|
// Replace <div class="stemblock"><div class="content"><span>$</span>...<span>$</span></div></div>
|
||||||
|
// with <div class="stemblock"><div class="content">$$...$$</div></div>
|
||||||
|
return html.replace( |
||||||
|
/<div class="stemblock">\s*<div class="content">\s*<span>\$<\/span>([\s\S]*?)<span>\$<\/span>\s*<\/div>\s*<\/div>/g, |
||||||
|
(_match, mathContent) => { |
||||||
|
// Remove any extra tags inside mathContent
|
||||||
|
const cleanMath = mathContent.replace(/<\/?span[^>]*>/g, "").trim(); |
||||||
|
return `<div class="stemblock"><div class="content">$$${cleanMath}$$</div></div>`; |
||||||
|
}, |
||||||
|
); |
||||||
|
} |
||||||
|
|
||||||
|
/** |
||||||
|
* Post-processes asciidoctor HTML output to add wikilink and nostr address rendering. |
||||||
|
* This function should be called after asciidoctor.convert() to enhance the HTML output. |
||||||
|
*/ |
||||||
|
export async function postProcessAsciidoctorHtml( |
||||||
|
html: string, |
||||||
|
): Promise<string> { |
||||||
|
if (!html) return html; |
||||||
|
|
||||||
|
try { |
||||||
|
// First process AsciiDoctor-generated anchors
|
||||||
|
let processedHtml = replaceAsciiDocAnchors(html); |
||||||
|
// Then process wikilinks in [[...]] format (if any remain)
|
||||||
|
processedHtml = replaceWikilinks(processedHtml); |
||||||
|
// Then process nostr addresses (but not those already in links)
|
||||||
|
processedHtml = await processNostrAddresses(processedHtml); |
||||||
|
processedHtml = fixStemBlocks(processedHtml); // Fix math blocks for MathJax
|
||||||
|
|
||||||
|
return processedHtml; |
||||||
|
} catch (error) { |
||||||
|
console.error("Error in postProcessAsciidoctorHtml:", error); |
||||||
|
return html; // Return original HTML if processing fails
|
||||||
|
} |
||||||
|
} |
||||||
@ -0,0 +1,60 @@ |
|||||||
|
/** |
||||||
|
* TikZ renderer using node-tikzjax |
||||||
|
* Converts TikZ LaTeX code to SVG for browser rendering |
||||||
|
*/ |
||||||
|
|
||||||
|
// We'll use a simple approach for now since node-tikzjax might not be available
|
||||||
|
// This is a placeholder implementation that can be enhanced later
|
||||||
|
|
||||||
|
export function renderTikZ(tikzCode: string): string { |
||||||
|
try { |
||||||
|
// For now, we'll create a simple SVG placeholder
|
||||||
|
// In a full implementation, this would use node-tikzjax or similar library
|
||||||
|
|
||||||
|
// Extract TikZ content and create a basic SVG
|
||||||
|
const svgContent = createBasicSVG(tikzCode); |
||||||
|
|
||||||
|
return svgContent; |
||||||
|
} catch (error) { |
||||||
|
console.error("Failed to render TikZ:", error); |
||||||
|
return `<div class="tikz-error text-red-500 p-4 border border-red-300 rounded">
|
||||||
|
<p class="font-bold">TikZ Rendering Error</p> |
||||||
|
<p class="text-sm">Failed to render TikZ diagram. Original code:</p> |
||||||
|
<pre class="mt-2 p-2 bg-gray-100 rounded text-xs overflow-x-auto">${tikzCode}</pre> |
||||||
|
</div>`;
|
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
/** |
||||||
|
* Creates a basic SVG placeholder for TikZ content |
||||||
|
* This is a temporary implementation until proper TikZ rendering is available |
||||||
|
*/ |
||||||
|
function createBasicSVG(tikzCode: string): string { |
||||||
|
// Create a simple SVG with the TikZ code as text
|
||||||
|
const width = 400; |
||||||
|
const height = 300; |
||||||
|
|
||||||
|
return `<svg width="${width}" height="${height}" class="tikz-diagram max-w-full h-auto rounded-lg shadow-lg my-4" viewBox="0 0 ${width} ${height}">
|
||||||
|
<rect width="${width}" height="${height}" fill="white" stroke="#ccc" stroke-width="1"/> |
||||||
|
<text x="10" y="20" font-family="monospace" font-size="12" fill="#666"> |
||||||
|
TikZ Diagram |
||||||
|
</text> |
||||||
|
<text x="10" y="40" font-family="monospace" font-size="10" fill="#999"> |
||||||
|
(Rendering not yet implemented) |
||||||
|
</text> |
||||||
|
<foreignObject x="10" y="60" width="${width - 20}" height="${height - 70}"> |
||||||
|
<div xmlns="http://www.w3.org/1999/xhtml" style="font-family: monospace; font-size: 10px; color: #666; overflow: hidden;"> |
||||||
|
<pre style="margin: 0; white-space: pre-wrap; word-break: break-all;">${escapeHtml(tikzCode)}</pre> |
||||||
|
</div> |
||||||
|
</foreignObject> |
||||||
|
</svg>`;
|
||||||
|
} |
||||||
|
|
||||||
|
/** |
||||||
|
* Escapes HTML characters for safe display |
||||||
|
*/ |
||||||
|
function escapeHtml(text: string): string { |
||||||
|
const div = document.createElement("div"); |
||||||
|
div.textContent = text; |
||||||
|
return div.innerHTML; |
||||||
|
} |
||||||
@ -0,0 +1,421 @@ |
|||||||
|
import { nip19 } from "nostr-tools"; |
||||||
|
import { getEventHash, signEvent, prefixNostrAddresses } from "./nostrUtils"; |
||||||
|
import { standardRelays, fallbackRelays } from "$lib/consts"; |
||||||
|
import { userRelays } from "$lib/stores/relayStore"; |
||||||
|
import { get } from "svelte/store"; |
||||||
|
import { goto } from "$app/navigation"; |
||||||
|
import type { NDKEvent } from "./nostrUtils"; |
||||||
|
import { EVENT_KINDS, TIME_CONSTANTS, TIMEOUTS } from './search_constants'; |
||||||
|
|
||||||
|
export interface RootEventInfo { |
||||||
|
rootId: string; |
||||||
|
rootPubkey: string; |
||||||
|
rootRelay: string; |
||||||
|
rootKind: number; |
||||||
|
rootAddress: string; |
||||||
|
rootIValue: string; |
||||||
|
rootIRelay: string; |
||||||
|
isRootA: boolean; |
||||||
|
isRootI: boolean; |
||||||
|
} |
||||||
|
|
||||||
|
export interface ParentEventInfo { |
||||||
|
parentId: string; |
||||||
|
parentPubkey: string; |
||||||
|
parentRelay: string; |
||||||
|
parentKind: number; |
||||||
|
parentAddress: string; |
||||||
|
} |
||||||
|
|
||||||
|
export interface EventPublishResult { |
||||||
|
success: boolean; |
||||||
|
relay?: string; |
||||||
|
eventId?: string; |
||||||
|
error?: string; |
||||||
|
} |
||||||
|
|
||||||
|
/** |
||||||
|
* Helper function to find a tag by its first element |
||||||
|
*/ |
||||||
|
function findTag(tags: string[][], tagName: string): string[] | undefined { |
||||||
|
return tags?.find((t: string[]) => t[0] === tagName); |
||||||
|
} |
||||||
|
|
||||||
|
/** |
||||||
|
* Helper function to get tag value safely |
||||||
|
*/ |
||||||
|
function getTagValue(tags: string[][], tagName: string, index: number = 1): string { |
||||||
|
const tag = findTag(tags, tagName); |
||||||
|
return tag?.[index] || ''; |
||||||
|
} |
||||||
|
|
||||||
|
/** |
||||||
|
* Helper function to create a tag array |
||||||
|
*/ |
||||||
|
function createTag(name: string, ...values: (string | number)[]): string[] { |
||||||
|
return [name, ...values.map(v => String(v))]; |
||||||
|
} |
||||||
|
|
||||||
|
/** |
||||||
|
* Helper function to add tags to an array |
||||||
|
*/ |
||||||
|
function addTags(tags: string[][], ...newTags: string[][]): void { |
||||||
|
tags.push(...newTags); |
||||||
|
} |
||||||
|
|
||||||
|
/** |
||||||
|
* Extract root event information from parent event tags |
||||||
|
*/ |
||||||
|
export function extractRootEventInfo(parent: NDKEvent): RootEventInfo { |
||||||
|
const rootInfo: RootEventInfo = { |
||||||
|
rootId: parent.id, |
||||||
|
rootPubkey: getPubkeyString(parent.pubkey), |
||||||
|
rootRelay: getRelayString(parent.relay), |
||||||
|
rootKind: parent.kind || 1, |
||||||
|
rootAddress: '', |
||||||
|
rootIValue: '', |
||||||
|
rootIRelay: '', |
||||||
|
isRootA: false, |
||||||
|
isRootI: false, |
||||||
|
}; |
||||||
|
|
||||||
|
if (!parent.tags) return rootInfo; |
||||||
|
|
||||||
|
const rootE = findTag(parent.tags, 'E'); |
||||||
|
const rootA = findTag(parent.tags, 'A'); |
||||||
|
const rootI = findTag(parent.tags, 'I'); |
||||||
|
|
||||||
|
rootInfo.isRootA = !!rootA; |
||||||
|
rootInfo.isRootI = !!rootI; |
||||||
|
|
||||||
|
if (rootE) { |
||||||
|
rootInfo.rootId = rootE[1]; |
||||||
|
rootInfo.rootRelay = getRelayString(rootE[2]); |
||||||
|
rootInfo.rootPubkey = getPubkeyString(rootE[3] || rootInfo.rootPubkey); |
||||||
|
rootInfo.rootKind = Number(getTagValue(parent.tags, 'K')) || rootInfo.rootKind; |
||||||
|
} else if (rootA) { |
||||||
|
rootInfo.rootAddress = rootA[1]; |
||||||
|
rootInfo.rootRelay = getRelayString(rootA[2]); |
||||||
|
rootInfo.rootPubkey = getPubkeyString(getTagValue(parent.tags, 'P') || rootInfo.rootPubkey); |
||||||
|
rootInfo.rootKind = Number(getTagValue(parent.tags, 'K')) || rootInfo.rootKind; |
||||||
|
} else if (rootI) { |
||||||
|
rootInfo.rootIValue = rootI[1]; |
||||||
|
rootInfo.rootIRelay = getRelayString(rootI[2]); |
||||||
|
rootInfo.rootKind = Number(getTagValue(parent.tags, 'K')) || rootInfo.rootKind; |
||||||
|
} |
||||||
|
|
||||||
|
return rootInfo; |
||||||
|
} |
||||||
|
|
||||||
|
/** |
||||||
|
* Extract parent event information |
||||||
|
*/ |
||||||
|
export function extractParentEventInfo(parent: NDKEvent): ParentEventInfo { |
||||||
|
const dTag = getTagValue(parent.tags || [], 'd'); |
||||||
|
const parentAddress = dTag ? `${parent.kind}:${getPubkeyString(parent.pubkey)}:${dTag}` : ''; |
||||||
|
|
||||||
|
return { |
||||||
|
parentId: parent.id, |
||||||
|
parentPubkey: getPubkeyString(parent.pubkey), |
||||||
|
parentRelay: getRelayString(parent.relay), |
||||||
|
parentKind: parent.kind || 1, |
||||||
|
parentAddress, |
||||||
|
}; |
||||||
|
} |
||||||
|
|
||||||
|
/** |
||||||
|
* Build root scope tags for NIP-22 threading |
||||||
|
*/ |
||||||
|
function buildRootScopeTags(rootInfo: RootEventInfo, parentInfo: ParentEventInfo): string[][] { |
||||||
|
const tags: string[][] = []; |
||||||
|
|
||||||
|
if (rootInfo.rootAddress) { |
||||||
|
const tagType = rootInfo.isRootA ? 'A' : rootInfo.isRootI ? 'I' : 'E'; |
||||||
|
addTags(tags, createTag(tagType, rootInfo.rootAddress || rootInfo.rootId, rootInfo.rootRelay)); |
||||||
|
} else if (rootInfo.rootIValue) { |
||||||
|
addTags(tags, createTag('I', rootInfo.rootIValue, rootInfo.rootIRelay)); |
||||||
|
} else { |
||||||
|
addTags(tags, createTag('E', rootInfo.rootId, rootInfo.rootRelay)); |
||||||
|
} |
||||||
|
|
||||||
|
addTags(tags, createTag('K', rootInfo.rootKind)); |
||||||
|
|
||||||
|
if (rootInfo.rootPubkey && !rootInfo.rootIValue) { |
||||||
|
addTags(tags, createTag('P', rootInfo.rootPubkey, rootInfo.rootRelay)); |
||||||
|
} |
||||||
|
|
||||||
|
return tags; |
||||||
|
} |
||||||
|
|
||||||
|
/** |
||||||
|
* Build parent scope tags for NIP-22 threading |
||||||
|
*/ |
||||||
|
function buildParentScopeTags(parent: NDKEvent, parentInfo: ParentEventInfo, rootInfo: RootEventInfo): string[][] { |
||||||
|
const tags: string[][] = []; |
||||||
|
|
||||||
|
if (parentInfo.parentAddress) { |
||||||
|
const tagType = rootInfo.isRootA ? 'a' : rootInfo.isRootI ? 'i' : 'e'; |
||||||
|
addTags(tags, createTag(tagType, parentInfo.parentAddress, parentInfo.parentRelay)); |
||||||
|
} |
||||||
|
|
||||||
|
addTags( |
||||||
|
tags, |
||||||
|
createTag('e', parent.id, parentInfo.parentRelay), |
||||||
|
createTag('k', parentInfo.parentKind), |
||||||
|
createTag('p', parentInfo.parentPubkey, parentInfo.parentRelay) |
||||||
|
); |
||||||
|
|
||||||
|
return tags; |
||||||
|
} |
||||||
|
|
||||||
|
/** |
||||||
|
* Build tags for a reply event based on parent and root information |
||||||
|
*/ |
||||||
|
export function buildReplyTags( |
||||||
|
parent: NDKEvent, |
||||||
|
rootInfo: RootEventInfo, |
||||||
|
parentInfo: ParentEventInfo, |
||||||
|
kind: number |
||||||
|
): string[][] { |
||||||
|
const tags: string[][] = []; |
||||||
|
|
||||||
|
const isParentReplaceable = parentInfo.parentKind >= EVENT_KINDS.ADDRESSABLE.MIN && parentInfo.parentKind < EVENT_KINDS.ADDRESSABLE.MAX; |
||||||
|
const isParentComment = parentInfo.parentKind === EVENT_KINDS.COMMENT; |
||||||
|
const isReplyToComment = isParentComment && rootInfo.rootId !== parent.id; |
||||||
|
|
||||||
|
if (kind === 1) { |
||||||
|
// Kind 1 replies use simple e/p tags
|
||||||
|
addTags( |
||||||
|
tags, |
||||||
|
createTag('e', parent.id, parentInfo.parentRelay, 'root'), |
||||||
|
createTag('p', parentInfo.parentPubkey) |
||||||
|
); |
||||||
|
|
||||||
|
// Add address for replaceable events
|
||||||
|
if (isParentReplaceable) { |
||||||
|
const dTag = getTagValue(parent.tags || [], 'd'); |
||||||
|
if (dTag) { |
||||||
|
const parentAddress = `${parentInfo.parentKind}:${parentInfo.parentPubkey}:${dTag}`; |
||||||
|
addTags(tags, createTag('a', parentAddress, '', 'root')); |
||||||
|
} |
||||||
|
} |
||||||
|
} else { |
||||||
|
// Kind 1111 (comment) uses NIP-22 threading format
|
||||||
|
if (isParentReplaceable) { |
||||||
|
const dTag = getTagValue(parent.tags || [], 'd'); |
||||||
|
if (dTag) { |
||||||
|
const parentAddress = `${parentInfo.parentKind}:${parentInfo.parentPubkey}:${dTag}`; |
||||||
|
|
||||||
|
if (isReplyToComment) { |
||||||
|
// Root scope (uppercase) - use the original article
|
||||||
|
addTags( |
||||||
|
tags, |
||||||
|
createTag('A', parentAddress, parentInfo.parentRelay), |
||||||
|
createTag('K', rootInfo.rootKind), |
||||||
|
createTag('P', rootInfo.rootPubkey, rootInfo.rootRelay) |
||||||
|
); |
||||||
|
// Parent scope (lowercase) - the comment we're replying to
|
||||||
|
addTags( |
||||||
|
tags, |
||||||
|
createTag('e', parent.id, parentInfo.parentRelay), |
||||||
|
createTag('k', parentInfo.parentKind), |
||||||
|
createTag('p', parentInfo.parentPubkey, parentInfo.parentRelay) |
||||||
|
); |
||||||
|
} else { |
||||||
|
// Top-level comment - root and parent are the same
|
||||||
|
addTags( |
||||||
|
tags, |
||||||
|
createTag('A', parentAddress, parentInfo.parentRelay), |
||||||
|
createTag('K', rootInfo.rootKind), |
||||||
|
createTag('P', rootInfo.rootPubkey, rootInfo.rootRelay), |
||||||
|
createTag('a', parentAddress, parentInfo.parentRelay), |
||||||
|
createTag('e', parent.id, parentInfo.parentRelay), |
||||||
|
createTag('k', parentInfo.parentKind), |
||||||
|
createTag('p', parentInfo.parentPubkey, parentInfo.parentRelay) |
||||||
|
); |
||||||
|
} |
||||||
|
} else { |
||||||
|
// Fallback to E/e tags if no d-tag found
|
||||||
|
if (isReplyToComment) { |
||||||
|
addTags( |
||||||
|
tags, |
||||||
|
createTag('E', rootInfo.rootId, rootInfo.rootRelay), |
||||||
|
createTag('K', rootInfo.rootKind), |
||||||
|
createTag('P', rootInfo.rootPubkey, rootInfo.rootRelay), |
||||||
|
createTag('e', parent.id, parentInfo.parentRelay), |
||||||
|
createTag('k', parentInfo.parentKind), |
||||||
|
createTag('p', parentInfo.parentPubkey, parentInfo.parentRelay) |
||||||
|
); |
||||||
|
} else { |
||||||
|
addTags( |
||||||
|
tags, |
||||||
|
createTag('E', parent.id, rootInfo.rootRelay), |
||||||
|
createTag('K', rootInfo.rootKind), |
||||||
|
createTag('P', rootInfo.rootPubkey, rootInfo.rootRelay), |
||||||
|
createTag('e', parent.id, parentInfo.parentRelay), |
||||||
|
createTag('k', parentInfo.parentKind), |
||||||
|
createTag('p', parentInfo.parentPubkey, parentInfo.parentRelay) |
||||||
|
); |
||||||
|
} |
||||||
|
} |
||||||
|
} else { |
||||||
|
// For regular events, use E/e tags
|
||||||
|
if (isReplyToComment) { |
||||||
|
// Reply to a comment - distinguish root from parent
|
||||||
|
addTags(tags, ...buildRootScopeTags(rootInfo, parentInfo)); |
||||||
|
addTags( |
||||||
|
tags, |
||||||
|
createTag('e', parent.id, parentInfo.parentRelay), |
||||||
|
createTag('k', parentInfo.parentKind), |
||||||
|
createTag('p', parentInfo.parentPubkey, parentInfo.parentRelay) |
||||||
|
); |
||||||
|
} else { |
||||||
|
// Top-level comment or regular event
|
||||||
|
addTags(tags, ...buildRootScopeTags(rootInfo, parentInfo)); |
||||||
|
addTags(tags, ...buildParentScopeTags(parent, parentInfo, rootInfo)); |
||||||
|
} |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
return tags; |
||||||
|
} |
||||||
|
|
||||||
|
/** |
||||||
|
* Create and sign a Nostr event |
||||||
|
*/ |
||||||
|
export async function createSignedEvent( |
||||||
|
content: string, |
||||||
|
pubkey: string, |
||||||
|
kind: number, |
||||||
|
tags: string[][] |
||||||
|
): Promise<{ id: string; sig: string; event: any }> { |
||||||
|
const prefixedContent = prefixNostrAddresses(content); |
||||||
|
|
||||||
|
const eventToSign = { |
||||||
|
kind: Number(kind), |
||||||
|
created_at: Number(Math.floor(Date.now() / TIME_CONSTANTS.UNIX_TIMESTAMP_FACTOR)), |
||||||
|
tags: tags.map(tag => [String(tag[0]), String(tag[1]), String(tag[2] || ''), String(tag[3] || '')]), |
||||||
|
content: String(prefixedContent), |
||||||
|
pubkey: pubkey, |
||||||
|
}; |
||||||
|
|
||||||
|
let sig, id; |
||||||
|
if (typeof window !== 'undefined' && window.nostr && window.nostr.signEvent) { |
||||||
|
const signed = await window.nostr.signEvent(eventToSign); |
||||||
|
sig = signed.sig as string; |
||||||
|
id = 'id' in signed ? signed.id as string : getEventHash(eventToSign); |
||||||
|
} else { |
||||||
|
id = getEventHash(eventToSign); |
||||||
|
sig = await signEvent(eventToSign); |
||||||
|
} |
||||||
|
|
||||||
|
return { |
||||||
|
id, |
||||||
|
sig, |
||||||
|
event: { |
||||||
|
...eventToSign, |
||||||
|
id, |
||||||
|
sig, |
||||||
|
} |
||||||
|
}; |
||||||
|
} |
||||||
|
|
||||||
|
/** |
||||||
|
* Publish event to a single relay |
||||||
|
*/ |
||||||
|
async function publishToRelay(relayUrl: string, signedEvent: any): Promise<void> { |
||||||
|
const ws = new WebSocket(relayUrl); |
||||||
|
|
||||||
|
return new Promise<void>((resolve, reject) => { |
||||||
|
const timeout = setTimeout(() => { |
||||||
|
ws.close(); |
||||||
|
reject(new Error("Timeout")); |
||||||
|
}, TIMEOUTS.GENERAL); |
||||||
|
|
||||||
|
ws.onopen = () => { |
||||||
|
ws.send(JSON.stringify(["EVENT", signedEvent])); |
||||||
|
}; |
||||||
|
|
||||||
|
ws.onmessage = (e) => { |
||||||
|
const [type, id, ok, message] = JSON.parse(e.data); |
||||||
|
if (type === "OK" && id === signedEvent.id) { |
||||||
|
clearTimeout(timeout); |
||||||
|
if (ok) { |
||||||
|
ws.close(); |
||||||
|
resolve(); |
||||||
|
} else { |
||||||
|
ws.close(); |
||||||
|
reject(new Error(message)); |
||||||
|
} |
||||||
|
} |
||||||
|
}; |
||||||
|
|
||||||
|
ws.onerror = () => { |
||||||
|
clearTimeout(timeout); |
||||||
|
ws.close(); |
||||||
|
reject(new Error("WebSocket error")); |
||||||
|
}; |
||||||
|
}); |
||||||
|
} |
||||||
|
|
||||||
|
/** |
||||||
|
* Publish event to relays |
||||||
|
*/ |
||||||
|
export async function publishEvent( |
||||||
|
signedEvent: any, |
||||||
|
useOtherRelays = false, |
||||||
|
useFallbackRelays = false, |
||||||
|
userRelayPreference = false |
||||||
|
): Promise<EventPublishResult> { |
||||||
|
// Determine which relays to use
|
||||||
|
let relays = userRelayPreference ? get(userRelays) : standardRelays; |
||||||
|
if (useOtherRelays) { |
||||||
|
relays = userRelayPreference ? standardRelays : get(userRelays); |
||||||
|
} |
||||||
|
if (useFallbackRelays) { |
||||||
|
relays = fallbackRelays; |
||||||
|
} |
||||||
|
|
||||||
|
// Try to publish to relays
|
||||||
|
for (const relayUrl of relays) { |
||||||
|
try { |
||||||
|
await publishToRelay(relayUrl, signedEvent); |
||||||
|
return { |
||||||
|
success: true, |
||||||
|
relay: relayUrl, |
||||||
|
eventId: signedEvent.id |
||||||
|
}; |
||||||
|
} catch (e) { |
||||||
|
console.error(`Failed to publish to ${relayUrl}:`, e); |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
return { |
||||||
|
success: false, |
||||||
|
error: "Failed to publish to any relays" |
||||||
|
}; |
||||||
|
} |
||||||
|
|
||||||
|
/** |
||||||
|
* Navigate to the published event |
||||||
|
*/ |
||||||
|
export function navigateToEvent(eventId: string): void { |
||||||
|
const nevent = nip19.neventEncode({ id: eventId }); |
||||||
|
goto(`/events?id=${nevent}`); |
||||||
|
} |
||||||
|
|
||||||
|
// Helper functions to ensure relay and pubkey are always strings
|
||||||
|
function getRelayString(relay: any): string { |
||||||
|
if (!relay) return ''; |
||||||
|
if (typeof relay === 'string') return relay; |
||||||
|
if (typeof relay.url === 'string') return relay.url; |
||||||
|
return ''; |
||||||
|
} |
||||||
|
|
||||||
|
function getPubkeyString(pubkey: any): string { |
||||||
|
if (!pubkey) return ''; |
||||||
|
if (typeof pubkey === 'string') return pubkey; |
||||||
|
if (typeof pubkey.hex === 'function') return pubkey.hex(); |
||||||
|
if (typeof pubkey.pubkey === 'string') return pubkey.pubkey; |
||||||
|
return ''; |
||||||
|
}
|
||||||
@ -0,0 +1,233 @@ |
|||||||
|
import { ndkInstance } from '$lib/ndk'; |
||||||
|
import { getUserMetadata, getNpubFromNip05 } from '$lib/utils/nostrUtils'; |
||||||
|
import { NDKRelaySet, NDKEvent } from '@nostr-dev-kit/ndk'; |
||||||
|
import { searchCache } from '$lib/utils/searchCache'; |
||||||
|
import { communityRelay, profileRelay } from '$lib/consts'; |
||||||
|
import { get } from 'svelte/store'; |
||||||
|
import type { NostrProfile, ProfileSearchResult } from './search_types'; |
||||||
|
import { fieldMatches, nip05Matches, normalizeSearchTerm, COMMON_DOMAINS, createProfileFromEvent } from './search_utils'; |
||||||
|
import { checkCommunityStatus } from './community_checker'; |
||||||
|
import { TIMEOUTS } from './search_constants'; |
||||||
|
|
||||||
|
/** |
||||||
|
* Search for profiles by various criteria (display name, name, NIP-05, npub) |
||||||
|
*/ |
||||||
|
export async function searchProfiles(searchTerm: string): Promise<ProfileSearchResult> { |
||||||
|
const normalizedSearchTerm = searchTerm.toLowerCase().trim(); |
||||||
|
|
||||||
|
// Check cache first
|
||||||
|
const cachedResult = searchCache.get('profile', normalizedSearchTerm); |
||||||
|
if (cachedResult) { |
||||||
|
const profiles = cachedResult.events.map(event => { |
||||||
|
try { |
||||||
|
const profileData = JSON.parse(event.content); |
||||||
|
return createProfileFromEvent(event, profileData); |
||||||
|
} catch { |
||||||
|
return null; |
||||||
|
} |
||||||
|
}).filter(Boolean) as NostrProfile[]; |
||||||
|
|
||||||
|
const communityStatus = await checkCommunityStatus(profiles); |
||||||
|
return { profiles, Status: communityStatus }; |
||||||
|
} |
||||||
|
|
||||||
|
const ndk = get(ndkInstance); |
||||||
|
if (!ndk) { |
||||||
|
throw new Error('NDK not initialized'); |
||||||
|
} |
||||||
|
|
||||||
|
let foundProfiles: NostrProfile[] = []; |
||||||
|
let timeoutId: ReturnType<typeof setTimeout> | null = null; |
||||||
|
|
||||||
|
// Set a timeout to force completion after profile search timeout
|
||||||
|
timeoutId = setTimeout(() => { |
||||||
|
if (foundProfiles.length === 0) { |
||||||
|
// Timeout reached, but no need to log this
|
||||||
|
} |
||||||
|
}, TIMEOUTS.PROFILE_SEARCH); |
||||||
|
|
||||||
|
try { |
||||||
|
// Check if it's a valid npub/nprofile first
|
||||||
|
if (normalizedSearchTerm.startsWith('npub') || normalizedSearchTerm.startsWith('nprofile')) { |
||||||
|
try { |
||||||
|
const metadata = await getUserMetadata(normalizedSearchTerm); |
||||||
|
if (metadata) { |
||||||
|
foundProfiles = [metadata]; |
||||||
|
} |
||||||
|
} catch (error) { |
||||||
|
console.error('Error fetching metadata for npub:', error); |
||||||
|
} |
||||||
|
} else if (normalizedSearchTerm.includes('@')) { |
||||||
|
// Check if it's a NIP-05 address
|
||||||
|
try { |
||||||
|
const npub = await getNpubFromNip05(normalizedSearchTerm); |
||||||
|
if (npub) { |
||||||
|
const metadata = await getUserMetadata(npub); |
||||||
|
const profile: NostrProfile = { |
||||||
|
...metadata, |
||||||
|
pubkey: npub |
||||||
|
}; |
||||||
|
foundProfiles = [profile]; |
||||||
|
} |
||||||
|
} catch (e) { |
||||||
|
console.error('[Search] NIP-05 lookup failed:', e); |
||||||
|
// If NIP-05 lookup fails, continue with regular search
|
||||||
|
} |
||||||
|
} else { |
||||||
|
// Try searching for NIP-05 addresses that match the search term
|
||||||
|
foundProfiles = await searchNip05Domains(normalizedSearchTerm, ndk); |
||||||
|
|
||||||
|
// If no NIP-05 results found, search for profiles across relays
|
||||||
|
if (foundProfiles.length === 0) { |
||||||
|
foundProfiles = await searchProfilesAcrossRelays(normalizedSearchTerm, ndk); |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
// Wait for search to complete or timeout
|
||||||
|
await new Promise<void>((resolve) => { |
||||||
|
const checkComplete = () => { |
||||||
|
if (timeoutId === null || foundProfiles.length > 0) { |
||||||
|
resolve(); |
||||||
|
} else { |
||||||
|
setTimeout(checkComplete, 100); |
||||||
|
} |
||||||
|
}; |
||||||
|
checkComplete(); |
||||||
|
}); |
||||||
|
|
||||||
|
// Cache the results
|
||||||
|
if (foundProfiles.length > 0) { |
||||||
|
const events = foundProfiles.map(profile => { |
||||||
|
const event = new NDKEvent(ndk); |
||||||
|
event.content = JSON.stringify(profile); |
||||||
|
event.pubkey = profile.pubkey || ''; |
||||||
|
return event; |
||||||
|
}); |
||||||
|
|
||||||
|
const result = { |
||||||
|
events, |
||||||
|
secondOrder: [], |
||||||
|
tTagEvents: [], |
||||||
|
eventIds: new Set<string>(), |
||||||
|
addresses: new Set<string>(), |
||||||
|
searchType: 'profile', |
||||||
|
searchTerm: normalizedSearchTerm |
||||||
|
}; |
||||||
|
searchCache.set('profile', normalizedSearchTerm, result); |
||||||
|
} |
||||||
|
|
||||||
|
// Check community status for all profiles
|
||||||
|
const communityStatus = await checkCommunityStatus(foundProfiles); |
||||||
|
return { profiles: foundProfiles, Status: communityStatus }; |
||||||
|
|
||||||
|
} catch (error) { |
||||||
|
console.error('Error searching profiles:', error); |
||||||
|
return { profiles: [], Status: {} }; |
||||||
|
} finally { |
||||||
|
if (timeoutId) { |
||||||
|
clearTimeout(timeoutId); |
||||||
|
} |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
/** |
||||||
|
* Search for NIP-05 addresses across common domains |
||||||
|
*/ |
||||||
|
async function searchNip05Domains(searchTerm: string, ndk: any): Promise<NostrProfile[]> { |
||||||
|
try { |
||||||
|
for (const domain of COMMON_DOMAINS) { |
||||||
|
const nip05Address = `${searchTerm}@${domain}`; |
||||||
|
try { |
||||||
|
const npub = await getNpubFromNip05(nip05Address); |
||||||
|
if (npub) { |
||||||
|
const metadata = await getUserMetadata(npub); |
||||||
|
const profile: NostrProfile = { |
||||||
|
...metadata, |
||||||
|
pubkey: npub |
||||||
|
}; |
||||||
|
return [profile]; |
||||||
|
} |
||||||
|
} catch (e) { |
||||||
|
// Continue to next domain
|
||||||
|
} |
||||||
|
} |
||||||
|
} catch (e) { |
||||||
|
console.error('[Search] NIP-05 domain search failed:', e); |
||||||
|
} |
||||||
|
return []; |
||||||
|
} |
||||||
|
|
||||||
|
/** |
||||||
|
* Search for profiles across relays |
||||||
|
*/ |
||||||
|
async function searchProfilesAcrossRelays(searchTerm: string, ndk: any): Promise<NostrProfile[]> { |
||||||
|
const foundProfiles: NostrProfile[] = []; |
||||||
|
|
||||||
|
// Prioritize community relays for better search results
|
||||||
|
const allRelays = Array.from(ndk.pool.relays.values()) as any[]; |
||||||
|
const prioritizedRelays = new Set([ |
||||||
|
...allRelays.filter((relay: any) => relay.url === communityRelay), |
||||||
|
...allRelays.filter((relay: any) => relay.url !== communityRelay) |
||||||
|
]); |
||||||
|
const relaySet = new NDKRelaySet(prioritizedRelays as any, ndk); |
||||||
|
|
||||||
|
// Subscribe to profile events
|
||||||
|
const sub = ndk.subscribe( |
||||||
|
{ kinds: [0] }, |
||||||
|
{ closeOnEose: true }, |
||||||
|
relaySet |
||||||
|
); |
||||||
|
|
||||||
|
return new Promise((resolve) => { |
||||||
|
sub.on('event', (event: NDKEvent) => { |
||||||
|
try { |
||||||
|
if (!event.content) return; |
||||||
|
const profileData = JSON.parse(event.content); |
||||||
|
const displayName = profileData.displayName || profileData.display_name || ''; |
||||||
|
const display_name = profileData.display_name || ''; |
||||||
|
const name = profileData.name || ''; |
||||||
|
const nip05 = profileData.nip05 || ''; |
||||||
|
const about = profileData.about || ''; |
||||||
|
|
||||||
|
// Check if any field matches the search term
|
||||||
|
const matchesDisplayName = fieldMatches(displayName, searchTerm); |
||||||
|
const matchesDisplay_name = fieldMatches(display_name, searchTerm); |
||||||
|
const matchesName = fieldMatches(name, searchTerm); |
||||||
|
const matchesNip05 = nip05Matches(nip05, searchTerm); |
||||||
|
const matchesAbout = fieldMatches(about, searchTerm); |
||||||
|
|
||||||
|
if (matchesDisplayName || matchesDisplay_name || matchesName || matchesNip05 || matchesAbout) { |
||||||
|
const profile = createProfileFromEvent(event, profileData); |
||||||
|
|
||||||
|
// Check if we already have this profile
|
||||||
|
const existingIndex = foundProfiles.findIndex(p => p.pubkey === event.pubkey); |
||||||
|
if (existingIndex === -1) { |
||||||
|
foundProfiles.push(profile); |
||||||
|
} |
||||||
|
} |
||||||
|
} catch (e) { |
||||||
|
// Invalid JSON or other error, skip
|
||||||
|
} |
||||||
|
}); |
||||||
|
|
||||||
|
sub.on('eose', () => { |
||||||
|
if (foundProfiles.length > 0) { |
||||||
|
// Deduplicate by pubkey, keep only newest
|
||||||
|
const deduped: Record<string, { profile: NostrProfile; created_at: number }> = {}; |
||||||
|
for (const profile of foundProfiles) { |
||||||
|
const pubkey = profile.pubkey; |
||||||
|
if (pubkey) { |
||||||
|
// We don't have created_at from getUserMetadata, so just keep the first one
|
||||||
|
if (!deduped[pubkey]) { |
||||||
|
deduped[pubkey] = { profile, created_at: 0 }; |
||||||
|
} |
||||||
|
} |
||||||
|
} |
||||||
|
const dedupedProfiles = Object.values(deduped).map(x => x.profile); |
||||||
|
resolve(dedupedProfiles); |
||||||
|
} else { |
||||||
|
resolve([]); |
||||||
|
} |
||||||
|
}); |
||||||
|
}); |
||||||
|
}
|
||||||
@ -0,0 +1,141 @@ |
|||||||
|
import { standardRelays, anonymousRelays, fallbackRelays } from '$lib/consts'; |
||||||
|
import NDK from '@nostr-dev-kit/ndk'; |
||||||
|
import { TIMEOUTS } from './search_constants'; |
||||||
|
|
||||||
|
export interface RelayDiagnostic { |
||||||
|
url: string; |
||||||
|
connected: boolean; |
||||||
|
requiresAuth: boolean; |
||||||
|
error?: string; |
||||||
|
responseTime?: number; |
||||||
|
} |
||||||
|
|
||||||
|
/** |
||||||
|
* Tests connection to a single relay |
||||||
|
*/ |
||||||
|
export async function testRelay(url: string): Promise<RelayDiagnostic> { |
||||||
|
const startTime = Date.now(); |
||||||
|
|
||||||
|
return new Promise((resolve) => { |
||||||
|
const ws = new WebSocket(url); |
||||||
|
let resolved = false; |
||||||
|
|
||||||
|
const timeout = setTimeout(() => { |
||||||
|
if (!resolved) { |
||||||
|
resolved = true; |
||||||
|
ws.close(); |
||||||
|
resolve({ |
||||||
|
url, |
||||||
|
connected: false, |
||||||
|
requiresAuth: false, |
||||||
|
error: 'Connection timeout', |
||||||
|
responseTime: Date.now() - startTime, |
||||||
|
}); |
||||||
|
} |
||||||
|
}, TIMEOUTS.RELAY_DIAGNOSTICS); |
||||||
|
|
||||||
|
ws.onopen = () => { |
||||||
|
if (!resolved) { |
||||||
|
resolved = true; |
||||||
|
clearTimeout(timeout); |
||||||
|
ws.close(); |
||||||
|
resolve({ |
||||||
|
url, |
||||||
|
connected: true, |
||||||
|
requiresAuth: false, |
||||||
|
responseTime: Date.now() - startTime, |
||||||
|
}); |
||||||
|
} |
||||||
|
}; |
||||||
|
|
||||||
|
ws.onerror = () => { |
||||||
|
if (!resolved) { |
||||||
|
resolved = true; |
||||||
|
clearTimeout(timeout); |
||||||
|
resolve({ |
||||||
|
url, |
||||||
|
connected: false, |
||||||
|
requiresAuth: false, |
||||||
|
error: 'WebSocket error', |
||||||
|
responseTime: Date.now() - startTime, |
||||||
|
}); |
||||||
|
} |
||||||
|
}; |
||||||
|
|
||||||
|
ws.onmessage = (event) => { |
||||||
|
const data = JSON.parse(event.data); |
||||||
|
if (data[0] === 'NOTICE' && data[1]?.includes('auth-required')) { |
||||||
|
if (!resolved) { |
||||||
|
resolved = true; |
||||||
|
clearTimeout(timeout); |
||||||
|
ws.close(); |
||||||
|
resolve({ |
||||||
|
url, |
||||||
|
connected: true, |
||||||
|
requiresAuth: true, |
||||||
|
responseTime: Date.now() - startTime, |
||||||
|
}); |
||||||
|
} |
||||||
|
} |
||||||
|
}; |
||||||
|
}); |
||||||
|
} |
||||||
|
|
||||||
|
/** |
||||||
|
* Tests all relays and returns diagnostic information |
||||||
|
*/ |
||||||
|
export async function testAllRelays(): Promise<RelayDiagnostic[]> { |
||||||
|
const allRelays = [...new Set([...standardRelays, ...anonymousRelays, ...fallbackRelays])]; |
||||||
|
|
||||||
|
console.log('[RelayDiagnostics] Testing', allRelays.length, 'relays...'); |
||||||
|
|
||||||
|
const results = await Promise.allSettled( |
||||||
|
allRelays.map(url => testRelay(url)) |
||||||
|
); |
||||||
|
|
||||||
|
return results.map((result, index) => { |
||||||
|
if (result.status === 'fulfilled') { |
||||||
|
return result.value; |
||||||
|
} else { |
||||||
|
return { |
||||||
|
url: allRelays[index], |
||||||
|
connected: false, |
||||||
|
requiresAuth: false, |
||||||
|
error: 'Test failed', |
||||||
|
}; |
||||||
|
} |
||||||
|
}); |
||||||
|
} |
||||||
|
|
||||||
|
/** |
||||||
|
* Gets working relays from diagnostic results |
||||||
|
*/ |
||||||
|
export function getWorkingRelays(diagnostics: RelayDiagnostic[]): string[] { |
||||||
|
return diagnostics |
||||||
|
.filter(d => d.connected) |
||||||
|
.map(d => d.url); |
||||||
|
} |
||||||
|
|
||||||
|
/** |
||||||
|
* Logs relay diagnostic results to console |
||||||
|
*/ |
||||||
|
export function logRelayDiagnostics(diagnostics: RelayDiagnostic[]): void { |
||||||
|
console.group('[RelayDiagnostics] Results'); |
||||||
|
|
||||||
|
const working = diagnostics.filter(d => d.connected); |
||||||
|
const failed = diagnostics.filter(d => !d.connected); |
||||||
|
|
||||||
|
console.log(`✅ Working relays (${working.length}):`); |
||||||
|
working.forEach(d => { |
||||||
|
console.log(` - ${d.url}${d.requiresAuth ? ' (requires auth)' : ''}${d.responseTime ? ` (${d.responseTime}ms)` : ''}`); |
||||||
|
}); |
||||||
|
|
||||||
|
if (failed.length > 0) { |
||||||
|
console.log(`❌ Failed relays (${failed.length}):`); |
||||||
|
failed.forEach(d => { |
||||||
|
console.log(` - ${d.url}: ${d.error || 'Unknown error'}`); |
||||||
|
}); |
||||||
|
} |
||||||
|
|
||||||
|
console.groupEnd(); |
||||||
|
}
|
||||||
@ -0,0 +1,105 @@ |
|||||||
|
import type { NDKEvent } from "./nostrUtils"; |
||||||
|
import { CACHE_DURATIONS, TIMEOUTS } from './search_constants'; |
||||||
|
|
||||||
|
export interface SearchResult { |
||||||
|
events: NDKEvent[]; |
||||||
|
secondOrder: NDKEvent[]; |
||||||
|
tTagEvents: NDKEvent[]; |
||||||
|
eventIds: Set<string>; |
||||||
|
addresses: Set<string>; |
||||||
|
searchType: string; |
||||||
|
searchTerm: string; |
||||||
|
timestamp: number; |
||||||
|
} |
||||||
|
|
||||||
|
class SearchCache { |
||||||
|
private cache: Map<string, SearchResult> = new Map(); |
||||||
|
private readonly CACHE_DURATION = CACHE_DURATIONS.SEARCH_CACHE; |
||||||
|
|
||||||
|
/** |
||||||
|
* Generate a cache key for a search |
||||||
|
*/ |
||||||
|
private generateKey(searchType: string, searchTerm: string): string { |
||||||
|
if (!searchTerm) { |
||||||
|
return `${searchType}:`; |
||||||
|
} |
||||||
|
return `${searchType}:${searchTerm.toLowerCase().trim()}`; |
||||||
|
} |
||||||
|
|
||||||
|
/** |
||||||
|
* Check if a cached result is still valid |
||||||
|
*/ |
||||||
|
private isExpired(result: SearchResult): boolean { |
||||||
|
return Date.now() - result.timestamp > this.CACHE_DURATION; |
||||||
|
} |
||||||
|
|
||||||
|
/** |
||||||
|
* Get cached search results |
||||||
|
*/ |
||||||
|
get(searchType: string, searchTerm: string): SearchResult | null { |
||||||
|
const key = this.generateKey(searchType, searchTerm); |
||||||
|
const result = this.cache.get(key); |
||||||
|
|
||||||
|
if (!result || this.isExpired(result)) { |
||||||
|
if (result) { |
||||||
|
this.cache.delete(key); |
||||||
|
} |
||||||
|
return null; |
||||||
|
} |
||||||
|
|
||||||
|
return result; |
||||||
|
} |
||||||
|
|
||||||
|
/** |
||||||
|
* Store search results in cache |
||||||
|
*/ |
||||||
|
set(searchType: string, searchTerm: string, result: Omit<SearchResult, 'timestamp'>): void { |
||||||
|
const key = this.generateKey(searchType, searchTerm); |
||||||
|
this.cache.set(key, { |
||||||
|
...result, |
||||||
|
timestamp: Date.now() |
||||||
|
}); |
||||||
|
} |
||||||
|
|
||||||
|
/** |
||||||
|
* Check if a search result is cached and valid |
||||||
|
*/ |
||||||
|
has(searchType: string, searchTerm: string): boolean { |
||||||
|
const key = this.generateKey(searchType, searchTerm); |
||||||
|
const result = this.cache.get(key); |
||||||
|
return result !== undefined && !this.isExpired(result); |
||||||
|
} |
||||||
|
|
||||||
|
/** |
||||||
|
* Clear expired entries from cache |
||||||
|
*/ |
||||||
|
cleanup(): void { |
||||||
|
const now = Date.now(); |
||||||
|
for (const [key, result] of this.cache.entries()) { |
||||||
|
if (this.isExpired(result)) { |
||||||
|
this.cache.delete(key); |
||||||
|
} |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
/** |
||||||
|
* Clear all cache entries |
||||||
|
*/ |
||||||
|
clear(): void { |
||||||
|
this.cache.clear(); |
||||||
|
} |
||||||
|
|
||||||
|
/** |
||||||
|
* Get cache size |
||||||
|
*/ |
||||||
|
size(): number { |
||||||
|
return this.cache.size; |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
export const searchCache = new SearchCache(); |
||||||
|
|
||||||
|
// Clean up expired entries periodically
|
||||||
|
setInterval(() => { |
||||||
|
searchCache.cleanup(); |
||||||
|
}, TIMEOUTS.CACHE_CLEANUP); // Check every minute
|
||||||
@ -0,0 +1,121 @@ |
|||||||
|
/** |
||||||
|
* Search and Event Utility Constants |
||||||
|
*
|
||||||
|
* This file centralizes all magic numbers used throughout the search and event utilities |
||||||
|
* to improve maintainability and reduce code duplication. |
||||||
|
*/ |
||||||
|
|
||||||
|
// Timeout constants (in milliseconds)
|
||||||
|
export const TIMEOUTS = { |
||||||
|
/** Default timeout for event fetching operations */ |
||||||
|
EVENT_FETCH: 10000, |
||||||
|
|
||||||
|
/** Timeout for profile search operations */ |
||||||
|
PROFILE_SEARCH: 15000, |
||||||
|
|
||||||
|
/** Timeout for subscription search operations */ |
||||||
|
SUBSCRIPTION_SEARCH: 30000, |
||||||
|
|
||||||
|
/** Timeout for relay diagnostics */ |
||||||
|
RELAY_DIAGNOSTICS: 5000, |
||||||
|
|
||||||
|
/** Timeout for general operations */ |
||||||
|
GENERAL: 5000, |
||||||
|
|
||||||
|
/** Cache cleanup interval */ |
||||||
|
CACHE_CLEANUP: 60000, |
||||||
|
} as const; |
||||||
|
|
||||||
|
// Cache duration constants (in milliseconds)
|
||||||
|
export const CACHE_DURATIONS = { |
||||||
|
/** Default cache duration for search results */ |
||||||
|
SEARCH_CACHE: 5 * 60 * 1000, // 5 minutes
|
||||||
|
|
||||||
|
/** Cache duration for index events */ |
||||||
|
INDEX_EVENT_CACHE: 10 * 60 * 1000, // 10 minutes
|
||||||
|
} as const; |
||||||
|
|
||||||
|
// Search limits
|
||||||
|
export const SEARCH_LIMITS = { |
||||||
|
/** Limit for specific profile searches (npub, NIP-05) */ |
||||||
|
SPECIFIC_PROFILE: 10, |
||||||
|
|
||||||
|
/** Limit for general profile searches */ |
||||||
|
GENERAL_PROFILE: 500, |
||||||
|
|
||||||
|
/** Limit for community relay checks */ |
||||||
|
COMMUNITY_CHECK: 1, |
||||||
|
|
||||||
|
/** Limit for second-order search results */ |
||||||
|
SECOND_ORDER_RESULTS: 100, |
||||||
|
} as const; |
||||||
|
|
||||||
|
// Nostr event kind ranges
|
||||||
|
export const EVENT_KINDS = { |
||||||
|
/** Replaceable event kinds (0, 3, 10000-19999) */ |
||||||
|
REPLACEABLE: { |
||||||
|
MIN: 0, |
||||||
|
MAX: 19999, |
||||||
|
SPECIFIC: [0, 3], |
||||||
|
}, |
||||||
|
|
||||||
|
/** Parameterized replaceable event kinds (20000-29999) */ |
||||||
|
PARAMETERIZED_REPLACEABLE: { |
||||||
|
MIN: 20000, |
||||||
|
MAX: 29999, |
||||||
|
}, |
||||||
|
|
||||||
|
/** Addressable event kinds (30000-39999) */ |
||||||
|
ADDRESSABLE: { |
||||||
|
MIN: 30000, |
||||||
|
MAX: 39999, |
||||||
|
}, |
||||||
|
|
||||||
|
/** Comment event kind */ |
||||||
|
COMMENT: 1111, |
||||||
|
|
||||||
|
/** Text note event kind */ |
||||||
|
TEXT_NOTE: 1, |
||||||
|
|
||||||
|
/** Profile metadata event kind */ |
||||||
|
PROFILE_METADATA: 0, |
||||||
|
} as const; |
||||||
|
|
||||||
|
// Relay-specific constants
|
||||||
|
export const RELAY_CONSTANTS = { |
||||||
|
/** Request ID for community relay checks */ |
||||||
|
COMMUNITY_REQUEST_ID: 'alexandria-forest', |
||||||
|
|
||||||
|
/** Default relay request kinds for community checks */ |
||||||
|
COMMUNITY_REQUEST_KINDS: [1], |
||||||
|
} as const; |
||||||
|
|
||||||
|
// Time constants
|
||||||
|
export const TIME_CONSTANTS = { |
||||||
|
/** Unix timestamp conversion factor (seconds to milliseconds) */ |
||||||
|
UNIX_TIMESTAMP_FACTOR: 1000, |
||||||
|
|
||||||
|
/** Current timestamp in seconds */ |
||||||
|
CURRENT_TIMESTAMP: Math.floor(Date.now() / 1000), |
||||||
|
} as const; |
||||||
|
|
||||||
|
// Validation constants
|
||||||
|
export const VALIDATION = { |
||||||
|
/** Hex string length for event IDs and pubkeys */ |
||||||
|
HEX_LENGTH: 64, |
||||||
|
|
||||||
|
/** Minimum length for Nostr identifiers */ |
||||||
|
MIN_NOSTR_IDENTIFIER_LENGTH: 4, |
||||||
|
} as const; |
||||||
|
|
||||||
|
// HTTP status codes
|
||||||
|
export const HTTP_STATUS = { |
||||||
|
/** OK status code */ |
||||||
|
OK: 200, |
||||||
|
|
||||||
|
/** Not found status code */ |
||||||
|
NOT_FOUND: 404, |
||||||
|
|
||||||
|
/** Internal server error status code */ |
||||||
|
INTERNAL_SERVER_ERROR: 500, |
||||||
|
} as const;
|
||||||
@ -0,0 +1,69 @@ |
|||||||
|
import { NDKEvent } from '@nostr-dev-kit/ndk'; |
||||||
|
|
||||||
|
/** |
||||||
|
* Extended NostrProfile interface for search results |
||||||
|
*/ |
||||||
|
export interface NostrProfile { |
||||||
|
name?: string; |
||||||
|
displayName?: string; |
||||||
|
nip05?: string; |
||||||
|
picture?: string; |
||||||
|
about?: string; |
||||||
|
banner?: string; |
||||||
|
website?: string; |
||||||
|
lud16?: string; |
||||||
|
pubkey?: string; |
||||||
|
} |
||||||
|
|
||||||
|
/** |
||||||
|
* Search result interface for subscription-based searches |
||||||
|
*/ |
||||||
|
export interface SearchResult { |
||||||
|
events: NDKEvent[]; |
||||||
|
secondOrder: NDKEvent[]; |
||||||
|
tTagEvents: NDKEvent[]; |
||||||
|
eventIds: Set<string>; |
||||||
|
addresses: Set<string>; |
||||||
|
searchType: string; |
||||||
|
searchTerm: string; |
||||||
|
} |
||||||
|
|
||||||
|
/** |
||||||
|
* Profile search result interface |
||||||
|
*/ |
||||||
|
export interface ProfileSearchResult { |
||||||
|
profiles: NostrProfile[]; |
||||||
|
Status: Record<string, boolean>; |
||||||
|
} |
||||||
|
|
||||||
|
/** |
||||||
|
* Search subscription type |
||||||
|
*/ |
||||||
|
export type SearchSubscriptionType = 'd' | 't' | 'n'; |
||||||
|
|
||||||
|
/** |
||||||
|
* Search filter configuration |
||||||
|
*/ |
||||||
|
export interface SearchFilter { |
||||||
|
filter: any; |
||||||
|
subscriptionType: string; |
||||||
|
} |
||||||
|
|
||||||
|
/** |
||||||
|
* Second-order search parameters |
||||||
|
*/ |
||||||
|
export interface SecondOrderSearchParams { |
||||||
|
searchType: 'n' | 'd'; |
||||||
|
firstOrderEvents: NDKEvent[]; |
||||||
|
eventIds?: Set<string>; |
||||||
|
addresses?: Set<string>; |
||||||
|
targetPubkey?: string; |
||||||
|
} |
||||||
|
|
||||||
|
/** |
||||||
|
* Search callback functions |
||||||
|
*/ |
||||||
|
export interface SearchCallbacks { |
||||||
|
onSecondOrderUpdate?: (result: SearchResult) => void; |
||||||
|
onSubscriptionCreated?: (sub: any) => void; |
||||||
|
}
|
||||||
@ -0,0 +1,25 @@ |
|||||||
|
// Re-export all search functionality from modular files
|
||||||
|
export * from './search_types'; |
||||||
|
export * from './search_utils'; |
||||||
|
export * from './community_checker'; |
||||||
|
export * from './profile_search'; |
||||||
|
export * from './event_search'; |
||||||
|
export * from './subscription_search'; |
||||||
|
export * from './search_constants'; |
||||||
|
|
||||||
|
// Legacy exports for backward compatibility
|
||||||
|
export { searchProfiles } from './profile_search'; |
||||||
|
export { searchBySubscription } from './subscription_search'; |
||||||
|
export { searchEvent, searchNip05 } from './event_search'; |
||||||
|
export { checkCommunity } from './community_checker'; |
||||||
|
export {
|
||||||
|
wellKnownUrl,
|
||||||
|
lnurlpWellKnownUrl,
|
||||||
|
isValidNip05Address, |
||||||
|
normalizeSearchTerm, |
||||||
|
fieldMatches, |
||||||
|
nip05Matches, |
||||||
|
COMMON_DOMAINS, |
||||||
|
isEmojiReaction, |
||||||
|
createProfileFromEvent |
||||||
|
} from './search_utils';
|
||||||
@ -0,0 +1,104 @@ |
|||||||
|
/** |
||||||
|
* Generate well-known NIP-05 URL |
||||||
|
*/ |
||||||
|
export function wellKnownUrl(domain: string, name: string): string { |
||||||
|
return `https://${domain}/.well-known/nostr.json?name=${name}`; |
||||||
|
} |
||||||
|
|
||||||
|
/** |
||||||
|
* Generate well-known LNURLp URL for Lightning Network addresses |
||||||
|
*/ |
||||||
|
export function lnurlpWellKnownUrl(domain: string, name: string): string { |
||||||
|
return `https://${domain}/.well-known/lnurlp/${name}`; |
||||||
|
} |
||||||
|
|
||||||
|
/** |
||||||
|
* Validate NIP-05 address format |
||||||
|
*/ |
||||||
|
export function isValidNip05Address(address: string): boolean { |
||||||
|
return /^[a-z0-9._-]+@[a-z0-9.-]+$/i.test(address); |
||||||
|
} |
||||||
|
|
||||||
|
/** |
||||||
|
* Helper function to normalize search terms |
||||||
|
*/ |
||||||
|
export function normalizeSearchTerm(term: string): string { |
||||||
|
return term.toLowerCase().replace(/\s+/g, ''); |
||||||
|
} |
||||||
|
|
||||||
|
/** |
||||||
|
* Helper function to check if a profile field matches the search term |
||||||
|
*/ |
||||||
|
export function fieldMatches(field: string, searchTerm: string): boolean { |
||||||
|
if (!field) return false; |
||||||
|
const fieldLower = field.toLowerCase(); |
||||||
|
const fieldNormalized = fieldLower.replace(/\s+/g, ''); |
||||||
|
const searchTermLower = searchTerm.toLowerCase(); |
||||||
|
const normalizedSearchTerm = normalizeSearchTerm(searchTerm); |
||||||
|
|
||||||
|
// Check exact match
|
||||||
|
if (fieldLower === searchTermLower) return true; |
||||||
|
if (fieldNormalized === normalizedSearchTerm) return true; |
||||||
|
|
||||||
|
// Check if field contains the search term
|
||||||
|
if (fieldLower.includes(searchTermLower)) return true; |
||||||
|
if (fieldNormalized.includes(normalizedSearchTerm)) return true; |
||||||
|
|
||||||
|
// Check individual words (handle spaces in display names)
|
||||||
|
const words = fieldLower.split(/\s+/); |
||||||
|
return words.some(word => word.includes(searchTermLower)); |
||||||
|
} |
||||||
|
|
||||||
|
/** |
||||||
|
* Helper function to check if NIP-05 address matches the search term |
||||||
|
*/ |
||||||
|
export function nip05Matches(nip05: string, searchTerm: string): boolean { |
||||||
|
if (!nip05) return false; |
||||||
|
const nip05Lower = nip05.toLowerCase(); |
||||||
|
const searchTermLower = searchTerm.toLowerCase(); |
||||||
|
const normalizedSearchTerm = normalizeSearchTerm(searchTerm); |
||||||
|
|
||||||
|
// Check if the part before @ contains the search term
|
||||||
|
const atIndex = nip05Lower.indexOf('@'); |
||||||
|
if (atIndex !== -1) { |
||||||
|
const localPart = nip05Lower.substring(0, atIndex); |
||||||
|
const localPartNormalized = localPart.replace(/\s+/g, ''); |
||||||
|
return localPart.includes(searchTermLower) || localPartNormalized.includes(normalizedSearchTerm); |
||||||
|
} |
||||||
|
return false; |
||||||
|
} |
||||||
|
|
||||||
|
/** |
||||||
|
* Common domains for NIP-05 lookups |
||||||
|
*/ |
||||||
|
export const COMMON_DOMAINS = [ |
||||||
|
'gitcitadel.com', |
||||||
|
'theforest.nostr1.com', |
||||||
|
'nostr1.com', |
||||||
|
'nostr.land', |
||||||
|
'sovbit.host' |
||||||
|
] as const; |
||||||
|
|
||||||
|
/** |
||||||
|
* Check if an event is an emoji reaction (kind 7) |
||||||
|
*/ |
||||||
|
export function isEmojiReaction(event: any): boolean { |
||||||
|
return event.kind === 7; |
||||||
|
} |
||||||
|
|
||||||
|
/** |
||||||
|
* Create a profile object from event data |
||||||
|
*/ |
||||||
|
export function createProfileFromEvent(event: any, profileData: any): any { |
||||||
|
return { |
||||||
|
name: profileData.name, |
||||||
|
displayName: profileData.displayName || profileData.display_name, |
||||||
|
nip05: profileData.nip05, |
||||||
|
picture: profileData.picture, |
||||||
|
about: profileData.about, |
||||||
|
banner: profileData.banner, |
||||||
|
website: profileData.website, |
||||||
|
lud16: profileData.lud16, |
||||||
|
pubkey: event.pubkey |
||||||
|
}; |
||||||
|
}
|
||||||
@ -0,0 +1,651 @@ |
|||||||
|
import { ndkInstance } from '$lib/ndk'; |
||||||
|
import { getMatchingTags, getNpubFromNip05 } from '$lib/utils/nostrUtils'; |
||||||
|
import { nip19 } from '$lib/utils/nostrUtils'; |
||||||
|
import { NDKRelaySet, NDKEvent } from '@nostr-dev-kit/ndk'; |
||||||
|
import { searchCache } from '$lib/utils/searchCache'; |
||||||
|
import { communityRelay, profileRelay } from '$lib/consts'; |
||||||
|
import { get } from 'svelte/store'; |
||||||
|
import type { SearchResult, SearchSubscriptionType, SearchFilter, SearchCallbacks, SecondOrderSearchParams } from './search_types'; |
||||||
|
import { fieldMatches, nip05Matches, normalizeSearchTerm, COMMON_DOMAINS, isEmojiReaction } from './search_utils'; |
||||||
|
import { TIMEOUTS, SEARCH_LIMITS } from './search_constants'; |
||||||
|
|
||||||
|
/** |
||||||
|
* Search for events by subscription type (d, t, n) |
||||||
|
*/ |
||||||
|
export async function searchBySubscription( |
||||||
|
searchType: SearchSubscriptionType, |
||||||
|
searchTerm: string, |
||||||
|
callbacks?: SearchCallbacks, |
||||||
|
abortSignal?: AbortSignal |
||||||
|
): Promise<SearchResult> { |
||||||
|
const normalizedSearchTerm = searchTerm.toLowerCase().trim(); |
||||||
|
|
||||||
|
console.log("subscription_search: Starting search:", { searchType, searchTerm, normalizedSearchTerm }); |
||||||
|
|
||||||
|
// Check cache first
|
||||||
|
const cachedResult = searchCache.get(searchType, normalizedSearchTerm); |
||||||
|
if (cachedResult) { |
||||||
|
console.log("subscription_search: Found cached result:", cachedResult); |
||||||
|
return cachedResult; |
||||||
|
} |
||||||
|
|
||||||
|
const ndk = get(ndkInstance); |
||||||
|
if (!ndk) { |
||||||
|
console.error("subscription_search: NDK not initialized"); |
||||||
|
throw new Error('NDK not initialized'); |
||||||
|
} |
||||||
|
|
||||||
|
console.log("subscription_search: NDK initialized, creating search state"); |
||||||
|
const searchState = createSearchState(); |
||||||
|
const cleanup = createCleanupFunction(searchState); |
||||||
|
|
||||||
|
// Set a timeout to force completion after subscription search timeout
|
||||||
|
searchState.timeoutId = setTimeout(() => { |
||||||
|
console.log("subscription_search: Search timeout reached"); |
||||||
|
cleanup(); |
||||||
|
}, TIMEOUTS.SUBSCRIPTION_SEARCH); |
||||||
|
|
||||||
|
// Check for abort signal
|
||||||
|
if (abortSignal?.aborted) { |
||||||
|
console.log("subscription_search: Search aborted"); |
||||||
|
cleanup(); |
||||||
|
throw new Error('Search cancelled'); |
||||||
|
} |
||||||
|
|
||||||
|
const searchFilter = await createSearchFilter(searchType, normalizedSearchTerm); |
||||||
|
console.log("subscription_search: Created search filter:", searchFilter); |
||||||
|
const primaryRelaySet = createPrimaryRelaySet(searchType, ndk); |
||||||
|
console.log("subscription_search: Created primary relay set with", primaryRelaySet.relays.size, "relays"); |
||||||
|
|
||||||
|
// Phase 1: Search primary relay
|
||||||
|
if (primaryRelaySet.relays.size > 0) { |
||||||
|
try { |
||||||
|
console.log("subscription_search: Searching primary relay with filter:", searchFilter.filter); |
||||||
|
const primaryEvents = await ndk.fetchEvents( |
||||||
|
searchFilter.filter, |
||||||
|
{ closeOnEose: true }, |
||||||
|
primaryRelaySet |
||||||
|
); |
||||||
|
|
||||||
|
console.log("subscription_search: Primary relay returned", primaryEvents.size, "events"); |
||||||
|
processPrimaryRelayResults(primaryEvents, searchType, searchFilter.subscriptionType, normalizedSearchTerm, searchState, abortSignal, cleanup); |
||||||
|
|
||||||
|
// If we found results from primary relay, return them immediately
|
||||||
|
if (hasResults(searchState, searchType)) { |
||||||
|
console.log("subscription_search: Found results from primary relay, returning immediately"); |
||||||
|
const immediateResult = createSearchResult(searchState, searchType, normalizedSearchTerm); |
||||||
|
searchCache.set(searchType, normalizedSearchTerm, immediateResult); |
||||||
|
|
||||||
|
// Start Phase 2 in background for additional results
|
||||||
|
searchOtherRelaysInBackground(searchType, searchFilter, searchState, callbacks, abortSignal, cleanup); |
||||||
|
|
||||||
|
return immediateResult; |
||||||
|
} else { |
||||||
|
console.log("subscription_search: No results from primary relay, continuing to Phase 2"); |
||||||
|
} |
||||||
|
} catch (error) { |
||||||
|
console.error(`subscription_search: Error searching primary relay:`, error); |
||||||
|
} |
||||||
|
} else { |
||||||
|
console.log("subscription_search: No primary relays available, skipping Phase 1"); |
||||||
|
} |
||||||
|
|
||||||
|
// Always do Phase 2: Search all other relays in parallel
|
||||||
|
return searchOtherRelaysInBackground(searchType, searchFilter, searchState, callbacks, abortSignal, cleanup); |
||||||
|
} |
||||||
|
|
||||||
|
/** |
||||||
|
* Create search state object |
||||||
|
*/ |
||||||
|
function createSearchState() { |
||||||
|
return { |
||||||
|
timeoutId: null as ReturnType<typeof setTimeout> | null, |
||||||
|
firstOrderEvents: [] as NDKEvent[], |
||||||
|
secondOrderEvents: [] as NDKEvent[], |
||||||
|
tTagEvents: [] as NDKEvent[], |
||||||
|
eventIds: new Set<string>(), |
||||||
|
eventAddresses: new Set<string>(), |
||||||
|
foundProfiles: [] as NDKEvent[], |
||||||
|
isCompleted: false, |
||||||
|
currentSubscription: null as any |
||||||
|
}; |
||||||
|
} |
||||||
|
|
||||||
|
/** |
||||||
|
* Create cleanup function |
||||||
|
*/ |
||||||
|
function createCleanupFunction(searchState: any) { |
||||||
|
return () => { |
||||||
|
if (searchState.timeoutId) { |
||||||
|
clearTimeout(searchState.timeoutId); |
||||||
|
searchState.timeoutId = null; |
||||||
|
} |
||||||
|
if (searchState.currentSubscription) { |
||||||
|
try { |
||||||
|
searchState.currentSubscription.stop(); |
||||||
|
} catch (e) { |
||||||
|
console.warn('Error stopping subscription:', e); |
||||||
|
} |
||||||
|
searchState.currentSubscription = null; |
||||||
|
} |
||||||
|
}; |
||||||
|
} |
||||||
|
|
||||||
|
/** |
||||||
|
* Create search filter based on search type |
||||||
|
*/ |
||||||
|
async function createSearchFilter(searchType: SearchSubscriptionType, normalizedSearchTerm: string): Promise<SearchFilter> { |
||||||
|
console.log("subscription_search: Creating search filter for:", { searchType, normalizedSearchTerm }); |
||||||
|
|
||||||
|
switch (searchType) { |
||||||
|
case 'd': |
||||||
|
const dFilter = { |
||||||
|
filter: { "#d": [normalizedSearchTerm] }, |
||||||
|
subscriptionType: 'd-tag' |
||||||
|
}; |
||||||
|
console.log("subscription_search: Created d-tag filter:", dFilter); |
||||||
|
return dFilter; |
||||||
|
case 't': |
||||||
|
const tFilter = { |
||||||
|
filter: { "#t": [normalizedSearchTerm] }, |
||||||
|
subscriptionType: 't-tag' |
||||||
|
}; |
||||||
|
console.log("subscription_search: Created t-tag filter:", tFilter); |
||||||
|
return tFilter; |
||||||
|
case 'n': |
||||||
|
const nFilter = await createProfileSearchFilter(normalizedSearchTerm); |
||||||
|
console.log("subscription_search: Created profile filter:", nFilter); |
||||||
|
return nFilter; |
||||||
|
default: |
||||||
|
throw new Error(`Unknown search type: ${searchType}`); |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
/** |
||||||
|
* Create profile search filter |
||||||
|
*/ |
||||||
|
async function createProfileSearchFilter(normalizedSearchTerm: string): Promise<SearchFilter> { |
||||||
|
// For npub searches, try to decode the search term first
|
||||||
|
try { |
||||||
|
const decoded = nip19.decode(normalizedSearchTerm); |
||||||
|
if (decoded && decoded.type === 'npub') { |
||||||
|
return { |
||||||
|
filter: { kinds: [0], authors: [decoded.data], limit: SEARCH_LIMITS.SPECIFIC_PROFILE }, |
||||||
|
subscriptionType: 'npub-specific' |
||||||
|
}; |
||||||
|
} |
||||||
|
} catch (e) { |
||||||
|
// Not a valid npub, continue with other strategies
|
||||||
|
} |
||||||
|
|
||||||
|
// Try NIP-05 lookup first
|
||||||
|
try { |
||||||
|
for (const domain of COMMON_DOMAINS) { |
||||||
|
const nip05Address = `${normalizedSearchTerm}@${domain}`; |
||||||
|
try { |
||||||
|
const npub = await getNpubFromNip05(nip05Address); |
||||||
|
if (npub) { |
||||||
|
return { |
||||||
|
filter: { kinds: [0], authors: [npub], limit: SEARCH_LIMITS.SPECIFIC_PROFILE }, |
||||||
|
subscriptionType: 'nip05-found' |
||||||
|
}; |
||||||
|
} |
||||||
|
} catch (e) { |
||||||
|
// Continue to next domain
|
||||||
|
} |
||||||
|
} |
||||||
|
} catch (e) { |
||||||
|
// Fallback to reasonable profile search
|
||||||
|
} |
||||||
|
|
||||||
|
return { |
||||||
|
filter: { kinds: [0], limit: SEARCH_LIMITS.GENERAL_PROFILE }, |
||||||
|
subscriptionType: 'profile' |
||||||
|
}; |
||||||
|
} |
||||||
|
|
||||||
|
/** |
||||||
|
* Create primary relay set based on search type |
||||||
|
*/ |
||||||
|
function createPrimaryRelaySet(searchType: SearchSubscriptionType, ndk: any): NDKRelaySet { |
||||||
|
if (searchType === 'n') { |
||||||
|
// For profile searches, use profile relay first
|
||||||
|
const profileRelays = Array.from(ndk.pool.relays.values()).filter((relay: any) =>
|
||||||
|
relay.url === profileRelay || relay.url === profileRelay + '/' |
||||||
|
); |
||||||
|
return new NDKRelaySet(new Set(profileRelays) as any, ndk); |
||||||
|
} else { |
||||||
|
// For other searches, use community relay first
|
||||||
|
const communityRelays = Array.from(ndk.pool.relays.values()).filter((relay: any) =>
|
||||||
|
relay.url === communityRelay || relay.url === communityRelay + '/' |
||||||
|
); |
||||||
|
return new NDKRelaySet(new Set(communityRelays) as any, ndk); |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
/** |
||||||
|
* Process primary relay results |
||||||
|
*/ |
||||||
|
function processPrimaryRelayResults( |
||||||
|
events: Set<NDKEvent>, |
||||||
|
searchType: SearchSubscriptionType, |
||||||
|
subscriptionType: string, |
||||||
|
normalizedSearchTerm: string, |
||||||
|
searchState: any, |
||||||
|
abortSignal?: AbortSignal, |
||||||
|
cleanup?: () => void |
||||||
|
) { |
||||||
|
console.log("subscription_search: Processing", events.size, "events from primary relay"); |
||||||
|
|
||||||
|
for (const event of events) { |
||||||
|
// Check for abort signal
|
||||||
|
if (abortSignal?.aborted) { |
||||||
|
cleanup?.(); |
||||||
|
throw new Error('Search cancelled'); |
||||||
|
} |
||||||
|
|
||||||
|
try { |
||||||
|
if (searchType === 'n') { |
||||||
|
processProfileEvent(event, subscriptionType, normalizedSearchTerm, searchState); |
||||||
|
} else { |
||||||
|
processContentEvent(event, searchType, searchState); |
||||||
|
} |
||||||
|
} catch (e) { |
||||||
|
console.warn("subscription_search: Error processing event:", e); |
||||||
|
// Invalid JSON or other error, skip
|
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
console.log("subscription_search: Processed events - firstOrder:", searchState.firstOrderEvents.length, "profiles:", searchState.foundProfiles.length, "tTag:", searchState.tTagEvents.length); |
||||||
|
} |
||||||
|
|
||||||
|
/** |
||||||
|
* Process profile event |
||||||
|
*/ |
||||||
|
function processProfileEvent(event: NDKEvent, subscriptionType: string, normalizedSearchTerm: string, searchState: any) { |
||||||
|
if (!event.content) return; |
||||||
|
|
||||||
|
// If this is a specific npub search or NIP-05 found search, include all matching events
|
||||||
|
if (subscriptionType === 'npub-specific' || subscriptionType === 'nip05-found') { |
||||||
|
searchState.foundProfiles.push(event); |
||||||
|
return; |
||||||
|
} |
||||||
|
|
||||||
|
// For general profile searches, filter by content
|
||||||
|
const profileData = JSON.parse(event.content); |
||||||
|
const displayName = profileData.display_name || profileData.displayName || ''; |
||||||
|
const name = profileData.name || ''; |
||||||
|
const nip05 = profileData.nip05 || ''; |
||||||
|
const username = profileData.username || ''; |
||||||
|
const about = profileData.about || ''; |
||||||
|
const bio = profileData.bio || ''; |
||||||
|
const description = profileData.description || ''; |
||||||
|
|
||||||
|
const matchesDisplayName = fieldMatches(displayName, normalizedSearchTerm); |
||||||
|
const matchesName = fieldMatches(name, normalizedSearchTerm); |
||||||
|
const matchesNip05 = nip05Matches(nip05, normalizedSearchTerm); |
||||||
|
const matchesUsername = fieldMatches(username, normalizedSearchTerm); |
||||||
|
const matchesAbout = fieldMatches(about, normalizedSearchTerm); |
||||||
|
const matchesBio = fieldMatches(bio, normalizedSearchTerm); |
||||||
|
const matchesDescription = fieldMatches(description, normalizedSearchTerm); |
||||||
|
|
||||||
|
if (matchesDisplayName || matchesName || matchesNip05 || matchesUsername || matchesAbout || matchesBio || matchesDescription) { |
||||||
|
searchState.foundProfiles.push(event); |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
/** |
||||||
|
* Process content event |
||||||
|
*/ |
||||||
|
function processContentEvent(event: NDKEvent, searchType: SearchSubscriptionType, searchState: any) { |
||||||
|
if (isEmojiReaction(event)) return; // Skip emoji reactions
|
||||||
|
|
||||||
|
if (searchType === 'd') { |
||||||
|
console.log("subscription_search: Processing d-tag event:", { id: event.id, kind: event.kind, pubkey: event.pubkey }); |
||||||
|
searchState.firstOrderEvents.push(event); |
||||||
|
|
||||||
|
// Collect event IDs and addresses for second-order search
|
||||||
|
if (event.id) { |
||||||
|
searchState.eventIds.add(event.id); |
||||||
|
} |
||||||
|
const aTags = getMatchingTags(event, "a"); |
||||||
|
aTags.forEach((tag: string[]) => { |
||||||
|
if (tag[1]) { |
||||||
|
searchState.eventAddresses.add(tag[1]); |
||||||
|
} |
||||||
|
}); |
||||||
|
} else if (searchType === 't') { |
||||||
|
searchState.tTagEvents.push(event); |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
/** |
||||||
|
* Check if search state has results |
||||||
|
*/ |
||||||
|
function hasResults(searchState: any, searchType: SearchSubscriptionType): boolean { |
||||||
|
if (searchType === 'n') { |
||||||
|
return searchState.foundProfiles.length > 0; |
||||||
|
} else if (searchType === 'd') { |
||||||
|
return searchState.firstOrderEvents.length > 0; |
||||||
|
} else if (searchType === 't') { |
||||||
|
return searchState.tTagEvents.length > 0; |
||||||
|
} |
||||||
|
return false; |
||||||
|
} |
||||||
|
|
||||||
|
/** |
||||||
|
* Create search result from state |
||||||
|
*/ |
||||||
|
function createSearchResult(searchState: any, searchType: SearchSubscriptionType, normalizedSearchTerm: string): SearchResult { |
||||||
|
return { |
||||||
|
events: searchType === 'n' ? searchState.foundProfiles : searchState.firstOrderEvents, |
||||||
|
secondOrder: [], |
||||||
|
tTagEvents: searchType === 't' ? searchState.tTagEvents : [], |
||||||
|
eventIds: searchState.eventIds, |
||||||
|
addresses: searchState.eventAddresses, |
||||||
|
searchType: searchType, |
||||||
|
searchTerm: normalizedSearchTerm |
||||||
|
}; |
||||||
|
} |
||||||
|
|
||||||
|
/** |
||||||
|
* Search other relays in background |
||||||
|
*/ |
||||||
|
async function searchOtherRelaysInBackground( |
||||||
|
searchType: SearchSubscriptionType, |
||||||
|
searchFilter: SearchFilter, |
||||||
|
searchState: any, |
||||||
|
callbacks?: SearchCallbacks, |
||||||
|
abortSignal?: AbortSignal, |
||||||
|
cleanup?: () => void |
||||||
|
): Promise<SearchResult> { |
||||||
|
const ndk = get(ndkInstance); |
||||||
|
|
||||||
|
const otherRelays = new NDKRelaySet( |
||||||
|
new Set(Array.from(ndk.pool.relays.values()).filter((relay: any) => { |
||||||
|
if (searchType === 'n') { |
||||||
|
// For profile searches, exclude profile relay from fallback search
|
||||||
|
return relay.url !== profileRelay && relay.url !== profileRelay + '/'; |
||||||
|
} else { |
||||||
|
// For other searches, exclude community relay from fallback search
|
||||||
|
return relay.url !== communityRelay && relay.url !== communityRelay + '/'; |
||||||
|
} |
||||||
|
})), |
||||||
|
ndk |
||||||
|
); |
||||||
|
|
||||||
|
// Subscribe to events from other relays
|
||||||
|
const sub = ndk.subscribe( |
||||||
|
searchFilter.filter, |
||||||
|
{ closeOnEose: true }, |
||||||
|
otherRelays |
||||||
|
); |
||||||
|
|
||||||
|
// Store the subscription for cleanup
|
||||||
|
searchState.currentSubscription = sub; |
||||||
|
|
||||||
|
// Notify the component about the subscription for cleanup
|
||||||
|
if (callbacks?.onSubscriptionCreated) { |
||||||
|
callbacks.onSubscriptionCreated(sub); |
||||||
|
} |
||||||
|
|
||||||
|
sub.on('event', (event: NDKEvent) => { |
||||||
|
try { |
||||||
|
if (searchType === 'n') { |
||||||
|
processProfileEvent(event, searchFilter.subscriptionType, searchState.normalizedSearchTerm, searchState); |
||||||
|
} else { |
||||||
|
processContentEvent(event, searchType, searchState); |
||||||
|
} |
||||||
|
} catch (e) { |
||||||
|
// Invalid JSON or other error, skip
|
||||||
|
} |
||||||
|
}); |
||||||
|
|
||||||
|
return new Promise<SearchResult>((resolve) => { |
||||||
|
sub.on('eose', () => { |
||||||
|
const result = processEoseResults(searchType, searchState, searchFilter, callbacks); |
||||||
|
searchCache.set(searchType, searchState.normalizedSearchTerm, result); |
||||||
|
cleanup?.(); |
||||||
|
resolve(result); |
||||||
|
}); |
||||||
|
}); |
||||||
|
} |
||||||
|
|
||||||
|
/** |
||||||
|
* Process EOSE results |
||||||
|
*/ |
||||||
|
function processEoseResults( |
||||||
|
searchType: SearchSubscriptionType, |
||||||
|
searchState: any, |
||||||
|
searchFilter: SearchFilter, |
||||||
|
callbacks?: SearchCallbacks |
||||||
|
): SearchResult { |
||||||
|
if (searchType === 'n') { |
||||||
|
return processProfileEoseResults(searchState, searchFilter, callbacks); |
||||||
|
} else if (searchType === 'd') { |
||||||
|
return processContentEoseResults(searchState, searchType); |
||||||
|
} else if (searchType === 't') { |
||||||
|
return processTTagEoseResults(searchState); |
||||||
|
} |
||||||
|
|
||||||
|
return createEmptySearchResult(searchType, searchState.normalizedSearchTerm); |
||||||
|
} |
||||||
|
|
||||||
|
/** |
||||||
|
* Process profile EOSE results |
||||||
|
*/ |
||||||
|
function processProfileEoseResults(searchState: any, searchFilter: SearchFilter, callbacks?: SearchCallbacks): SearchResult { |
||||||
|
if (searchState.foundProfiles.length === 0) { |
||||||
|
return createEmptySearchResult('n', searchState.normalizedSearchTerm); |
||||||
|
} |
||||||
|
|
||||||
|
// Deduplicate by pubkey, keep only newest
|
||||||
|
const deduped: Record<string, { event: NDKEvent; created_at: number }> = {}; |
||||||
|
for (const event of searchState.foundProfiles) { |
||||||
|
const pubkey = event.pubkey; |
||||||
|
const created_at = event.created_at || 0; |
||||||
|
if (!deduped[pubkey] || deduped[pubkey].created_at < created_at) { |
||||||
|
deduped[pubkey] = { event, created_at }; |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
// Sort by creation time (newest first) and take only the most recent profiles
|
||||||
|
const dedupedProfiles = Object.values(deduped) |
||||||
|
.sort((a, b) => b.created_at - a.created_at) |
||||||
|
.map(x => x.event); |
||||||
|
|
||||||
|
// Perform second-order search for npub searches
|
||||||
|
if (searchFilter.subscriptionType === 'npub-specific' || searchFilter.subscriptionType === 'nip05-found') { |
||||||
|
const targetPubkey = dedupedProfiles[0]?.pubkey; |
||||||
|
if (targetPubkey) { |
||||||
|
performSecondOrderSearchInBackground('n', dedupedProfiles, new Set(), new Set(), targetPubkey, callbacks); |
||||||
|
} |
||||||
|
} else if (searchFilter.subscriptionType === 'profile') { |
||||||
|
// For general profile searches, perform second-order search for each found profile
|
||||||
|
for (const profile of dedupedProfiles) { |
||||||
|
if (profile.pubkey) { |
||||||
|
performSecondOrderSearchInBackground('n', dedupedProfiles, new Set(), new Set(), profile.pubkey, callbacks); |
||||||
|
} |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
return { |
||||||
|
events: dedupedProfiles, |
||||||
|
secondOrder: [], |
||||||
|
tTagEvents: [], |
||||||
|
eventIds: new Set(dedupedProfiles.map(p => p.id)), |
||||||
|
addresses: new Set(), |
||||||
|
searchType: 'n', |
||||||
|
searchTerm: searchState.normalizedSearchTerm |
||||||
|
}; |
||||||
|
} |
||||||
|
|
||||||
|
/** |
||||||
|
* Process content EOSE results |
||||||
|
*/ |
||||||
|
function processContentEoseResults(searchState: any, searchType: SearchSubscriptionType): SearchResult { |
||||||
|
if (searchState.firstOrderEvents.length === 0) { |
||||||
|
return createEmptySearchResult(searchType, searchState.normalizedSearchTerm); |
||||||
|
} |
||||||
|
|
||||||
|
// Deduplicate by kind, pubkey, and d-tag, keep only newest event for each combination
|
||||||
|
const deduped: Record<string, { event: NDKEvent; created_at: number }> = {}; |
||||||
|
for (const event of searchState.firstOrderEvents) { |
||||||
|
const dTag = getMatchingTags(event, 'd')[0]?.[1] || ''; |
||||||
|
const key = `${event.kind}:${event.pubkey}:${dTag}`; |
||||||
|
const created_at = event.created_at || 0; |
||||||
|
if (!deduped[key] || deduped[key].created_at < created_at) { |
||||||
|
deduped[key] = { event, created_at }; |
||||||
|
} |
||||||
|
} |
||||||
|
const dedupedEvents = Object.values(deduped).map(x => x.event); |
||||||
|
|
||||||
|
// Perform second-order search for d-tag searches
|
||||||
|
if (dedupedEvents.length > 0) { |
||||||
|
performSecondOrderSearchInBackground('d', dedupedEvents, searchState.eventIds, searchState.eventAddresses); |
||||||
|
} |
||||||
|
|
||||||
|
return { |
||||||
|
events: dedupedEvents, |
||||||
|
secondOrder: [], |
||||||
|
tTagEvents: [], |
||||||
|
eventIds: searchState.eventIds, |
||||||
|
addresses: searchState.eventAddresses, |
||||||
|
searchType: searchType, |
||||||
|
searchTerm: searchState.normalizedSearchTerm |
||||||
|
}; |
||||||
|
} |
||||||
|
|
||||||
|
/** |
||||||
|
* Process t-tag EOSE results |
||||||
|
*/ |
||||||
|
function processTTagEoseResults(searchState: any): SearchResult { |
||||||
|
if (searchState.tTagEvents.length === 0) { |
||||||
|
return createEmptySearchResult('t', searchState.normalizedSearchTerm); |
||||||
|
} |
||||||
|
|
||||||
|
return { |
||||||
|
events: [], |
||||||
|
secondOrder: [], |
||||||
|
tTagEvents: searchState.tTagEvents, |
||||||
|
eventIds: new Set(), |
||||||
|
addresses: new Set(), |
||||||
|
searchType: 't', |
||||||
|
searchTerm: searchState.normalizedSearchTerm |
||||||
|
}; |
||||||
|
} |
||||||
|
|
||||||
|
/** |
||||||
|
* Create empty search result |
||||||
|
*/ |
||||||
|
function createEmptySearchResult(searchType: SearchSubscriptionType, searchTerm: string): SearchResult { |
||||||
|
return { |
||||||
|
events: [], |
||||||
|
secondOrder: [], |
||||||
|
tTagEvents: [], |
||||||
|
eventIds: new Set(), |
||||||
|
addresses: new Set(), |
||||||
|
searchType: searchType, |
||||||
|
searchTerm: searchTerm |
||||||
|
}; |
||||||
|
} |
||||||
|
|
||||||
|
/** |
||||||
|
* Perform second-order search in background |
||||||
|
*/ |
||||||
|
async function performSecondOrderSearchInBackground( |
||||||
|
searchType: 'n' | 'd', |
||||||
|
firstOrderEvents: NDKEvent[], |
||||||
|
eventIds: Set<string> = new Set(), |
||||||
|
addresses: Set<string> = new Set(), |
||||||
|
targetPubkey?: string, |
||||||
|
callbacks?: SearchCallbacks |
||||||
|
) { |
||||||
|
try { |
||||||
|
const ndk = get(ndkInstance); |
||||||
|
let allSecondOrderEvents: NDKEvent[] = []; |
||||||
|
|
||||||
|
if (searchType === 'n' && targetPubkey) { |
||||||
|
// Search for events that mention this pubkey via p-tags
|
||||||
|
const pTagFilter = { "#p": [targetPubkey] }; |
||||||
|
const pTagEvents = await ndk.fetchEvents( |
||||||
|
pTagFilter, |
||||||
|
{ closeOnEose: true }, |
||||||
|
new NDKRelaySet(new Set(Array.from(ndk.pool.relays.values())), ndk), |
||||||
|
); |
||||||
|
|
||||||
|
// Filter out emoji reactions
|
||||||
|
const filteredEvents = Array.from(pTagEvents).filter(event => !isEmojiReaction(event)); |
||||||
|
allSecondOrderEvents = [...allSecondOrderEvents, ...filteredEvents]; |
||||||
|
|
||||||
|
} else if (searchType === 'd') { |
||||||
|
// Search for events that reference the original events via e-tags and a-tags
|
||||||
|
|
||||||
|
// Search for events that reference the original events via e-tags
|
||||||
|
if (eventIds.size > 0) { |
||||||
|
const eTagFilter = { "#e": Array.from(eventIds) }; |
||||||
|
const eTagEvents = await ndk.fetchEvents( |
||||||
|
eTagFilter, |
||||||
|
{ closeOnEose: true }, |
||||||
|
new NDKRelaySet(new Set(Array.from(ndk.pool.relays.values())), ndk), |
||||||
|
); |
||||||
|
|
||||||
|
// Filter out emoji reactions
|
||||||
|
const filteredETagEvents = Array.from(eTagEvents).filter(event => !isEmojiReaction(event)); |
||||||
|
allSecondOrderEvents = [...allSecondOrderEvents, ...filteredETagEvents]; |
||||||
|
} |
||||||
|
|
||||||
|
// Search for events that reference the original events via a-tags
|
||||||
|
if (addresses.size > 0) { |
||||||
|
const aTagFilter = { "#a": Array.from(addresses) }; |
||||||
|
const aTagEvents = await ndk.fetchEvents( |
||||||
|
aTagFilter, |
||||||
|
{ closeOnEose: true }, |
||||||
|
new NDKRelaySet(new Set(Array.from(ndk.pool.relays.values())), ndk), |
||||||
|
); |
||||||
|
|
||||||
|
// Filter out emoji reactions
|
||||||
|
const filteredATagEvents = Array.from(aTagEvents).filter(event => !isEmojiReaction(event)); |
||||||
|
allSecondOrderEvents = [...allSecondOrderEvents, ...filteredATagEvents]; |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
// Deduplicate by event ID
|
||||||
|
const uniqueSecondOrder = new Map<string, NDKEvent>(); |
||||||
|
allSecondOrderEvents.forEach(event => { |
||||||
|
if (event.id) { |
||||||
|
uniqueSecondOrder.set(event.id, event); |
||||||
|
} |
||||||
|
}); |
||||||
|
|
||||||
|
let deduplicatedSecondOrder = Array.from(uniqueSecondOrder.values()); |
||||||
|
|
||||||
|
// Remove any events already in first order
|
||||||
|
const firstOrderIds = new Set(firstOrderEvents.map(e => e.id)); |
||||||
|
deduplicatedSecondOrder = deduplicatedSecondOrder.filter(e => !firstOrderIds.has(e.id)); |
||||||
|
|
||||||
|
// Sort by creation date (newest first) and limit to newest results
|
||||||
|
const sortedSecondOrder = deduplicatedSecondOrder |
||||||
|
.sort((a, b) => (b.created_at || 0) - (a.created_at || 0)) |
||||||
|
.slice(0, SEARCH_LIMITS.SECOND_ORDER_RESULTS); |
||||||
|
|
||||||
|
// Update the search results with second-order events
|
||||||
|
const result: SearchResult = { |
||||||
|
events: firstOrderEvents, |
||||||
|
secondOrder: sortedSecondOrder, |
||||||
|
tTagEvents: [], |
||||||
|
eventIds: searchType === 'n' ? new Set(firstOrderEvents.map(p => p.id)) : eventIds, |
||||||
|
addresses: searchType === 'n' ? new Set() : addresses, |
||||||
|
searchType: searchType, |
||||||
|
searchTerm: '' // This will be set by the caller
|
||||||
|
}; |
||||||
|
|
||||||
|
// Notify UI of updated results
|
||||||
|
if (callbacks?.onSecondOrderUpdate) { |
||||||
|
callbacks.onSecondOrderUpdate(result); |
||||||
|
} |
||||||
|
|
||||||
|
} catch (err) { |
||||||
|
console.error(`[Search] Error in second-order ${searchType}-tag search:`, err); |
||||||
|
} |
||||||
|
}
|
||||||
@ -1,66 +1,94 @@ |
|||||||
<script lang='ts'> |
<script lang="ts"> |
||||||
import { FeedType, feedTypeStorageKey, standardRelays, fallbackRelays } from '$lib/consts'; |
import { |
||||||
|
FeedType, |
||||||
|
feedTypeStorageKey, |
||||||
|
standardRelays, |
||||||
|
fallbackRelays, |
||||||
|
} from "$lib/consts"; |
||||||
import { Alert, Button, Dropdown, Radio, Input } from "flowbite-svelte"; |
import { Alert, Button, Dropdown, Radio, Input } from "flowbite-svelte"; |
||||||
import { ChevronDownOutline, HammerSolid } from "flowbite-svelte-icons"; |
import { ChevronDownOutline, HammerSolid } from "flowbite-svelte-icons"; |
||||||
import { inboxRelays, ndkSignedIn } from '$lib/ndk'; |
import { inboxRelays, ndkSignedIn } from "$lib/ndk"; |
||||||
import PublicationFeed from '$lib/components/PublicationFeed.svelte'; |
import PublicationFeed from "$lib/components/PublicationFeed.svelte"; |
||||||
import { feedType } from '$lib/stores'; |
import { feedType } from "$lib/stores"; |
||||||
|
|
||||||
$effect(() => { |
$effect(() => { |
||||||
localStorage.setItem(feedTypeStorageKey, $feedType); |
localStorage.setItem(feedTypeStorageKey, $feedType); |
||||||
}); |
}); |
||||||
|
|
||||||
|
$effect(() => { |
||||||
|
if (!$ndkSignedIn && $feedType !== FeedType.StandardRelays) { |
||||||
|
feedType.set(FeedType.StandardRelays); |
||||||
|
} |
||||||
|
}); |
||||||
|
|
||||||
const getFeedTypeFriendlyName = (feedType: FeedType): string => { |
const getFeedTypeFriendlyName = (feedType: FeedType): string => { |
||||||
switch (feedType) { |
switch (feedType) { |
||||||
case FeedType.StandardRelays: |
case FeedType.StandardRelays: |
||||||
return `Alexandria's Relays`; |
return `Alexandria's Relays`; |
||||||
case FeedType.UserRelays: |
case FeedType.UserRelays: |
||||||
return `Your Relays`; |
return `Your Relays`; |
||||||
default: |
default: |
||||||
return ''; |
return ""; |
||||||
} |
} |
||||||
}; |
}; |
||||||
|
|
||||||
let searchQuery = $state(''); |
let searchQuery = $state(""); |
||||||
</script> |
</script> |
||||||
|
|
||||||
<Alert rounded={false} id="alert-experimental" class='border-t-4 border-primary-500 text-gray-900 dark:text-gray-100 dark:border-primary-500 flex justify-left mb-2'> |
<Alert |
||||||
<HammerSolid class='mr-2 h-5 w-5 text-primary-500 dark:text-primary-500' /> |
rounded={false} |
||||||
<span class='font-medium'> |
id="alert-experimental" |
||||||
Pardon our dust! The publication view is currently using an experimental loader, and may be unstable. |
class="border-t-4 border-primary-600 text-gray-900 dark:text-gray-100 dark:border-primary-500 flex justify-left mb-2" |
||||||
</span> |
> |
||||||
|
<HammerSolid class="mr-2 h-5 w-5 text-primary-500 dark:text-primary-500" /> |
||||||
|
<span class="font-medium"> |
||||||
|
Pardon our dust! The publication view is currently using an experimental |
||||||
|
loader, and may be unstable. |
||||||
|
</span> |
||||||
</Alert> |
</Alert> |
||||||
|
|
||||||
<main class='leather flex flex-col flex-grow-0 space-y-4 p-4'> |
<main class="leather flex flex-col flex-grow-0 space-y-4 p-4"> |
||||||
{#if !$ndkSignedIn} |
<div |
||||||
<PublicationFeed relays={standardRelays} fallbackRelays={fallbackRelays} searchQuery={searchQuery} /> |
class="leather w-full flex flex-row items-center justify-center gap-4 mb-4" |
||||||
{:else} |
> |
||||||
<div class='leather w-full flex flex-row items-center justify-center gap-4 mb-4'> |
<Button id="feed-toggle-btn" class="min-w-[220px] max-w-sm"> |
||||||
<Button id="feed-toggle-btn" class="min-w-[220px] max-w-sm"> |
{`Showing publications from: ${getFeedTypeFriendlyName($feedType)}`} |
||||||
{`Showing publications from: ${getFeedTypeFriendlyName($feedType)}`} |
{#if $ndkSignedIn} |
||||||
<ChevronDownOutline class='w-6 h-6' /> |
<ChevronDownOutline class="w-6 h-6" /> |
||||||
</Button> |
{/if} |
||||||
<Input |
</Button> |
||||||
bind:value={searchQuery} |
<Input |
||||||
placeholder="Search publications by title or author..." |
bind:value={searchQuery} |
||||||
class="flex-grow max-w-2xl min-w-[300px] text-base" |
placeholder="Search publications by title or author..." |
||||||
/> |
class="flex-grow max-w-2xl min-w-[300px] text-base" |
||||||
|
/> |
||||||
|
{#if $ndkSignedIn} |
||||||
<Dropdown |
<Dropdown |
||||||
class='w-fit p-2 space-y-2 text-sm' |
class="w-fit p-2 space-y-2 text-sm" |
||||||
triggeredBy="#feed-toggle-btn" |
triggeredBy="#feed-toggle-btn" |
||||||
> |
> |
||||||
<li> |
<li> |
||||||
<Radio name='relays' bind:group={$feedType} value={FeedType.StandardRelays}>Alexandria's Relays</Radio> |
<Radio |
||||||
|
name="relays" |
||||||
|
bind:group={$feedType} |
||||||
|
value={FeedType.StandardRelays}>Alexandria's Relays</Radio |
||||||
|
> |
||||||
</li> |
</li> |
||||||
<li> |
<li> |
||||||
<Radio name='follows' bind:group={$feedType} value={FeedType.UserRelays}>Your Relays</Radio> |
<Radio |
||||||
|
name="follows" |
||||||
|
bind:group={$feedType} |
||||||
|
value={FeedType.UserRelays}>Your Relays</Radio |
||||||
|
> |
||||||
</li> |
</li> |
||||||
</Dropdown> |
</Dropdown> |
||||||
</div> |
|
||||||
{#if $feedType === FeedType.StandardRelays} |
|
||||||
<PublicationFeed relays={standardRelays} fallbackRelays={fallbackRelays} searchQuery={searchQuery} /> |
|
||||||
{:else if $feedType === FeedType.UserRelays} |
|
||||||
<PublicationFeed relays={$inboxRelays} fallbackRelays={fallbackRelays} searchQuery={searchQuery} /> |
|
||||||
{/if} |
{/if} |
||||||
|
</div> |
||||||
|
{#if !$ndkSignedIn} |
||||||
|
<PublicationFeed relays={standardRelays} {fallbackRelays} {searchQuery} /> |
||||||
|
{:else if $feedType === FeedType.StandardRelays} |
||||||
|
<PublicationFeed relays={standardRelays} {fallbackRelays} {searchQuery} /> |
||||||
|
{:else if $feedType === FeedType.UserRelays} |
||||||
|
<PublicationFeed relays={$inboxRelays} {fallbackRelays} {searchQuery} /> |
||||||
{/if} |
{/if} |
||||||
</main> |
</main> |
||||||
|
|||||||
@ -1,13 +1,23 @@ |
|||||||
<script lang="ts"> |
<script lang="ts"> |
||||||
import { goto } from '$app/navigation'; |
import { goto } from "$app/navigation"; |
||||||
import { Button, P } from 'flowbite-svelte'; |
import { Button, P } from "flowbite-svelte"; |
||||||
</script> |
</script> |
||||||
|
|
||||||
<div class="leather flex flex-col items-center justify-center min-h-screen text-center px-4"> |
<div |
||||||
|
class="leather flex flex-col items-center justify-center min-h-screen text-center px-4" |
||||||
|
> |
||||||
<h1 class="h-leather mb-4">404 - Page Not Found</h1> |
<h1 class="h-leather mb-4">404 - Page Not Found</h1> |
||||||
<P class="note-leather mb-6">The page you are looking for does not exist or has been moved.</P> |
<P class="note-leather mb-6" |
||||||
|
>The page you are looking for does not exist or has been moved.</P |
||||||
|
> |
||||||
<div class="flex space-x-4"> |
<div class="flex space-x-4"> |
||||||
<Button class="btn-leather !w-fit" on:click={() => goto('/')}>Return to Home</Button> |
<Button class="btn-leather !w-fit" on:click={() => goto("/")} |
||||||
<Button class="btn-leather !w-fit" outline on:click={() => window.history.back()}>Go Back</Button> |
>Return to Home</Button |
||||||
|
> |
||||||
|
<Button |
||||||
|
class="btn-leather !w-fit" |
||||||
|
outline |
||||||
|
on:click={() => window.history.back()}>Go Back</Button |
||||||
|
> |
||||||
</div> |
</div> |
||||||
</div> |
</div> |
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Loading…
Reference in new issue