36 changed files with 2929 additions and 1430 deletions
@ -0,0 +1,286 @@ |
|||||||
|
<script lang="ts"> |
||||||
|
import { sessionManager } from '../../services/auth/session-manager.js'; |
||||||
|
import { uploadFileToServer, buildImetaTag } from '../../services/nostr/file-upload.js'; |
||||||
|
import { insertTextAtCursor } from '../../services/text-utils.js'; |
||||||
|
import MentionsAutocomplete from './MentionsAutocomplete.svelte'; |
||||||
|
import GifPicker from './GifPicker.svelte'; |
||||||
|
import EmojiPicker from './EmojiPicker.svelte'; |
||||||
|
|
||||||
|
interface Props { |
||||||
|
value: string; |
||||||
|
placeholder?: string; |
||||||
|
rows?: number; |
||||||
|
disabled?: boolean; |
||||||
|
showToolbar?: boolean; |
||||||
|
uploadContext?: string; // For logging purposes |
||||||
|
onValueChange?: (value: string) => void; |
||||||
|
onFilesUploaded?: (files: Array<{ url: string; imetaTag: string[] }>) => void; |
||||||
|
} |
||||||
|
|
||||||
|
let { |
||||||
|
value = $bindable(''), |
||||||
|
placeholder = 'Enter text...', |
||||||
|
rows = 4, |
||||||
|
disabled = false, |
||||||
|
showToolbar = true, |
||||||
|
uploadContext = 'RichTextEditor', |
||||||
|
onValueChange, |
||||||
|
onFilesUploaded |
||||||
|
}: Props = $props(); |
||||||
|
|
||||||
|
let textareaRef: HTMLTextAreaElement | null = $state(null); |
||||||
|
let fileInputRef: HTMLInputElement | null = $state(null); |
||||||
|
let showGifPicker = $state(false); |
||||||
|
let showEmojiPicker = $state(false); |
||||||
|
let uploading = $state(false); |
||||||
|
let uploadedFiles: Array<{ url: string; imetaTag: string[] }> = $state([]); |
||||||
|
|
||||||
|
// Generate unique ID for file input |
||||||
|
const fileInputId = `rich-text-file-upload-${Math.random().toString(36).substring(7)}`; |
||||||
|
|
||||||
|
// Sync uploaded files to parent |
||||||
|
$effect(() => { |
||||||
|
if (uploadedFiles.length > 0 && onFilesUploaded) { |
||||||
|
onFilesUploaded(uploadedFiles); |
||||||
|
} |
||||||
|
}); |
||||||
|
|
||||||
|
function handleGifSelect(gifUrl: string) { |
||||||
|
if (!textareaRef) return; |
||||||
|
insertTextAtCursor(textareaRef, gifUrl); |
||||||
|
showGifPicker = false; |
||||||
|
} |
||||||
|
|
||||||
|
function handleEmojiSelect(emoji: string) { |
||||||
|
if (!textareaRef) return; |
||||||
|
insertTextAtCursor(textareaRef, emoji); |
||||||
|
showEmojiPicker = false; |
||||||
|
} |
||||||
|
|
||||||
|
async function handleFileUpload(event: Event) { |
||||||
|
const input = event.target as HTMLInputElement; |
||||||
|
const files = input.files; |
||||||
|
if (!files || files.length === 0) return; |
||||||
|
|
||||||
|
// Filter valid file types |
||||||
|
const validFiles: File[] = []; |
||||||
|
for (const file of Array.from(files)) { |
||||||
|
const isImage = file.type.startsWith('image/'); |
||||||
|
const isVideo = file.type.startsWith('video/'); |
||||||
|
const isAudio = file.type.startsWith('audio/'); |
||||||
|
|
||||||
|
if (!isImage && !isVideo && !isAudio) { |
||||||
|
alert(`${file.name} is not an image, video, or audio file`); |
||||||
|
continue; |
||||||
|
} |
||||||
|
validFiles.push(file); |
||||||
|
} |
||||||
|
|
||||||
|
if (validFiles.length === 0) { |
||||||
|
return; |
||||||
|
} |
||||||
|
|
||||||
|
if (!sessionManager.isLoggedIn()) { |
||||||
|
alert('Please log in to upload files'); |
||||||
|
return; |
||||||
|
} |
||||||
|
|
||||||
|
uploading = true; |
||||||
|
const uploadPromises: Promise<void>[] = []; |
||||||
|
|
||||||
|
// Process all files |
||||||
|
for (const file of validFiles) { |
||||||
|
const uploadPromise = (async () => { |
||||||
|
try { |
||||||
|
// Upload file to media server |
||||||
|
const uploadResult = await uploadFileToServer(file, uploadContext); |
||||||
|
console.log(`[${uploadContext}] Uploaded ${file.name} to ${uploadResult.url}`); |
||||||
|
|
||||||
|
// Build imeta tag from upload response (NIP-92 format) |
||||||
|
const imetaTag = buildImetaTag(file, uploadResult); |
||||||
|
|
||||||
|
// Store file with imeta tag |
||||||
|
uploadedFiles.push({ |
||||||
|
url: uploadResult.url, |
||||||
|
imetaTag |
||||||
|
}); |
||||||
|
|
||||||
|
// Insert file URL into textarea (plain URL for all file types) |
||||||
|
if (textareaRef) { |
||||||
|
insertTextAtCursor(textareaRef, `${uploadResult.url}\n`); |
||||||
|
} |
||||||
|
} catch (error) { |
||||||
|
console.error(`[${uploadContext}] File upload failed for ${file.name}:`, error); |
||||||
|
const errorMessage = error instanceof Error ? error.message : String(error); |
||||||
|
alert(`Failed to upload ${file.name}: ${errorMessage}`); |
||||||
|
} |
||||||
|
})(); |
||||||
|
|
||||||
|
uploadPromises.push(uploadPromise); |
||||||
|
} |
||||||
|
|
||||||
|
// Wait for all uploads to complete |
||||||
|
try { |
||||||
|
await Promise.all(uploadPromises); |
||||||
|
} finally { |
||||||
|
uploading = false; |
||||||
|
// Reset file input |
||||||
|
if (fileInputRef) { |
||||||
|
fileInputRef.value = ''; |
||||||
|
} |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
// Expose method to clear uploaded files (useful when form is cleared) |
||||||
|
export function clearUploadedFiles() { |
||||||
|
uploadedFiles = []; |
||||||
|
} |
||||||
|
|
||||||
|
// Expose method to get uploaded files |
||||||
|
export function getUploadedFiles() { |
||||||
|
return uploadedFiles; |
||||||
|
} |
||||||
|
</script> |
||||||
|
|
||||||
|
<div class="textarea-wrapper"> |
||||||
|
<textarea |
||||||
|
bind:this={textareaRef} |
||||||
|
bind:value |
||||||
|
{placeholder} |
||||||
|
class="w-full p-3 border border-fog-border dark:border-fog-dark-border rounded bg-fog-post dark:bg-fog-dark-post text-fog-text dark:text-fog-dark-text {showToolbar ? 'has-buttons' : ''}" |
||||||
|
{rows} |
||||||
|
{disabled} |
||||||
|
oninput={(e) => { |
||||||
|
if (onValueChange) { |
||||||
|
onValueChange(e.currentTarget.value); |
||||||
|
} |
||||||
|
}} |
||||||
|
></textarea> |
||||||
|
|
||||||
|
{#if textareaRef} |
||||||
|
<MentionsAutocomplete textarea={textareaRef} /> |
||||||
|
{/if} |
||||||
|
|
||||||
|
{#if showToolbar} |
||||||
|
<div class="textarea-buttons"> |
||||||
|
<button |
||||||
|
type="button" |
||||||
|
onclick={() => { |
||||||
|
showGifPicker = !showGifPicker; |
||||||
|
showEmojiPicker = false; |
||||||
|
}} |
||||||
|
class="toolbar-button" |
||||||
|
title="Insert GIF" |
||||||
|
aria-label="Insert GIF" |
||||||
|
{disabled} |
||||||
|
> |
||||||
|
GIF |
||||||
|
</button> |
||||||
|
<button |
||||||
|
type="button" |
||||||
|
onclick={() => { showEmojiPicker = !showEmojiPicker; showGifPicker = false; }} |
||||||
|
class="toolbar-button" |
||||||
|
title="Insert emoji" |
||||||
|
aria-label="Insert emoji" |
||||||
|
{disabled} |
||||||
|
> |
||||||
|
😀 |
||||||
|
</button> |
||||||
|
<input |
||||||
|
type="file" |
||||||
|
bind:this={fileInputRef} |
||||||
|
accept="image/*,video/*,audio/*" |
||||||
|
multiple |
||||||
|
onchange={handleFileUpload} |
||||||
|
class="hidden" |
||||||
|
id={fileInputId} |
||||||
|
disabled={disabled || uploading} |
||||||
|
/> |
||||||
|
<label |
||||||
|
for={fileInputId} |
||||||
|
class="toolbar-button upload-button" |
||||||
|
title="Upload file (image, video, or audio)" |
||||||
|
aria-label="Upload file" |
||||||
|
> |
||||||
|
📤 |
||||||
|
</label> |
||||||
|
</div> |
||||||
|
{/if} |
||||||
|
</div> |
||||||
|
|
||||||
|
<GifPicker open={showGifPicker} onSelect={handleGifSelect} onClose={() => showGifPicker = false} /> |
||||||
|
<EmojiPicker open={showEmojiPicker} onSelect={handleEmojiSelect} onClose={() => showEmojiPicker = false} /> |
||||||
|
|
||||||
|
<style> |
||||||
|
.textarea-wrapper { |
||||||
|
position: relative; |
||||||
|
} |
||||||
|
|
||||||
|
textarea { |
||||||
|
resize: vertical; |
||||||
|
min-height: 100px; |
||||||
|
} |
||||||
|
|
||||||
|
/* Add padding to bottom when buttons are visible to prevent text overlap */ |
||||||
|
textarea.has-buttons { |
||||||
|
padding-bottom: 3rem; /* Increased padding to accommodate buttons */ |
||||||
|
} |
||||||
|
|
||||||
|
textarea:focus { |
||||||
|
outline: none; |
||||||
|
border-color: var(--fog-accent, #64748b); |
||||||
|
} |
||||||
|
|
||||||
|
.textarea-buttons { |
||||||
|
position: absolute; |
||||||
|
bottom: 0.5rem; |
||||||
|
left: 0.75rem; |
||||||
|
display: flex; |
||||||
|
gap: 0.25rem; |
||||||
|
z-index: 10; |
||||||
|
padding-top: 0.5rem; /* Add padding above buttons to separate from text */ |
||||||
|
} |
||||||
|
|
||||||
|
.upload-button { |
||||||
|
cursor: pointer; |
||||||
|
user-select: none; |
||||||
|
} |
||||||
|
|
||||||
|
.toolbar-button { |
||||||
|
padding: 0.25rem 0.5rem; |
||||||
|
font-size: 0.75rem; |
||||||
|
border: 1px solid var(--fog-border, #e5e7eb); |
||||||
|
border-radius: 0.25rem; |
||||||
|
background: var(--fog-highlight, #f3f4f6); |
||||||
|
color: var(--fog-text-light, #6b7280); |
||||||
|
cursor: pointer; |
||||||
|
transition: all 0.2s; |
||||||
|
} |
||||||
|
|
||||||
|
.toolbar-button:hover:not(:disabled) { |
||||||
|
background: var(--fog-border, #e5e7eb); |
||||||
|
border-color: var(--fog-accent, #64748b); |
||||||
|
color: var(--fog-text, #475569); |
||||||
|
} |
||||||
|
|
||||||
|
.toolbar-button:disabled { |
||||||
|
opacity: 0.5; |
||||||
|
cursor: not-allowed; |
||||||
|
} |
||||||
|
|
||||||
|
:global(.dark) .toolbar-button { |
||||||
|
background: var(--fog-dark-highlight, #374151); |
||||||
|
border-color: var(--fog-dark-border, #475569); |
||||||
|
color: var(--fog-dark-text-light, #9ca3af); |
||||||
|
} |
||||||
|
|
||||||
|
:global(.dark) .toolbar-button:hover:not(:disabled) { |
||||||
|
background: var(--fog-dark-border, #475569); |
||||||
|
border-color: var(--fog-dark-accent, #64748b); |
||||||
|
color: var(--fog-dark-text, #cbd5e1); |
||||||
|
} |
||||||
|
|
||||||
|
.hidden { |
||||||
|
display: none; |
||||||
|
} |
||||||
|
</style> |
||||||
@ -0,0 +1,279 @@ |
|||||||
|
<script lang="ts"> |
||||||
|
import { nip19 } from 'nostr-tools'; |
||||||
|
import { getProfile, getProfiles } from '../../services/cache/profile-cache.js'; |
||||||
|
import { parseProfile } from '../../services/user-data.js'; |
||||||
|
|
||||||
|
interface Props { |
||||||
|
value: string; |
||||||
|
onInput: (value: string) => void; |
||||||
|
placeholder?: string; |
||||||
|
} |
||||||
|
|
||||||
|
let { value, onInput, placeholder = 'Search by pubkey, p, q tags, or content...' }: Props = $props(); |
||||||
|
|
||||||
|
// Resolved pubkey from NIP-05 or other formats |
||||||
|
let resolvedPubkey = $state<string | null>(null); |
||||||
|
let resolving = $state(false); |
||||||
|
|
||||||
|
function handleInput(e: Event) { |
||||||
|
const input = e.target as HTMLInputElement; |
||||||
|
onInput(input.value); |
||||||
|
// Trigger async resolution |
||||||
|
resolvePubkey(input.value); |
||||||
|
} |
||||||
|
|
||||||
|
// Check if input looks like NIP-05 (user@domain.com) |
||||||
|
function isNIP05(input: string): boolean { |
||||||
|
const trimmed = input.trim(); |
||||||
|
// Simple check: contains @ and has at least one character before and after |
||||||
|
return /^[^@]+@[^@]+\.[^@]+$/.test(trimmed); |
||||||
|
} |
||||||
|
|
||||||
|
// Search cache for profiles with matching NIP-05 |
||||||
|
async function searchCacheForNIP05(nip05: string): Promise<string | null> { |
||||||
|
try { |
||||||
|
// Get all profiles from cache (we need to iterate through them) |
||||||
|
// Since we don't have an index on nip05, we'll need to get all profiles |
||||||
|
// This is not ideal but necessary for now |
||||||
|
const db = await import('../../services/cache/indexeddb-store.js').then(m => m.getDB()); |
||||||
|
const tx = db.transaction('profiles', 'readonly'); |
||||||
|
const store = tx.store; |
||||||
|
const profiles: any[] = []; |
||||||
|
|
||||||
|
// Get all profiles (this could be slow with many profiles, but it's cached) |
||||||
|
let cursor = await store.openCursor(); |
||||||
|
while (cursor) { |
||||||
|
profiles.push(cursor.value); |
||||||
|
cursor = await cursor.continue(); |
||||||
|
} |
||||||
|
await tx.done; |
||||||
|
|
||||||
|
// Search for matching NIP-05 |
||||||
|
const normalizedNIP05 = nip05.toLowerCase(); |
||||||
|
for (const cached of profiles) { |
||||||
|
const profile = parseProfile(cached.event); |
||||||
|
if (profile.nip05) { |
||||||
|
for (const profileNip05 of profile.nip05) { |
||||||
|
if (profileNip05.toLowerCase() === normalizedNIP05) { |
||||||
|
return cached.pubkey; |
||||||
|
} |
||||||
|
} |
||||||
|
} |
||||||
|
} |
||||||
|
} catch (error) { |
||||||
|
console.debug('Error searching cache for NIP-05:', error); |
||||||
|
} |
||||||
|
return null; |
||||||
|
} |
||||||
|
|
||||||
|
// Resolve NIP-05 from well-known.json |
||||||
|
async function resolveNIP05FromWellKnown(nip05: string): Promise<string | null> { |
||||||
|
try { |
||||||
|
const [localPart, domain] = nip05.split('@'); |
||||||
|
if (!localPart || !domain) return null; |
||||||
|
|
||||||
|
const wellKnownUrl = `https://${domain}/.well-known/nostr.json?name=${encodeURIComponent(localPart)}`; |
||||||
|
|
||||||
|
const controller = new AbortController(); |
||||||
|
const timeout = setTimeout(() => controller.abort(), 5000); // 5 second timeout |
||||||
|
|
||||||
|
try { |
||||||
|
const response = await fetch(wellKnownUrl, { signal: controller.signal }); |
||||||
|
clearTimeout(timeout); |
||||||
|
|
||||||
|
if (!response.ok) { |
||||||
|
return null; |
||||||
|
} |
||||||
|
|
||||||
|
const data = await response.json(); |
||||||
|
const names = data.names || {}; |
||||||
|
const pubkey = names[localPart]; |
||||||
|
|
||||||
|
if (pubkey && /^[0-9a-f]{64}$/i.test(pubkey)) { |
||||||
|
return pubkey.toLowerCase(); |
||||||
|
} |
||||||
|
} catch (fetchError) { |
||||||
|
clearTimeout(timeout); |
||||||
|
if (fetchError instanceof Error && fetchError.name !== 'AbortError') { |
||||||
|
console.debug('Error fetching well-known.json:', fetchError); |
||||||
|
} |
||||||
|
} |
||||||
|
} catch (error) { |
||||||
|
console.debug('Error resolving NIP-05:', error); |
||||||
|
} |
||||||
|
return null; |
||||||
|
} |
||||||
|
|
||||||
|
// Helper to normalize input to hex pubkey (sync for hex/npub/nprofile, async for NIP-05) |
||||||
|
function normalizeToHexSync(input: string): string | null { |
||||||
|
const trimmed = input.trim(); |
||||||
|
if (!trimmed) return null; |
||||||
|
|
||||||
|
// If it's already a hex pubkey (64 hex chars) |
||||||
|
if (/^[a-fA-F0-9]{64}$/.test(trimmed)) { |
||||||
|
return trimmed.toLowerCase(); |
||||||
|
} |
||||||
|
|
||||||
|
// Try to decode as bech32 (npub or nprofile) |
||||||
|
try { |
||||||
|
const decoded = nip19.decode(trimmed); |
||||||
|
if (decoded.type === 'npub') { |
||||||
|
return decoded.data as string; |
||||||
|
} else if (decoded.type === 'nprofile') { |
||||||
|
return (decoded.data as any).pubkey; |
||||||
|
} |
||||||
|
} catch { |
||||||
|
// Not a valid bech32 |
||||||
|
} |
||||||
|
|
||||||
|
return null; |
||||||
|
} |
||||||
|
|
||||||
|
// Async resolution for NIP-05 |
||||||
|
async function resolvePubkey(input: string) { |
||||||
|
const trimmed = input.trim(); |
||||||
|
if (!trimmed) { |
||||||
|
resolvedPubkey = null; |
||||||
|
return; |
||||||
|
} |
||||||
|
|
||||||
|
// Try sync resolution first |
||||||
|
const syncResult = normalizeToHexSync(trimmed); |
||||||
|
if (syncResult) { |
||||||
|
resolvedPubkey = syncResult; |
||||||
|
return; |
||||||
|
} |
||||||
|
|
||||||
|
// If it looks like NIP-05, resolve it |
||||||
|
if (isNIP05(trimmed)) { |
||||||
|
resolving = true; |
||||||
|
try { |
||||||
|
// First check cache |
||||||
|
const cachedPubkey = await searchCacheForNIP05(trimmed); |
||||||
|
if (cachedPubkey) { |
||||||
|
resolvedPubkey = cachedPubkey; |
||||||
|
resolving = false; |
||||||
|
return; |
||||||
|
} |
||||||
|
|
||||||
|
// Then check well-known.json |
||||||
|
const wellKnownPubkey = await resolveNIP05FromWellKnown(trimmed); |
||||||
|
if (wellKnownPubkey) { |
||||||
|
resolvedPubkey = wellKnownPubkey; |
||||||
|
} else { |
||||||
|
resolvedPubkey = null; |
||||||
|
} |
||||||
|
} catch (error) { |
||||||
|
console.debug('Error resolving NIP-05:', error); |
||||||
|
resolvedPubkey = null; |
||||||
|
} finally { |
||||||
|
resolving = false; |
||||||
|
} |
||||||
|
} else { |
||||||
|
resolvedPubkey = null; |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
// Resolve when value changes |
||||||
|
let resolveTimeout: ReturnType<typeof setTimeout> | null = null; |
||||||
|
$effect(() => { |
||||||
|
// Debounce resolution to avoid too many requests |
||||||
|
if (resolveTimeout) clearTimeout(resolveTimeout); |
||||||
|
resolveTimeout = setTimeout(() => { |
||||||
|
resolvePubkey(value); |
||||||
|
}, 300); // 300ms debounce |
||||||
|
|
||||||
|
return () => { |
||||||
|
if (resolveTimeout) clearTimeout(resolveTimeout); |
||||||
|
}; |
||||||
|
}); |
||||||
|
|
||||||
|
// Expose resolved pubkey for parent components |
||||||
|
export function getNormalizedPubkey(): string | null { |
||||||
|
return resolvedPubkey; |
||||||
|
} |
||||||
|
|
||||||
|
// Expose resolving state |
||||||
|
export function isResolving(): boolean { |
||||||
|
return resolving; |
||||||
|
} |
||||||
|
</script> |
||||||
|
|
||||||
|
<div class="pubkey-filter"> |
||||||
|
<input |
||||||
|
type="text" |
||||||
|
value={value} |
||||||
|
oninput={handleInput} |
||||||
|
placeholder={placeholder} |
||||||
|
class="filter-input" |
||||||
|
class:resolving={resolving} |
||||||
|
/> |
||||||
|
{#if resolving} |
||||||
|
<span class="resolving-indicator" title="Resolving NIP-05...">⟳</span> |
||||||
|
{/if} |
||||||
|
</div> |
||||||
|
|
||||||
|
<style> |
||||||
|
.pubkey-filter { |
||||||
|
margin-bottom: 1rem; |
||||||
|
} |
||||||
|
|
||||||
|
.filter-input { |
||||||
|
width: 100%; |
||||||
|
max-width: 500px; |
||||||
|
padding: 0.75rem; |
||||||
|
border: 1px solid var(--fog-border, #e5e7eb); |
||||||
|
border-radius: 0.375rem; |
||||||
|
background: var(--fog-post, #ffffff); |
||||||
|
color: var(--fog-text, #1f2937); |
||||||
|
font-size: 0.875rem; |
||||||
|
font-family: monospace; |
||||||
|
} |
||||||
|
|
||||||
|
:global(.dark) .filter-input { |
||||||
|
border-color: var(--fog-dark-border, #374151); |
||||||
|
background: var(--fog-dark-post, #1f2937); |
||||||
|
color: var(--fog-dark-text, #f9fafb); |
||||||
|
} |
||||||
|
|
||||||
|
.filter-input:focus { |
||||||
|
outline: none; |
||||||
|
border-color: var(--fog-accent, #64748b); |
||||||
|
box-shadow: 0 0 0 3px rgba(100, 116, 139, 0.1); |
||||||
|
} |
||||||
|
|
||||||
|
:global(.dark) .filter-input:focus { |
||||||
|
border-color: var(--fog-dark-accent, #94a3b8); |
||||||
|
box-shadow: 0 0 0 3px rgba(148, 163, 184, 0.1); |
||||||
|
} |
||||||
|
|
||||||
|
.filter-input.resolving { |
||||||
|
border-color: var(--fog-accent, #64748b); |
||||||
|
} |
||||||
|
|
||||||
|
.pubkey-filter { |
||||||
|
position: relative; |
||||||
|
display: flex; |
||||||
|
align-items: center; |
||||||
|
gap: 0.5rem; |
||||||
|
} |
||||||
|
|
||||||
|
.resolving-indicator { |
||||||
|
color: var(--fog-accent, #64748b); |
||||||
|
font-size: 1rem; |
||||||
|
animation: spin 1s linear infinite; |
||||||
|
} |
||||||
|
|
||||||
|
:global(.dark) .resolving-indicator { |
||||||
|
color: var(--fog-dark-accent, #94a3b8); |
||||||
|
} |
||||||
|
|
||||||
|
@keyframes spin { |
||||||
|
from { |
||||||
|
transform: rotate(0deg); |
||||||
|
} |
||||||
|
to { |
||||||
|
transform: rotate(360deg); |
||||||
|
} |
||||||
|
} |
||||||
|
</style> |
||||||
@ -0,0 +1,938 @@ |
|||||||
|
<script lang="ts"> |
||||||
|
import { nostrClient } from '../../services/nostr/nostr-client.js'; |
||||||
|
import { relayManager } from '../../services/nostr/relay-manager.js'; |
||||||
|
import { getEvent, getEventsByKind } from '../../services/cache/event-cache.js'; |
||||||
|
import { cacheEvent } from '../../services/cache/event-cache.js'; |
||||||
|
import { nip19 } from 'nostr-tools'; |
||||||
|
import { goto } from '$app/navigation'; |
||||||
|
import { KIND, KIND_LOOKUP } from '../../types/kind-lookup.js'; |
||||||
|
import type { NostrEvent } from '../../types/nostr.js'; |
||||||
|
import { parseProfile } from '../../services/user-data.js'; |
||||||
|
|
||||||
|
interface Props { |
||||||
|
mode?: 'search' | 'filter'; // 'search' shows dropdown, 'filter' filters page content |
||||||
|
placeholder?: string; |
||||||
|
onFilterChange?: (result: { type: 'event' | 'pubkey' | 'text' | null; value: string | null; kind?: number | null }) => void; |
||||||
|
showKindFilter?: boolean; // Show kind filter dropdown |
||||||
|
selectedKind?: number | null; // Selected kind for filtering |
||||||
|
onKindChange?: (kind: number | null) => void; // Callback when kind filter changes |
||||||
|
hideDropdownResults?: boolean; // If true, don't show dropdown results (for /find page) |
||||||
|
onSearchResults?: (results: { events: NostrEvent[]; profiles: string[] }) => void; // Callback for search results (events and profile pubkeys) |
||||||
|
} |
||||||
|
|
||||||
|
let { mode = 'search', placeholder = 'Search events, profiles, pubkeys, or enter event ID...', onFilterChange, showKindFilter = false, selectedKind = null, onKindChange, hideDropdownResults = false, onSearchResults }: Props = $props(); |
||||||
|
|
||||||
|
let searchQuery = $state(''); |
||||||
|
let searching = $state(false); |
||||||
|
let resolving = $state(false); |
||||||
|
let searchResults = $state<Array<{ event: NostrEvent; matchType: string }>>([]); |
||||||
|
let showResults = $state(false); |
||||||
|
let searchInput: HTMLInputElement | null = $state(null); |
||||||
|
let searchTimeout: ReturnType<typeof setTimeout> | null = null; |
||||||
|
|
||||||
|
// For collecting results when hideDropdownResults is true |
||||||
|
let foundEvents: NostrEvent[] = []; |
||||||
|
let foundProfiles: string[] = []; |
||||||
|
|
||||||
|
// For filter mode: resolved search result |
||||||
|
let filterResult = $state<{ type: 'event' | 'pubkey' | 'text' | null; value: string | null; kind?: number | null }>({ type: null, value: null, kind: null }); |
||||||
|
|
||||||
|
// Note: filterResult kind is updated in performSearch, not here to avoid loops |
||||||
|
|
||||||
|
// Check if input looks like NIP-05 (user@domain.com) |
||||||
|
function isNIP05(input: string): boolean { |
||||||
|
const trimmed = input.trim(); |
||||||
|
return /^[^@]+@[^@]+\.[^@]+$/.test(trimmed); |
||||||
|
} |
||||||
|
|
||||||
|
// Search cache for profiles with matching NIP-05 |
||||||
|
async function searchCacheForNIP05(nip05: string): Promise<string | null> { |
||||||
|
try { |
||||||
|
const db = await import('../../services/cache/indexeddb-store.js').then(m => m.getDB()); |
||||||
|
const tx = db.transaction('profiles', 'readonly'); |
||||||
|
const store = tx.store; |
||||||
|
const profiles: any[] = []; |
||||||
|
|
||||||
|
let cursor = await store.openCursor(); |
||||||
|
while (cursor) { |
||||||
|
profiles.push(cursor.value); |
||||||
|
cursor = await cursor.continue(); |
||||||
|
} |
||||||
|
await tx.done; |
||||||
|
|
||||||
|
const normalizedNIP05 = nip05.toLowerCase(); |
||||||
|
for (const cached of profiles) { |
||||||
|
const profile = parseProfile(cached.event); |
||||||
|
if (profile.nip05) { |
||||||
|
for (const profileNip05 of profile.nip05) { |
||||||
|
if (profileNip05.toLowerCase() === normalizedNIP05) { |
||||||
|
return cached.pubkey; |
||||||
|
} |
||||||
|
} |
||||||
|
} |
||||||
|
} |
||||||
|
} catch (error) { |
||||||
|
console.debug('Error searching cache for NIP-05:', error); |
||||||
|
} |
||||||
|
return null; |
||||||
|
} |
||||||
|
|
||||||
|
// Resolve NIP-05 from well-known.json |
||||||
|
async function resolveNIP05FromWellKnown(nip05: string): Promise<string | null> { |
||||||
|
try { |
||||||
|
const [localPart, domain] = nip05.split('@'); |
||||||
|
if (!localPart || !domain) return null; |
||||||
|
|
||||||
|
const wellKnownUrl = `https://${domain}/.well-known/nostr.json?name=${encodeURIComponent(localPart)}`; |
||||||
|
|
||||||
|
const controller = new AbortController(); |
||||||
|
const timeout = setTimeout(() => controller.abort(), 5000); |
||||||
|
|
||||||
|
try { |
||||||
|
const response = await fetch(wellKnownUrl, { signal: controller.signal }); |
||||||
|
clearTimeout(timeout); |
||||||
|
|
||||||
|
if (!response.ok) { |
||||||
|
return null; |
||||||
|
} |
||||||
|
|
||||||
|
const data = await response.json(); |
||||||
|
const names = data.names || {}; |
||||||
|
const pubkey = names[localPart]; |
||||||
|
|
||||||
|
if (pubkey && /^[0-9a-f]{64}$/i.test(pubkey)) { |
||||||
|
return pubkey.toLowerCase(); |
||||||
|
} |
||||||
|
} catch (fetchError) { |
||||||
|
clearTimeout(timeout); |
||||||
|
if (fetchError instanceof Error && fetchError.name !== 'AbortError') { |
||||||
|
console.debug('Error fetching well-known.json:', fetchError); |
||||||
|
} |
||||||
|
} |
||||||
|
} catch (error) { |
||||||
|
console.debug('Error resolving NIP-05:', error); |
||||||
|
} |
||||||
|
return null; |
||||||
|
} |
||||||
|
|
||||||
|
async function performSearch() { |
||||||
|
if (!searchQuery.trim()) { |
||||||
|
searchResults = []; |
||||||
|
showResults = false; |
||||||
|
filterResult = { type: null, value: null, kind: selectedKind }; |
||||||
|
if (onFilterChange) onFilterChange(filterResult); |
||||||
|
return; |
||||||
|
} |
||||||
|
|
||||||
|
await nostrClient.initialize(); |
||||||
|
searching = true; |
||||||
|
resolving = true; |
||||||
|
searchResults = []; |
||||||
|
showResults = true; |
||||||
|
filterResult = { type: null, value: null, kind: selectedKind }; |
||||||
|
|
||||||
|
try { |
||||||
|
const query = searchQuery.trim(); |
||||||
|
|
||||||
|
// 1. Check if it's a hex event ID (64 hex chars) |
||||||
|
if (/^[0-9a-f]{64}$/i.test(query)) { |
||||||
|
const hexId = query.toLowerCase(); |
||||||
|
let event: NostrEvent | undefined = await getEvent(hexId); |
||||||
|
|
||||||
|
if (!event) { |
||||||
|
const relays = relayManager.getFeedReadRelays(); |
||||||
|
const events = await nostrClient.fetchEvents( |
||||||
|
[{ ids: [hexId] }], |
||||||
|
relays, |
||||||
|
{ useCache: false, cacheResults: true } |
||||||
|
); |
||||||
|
|
||||||
|
if (events.length > 0) { |
||||||
|
event = events[0]; |
||||||
|
await cacheEvent(event); |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
if (event) { |
||||||
|
if (mode === 'search') { |
||||||
|
if (hideDropdownResults && onSearchResults) { |
||||||
|
foundEvents = [event]; |
||||||
|
onSearchResults({ events: foundEvents, profiles: [] }); |
||||||
|
} else { |
||||||
|
searchResults = [{ event, matchType: 'Event ID' }]; |
||||||
|
showResults = true; |
||||||
|
} |
||||||
|
} else { |
||||||
|
filterResult = { type: 'event', value: event.id }; |
||||||
|
if (onFilterChange) onFilterChange(filterResult); |
||||||
|
} |
||||||
|
searching = false; |
||||||
|
resolving = false; |
||||||
|
return; |
||||||
|
} |
||||||
|
|
||||||
|
// Event not found, try as pubkey (step 2) |
||||||
|
const hexPubkey = hexId.toLowerCase(); |
||||||
|
// If kind is selected, search for events with this pubkey in p/q tags |
||||||
|
if (selectedKind !== null && mode === 'search' && hideDropdownResults && onSearchResults) { |
||||||
|
const relays = relayManager.getProfileReadRelays(); |
||||||
|
const filters: any[] = [{ |
||||||
|
kinds: [selectedKind], |
||||||
|
limit: 100 |
||||||
|
}]; |
||||||
|
|
||||||
|
// Search for events with pubkey in p or q tags |
||||||
|
const eventsWithP = await nostrClient.fetchEvents( |
||||||
|
filters.map(f => ({ ...f, '#p': [hexPubkey] })), |
||||||
|
relays, |
||||||
|
{ useCache: true, cacheResults: true, timeout: 10000 } |
||||||
|
); |
||||||
|
|
||||||
|
const eventsWithQ = await nostrClient.fetchEvents( |
||||||
|
filters.map(f => ({ ...f, '#q': [hexPubkey] })), |
||||||
|
relays, |
||||||
|
{ useCache: true, cacheResults: true, timeout: 10000 } |
||||||
|
); |
||||||
|
|
||||||
|
// Also search by author |
||||||
|
const eventsByAuthor = await nostrClient.fetchEvents( |
||||||
|
filters.map(f => ({ ...f, authors: [hexPubkey] })), |
||||||
|
relays, |
||||||
|
{ useCache: true, cacheResults: true, timeout: 10000 } |
||||||
|
); |
||||||
|
|
||||||
|
// Combine and deduplicate |
||||||
|
const allEvents = new Map<string, NostrEvent>(); |
||||||
|
for (const event of [...eventsWithP, ...eventsWithQ, ...eventsByAuthor]) { |
||||||
|
allEvents.set(event.id, event); |
||||||
|
} |
||||||
|
|
||||||
|
foundEvents = Array.from(allEvents.values()); |
||||||
|
onSearchResults({ events: foundEvents, profiles: [] }); |
||||||
|
searching = false; |
||||||
|
resolving = false; |
||||||
|
return; |
||||||
|
} else if (mode === 'search') { |
||||||
|
if (hideDropdownResults && onSearchResults) { |
||||||
|
foundProfiles = [hexPubkey]; |
||||||
|
onSearchResults({ events: [], profiles: foundProfiles }); |
||||||
|
} else { |
||||||
|
// For search mode, navigate to profile |
||||||
|
handleProfileClick(hexPubkey); |
||||||
|
} |
||||||
|
searching = false; |
||||||
|
resolving = false; |
||||||
|
return; |
||||||
|
} else { |
||||||
|
filterResult = { type: 'pubkey', value: hexPubkey, kind: selectedKind }; |
||||||
|
if (onFilterChange) onFilterChange(filterResult); |
||||||
|
searching = false; |
||||||
|
resolving = false; |
||||||
|
return; |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
// 3. Check npub, nprofile (resolve to hex) |
||||||
|
if (/^(npub|nprofile)1[a-z0-9]+$/i.test(query)) { |
||||||
|
try { |
||||||
|
const decoded = nip19.decode(query); |
||||||
|
let pubkey: string | null = null; |
||||||
|
|
||||||
|
if (decoded.type === 'npub') { |
||||||
|
pubkey = String(decoded.data); |
||||||
|
} else if (decoded.type === 'nprofile') { |
||||||
|
if (decoded.data && typeof decoded.data === 'object' && 'pubkey' in decoded.data) { |
||||||
|
pubkey = String(decoded.data.pubkey); |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
if (pubkey) { |
||||||
|
const normalizedPubkey = pubkey.toLowerCase(); |
||||||
|
// If kind is selected, search for events with this pubkey in p/q tags |
||||||
|
if (selectedKind !== null && mode === 'search' && hideDropdownResults && onSearchResults) { |
||||||
|
const relays = relayManager.getProfileReadRelays(); |
||||||
|
const filters: any[] = [{ |
||||||
|
kinds: [selectedKind], |
||||||
|
limit: 100 |
||||||
|
}]; |
||||||
|
|
||||||
|
// Search for events with pubkey in p or q tags |
||||||
|
const eventsWithP = await nostrClient.fetchEvents( |
||||||
|
filters.map(f => ({ ...f, '#p': [normalizedPubkey] })), |
||||||
|
relays, |
||||||
|
{ useCache: true, cacheResults: true, timeout: 10000 } |
||||||
|
); |
||||||
|
|
||||||
|
const eventsWithQ = await nostrClient.fetchEvents( |
||||||
|
filters.map(f => ({ ...f, '#q': [normalizedPubkey] })), |
||||||
|
relays, |
||||||
|
{ useCache: true, cacheResults: true, timeout: 10000 } |
||||||
|
); |
||||||
|
|
||||||
|
// Also search by author |
||||||
|
const eventsByAuthor = await nostrClient.fetchEvents( |
||||||
|
filters.map(f => ({ ...f, authors: [normalizedPubkey] })), |
||||||
|
relays, |
||||||
|
{ useCache: true, cacheResults: true, timeout: 10000 } |
||||||
|
); |
||||||
|
|
||||||
|
// Combine and deduplicate |
||||||
|
const allEvents = new Map<string, NostrEvent>(); |
||||||
|
for (const event of [...eventsWithP, ...eventsWithQ, ...eventsByAuthor]) { |
||||||
|
allEvents.set(event.id, event); |
||||||
|
} |
||||||
|
|
||||||
|
foundEvents = Array.from(allEvents.values()); |
||||||
|
onSearchResults({ events: foundEvents, profiles: [] }); |
||||||
|
searching = false; |
||||||
|
resolving = false; |
||||||
|
return; |
||||||
|
} else if (mode === 'search') { |
||||||
|
if (hideDropdownResults && onSearchResults) { |
||||||
|
foundProfiles = [normalizedPubkey]; |
||||||
|
onSearchResults({ events: [], profiles: foundProfiles }); |
||||||
|
} else { |
||||||
|
handleProfileClick(normalizedPubkey); |
||||||
|
} |
||||||
|
searching = false; |
||||||
|
resolving = false; |
||||||
|
return; |
||||||
|
} else { |
||||||
|
filterResult = { type: 'pubkey', value: normalizedPubkey, kind: selectedKind }; |
||||||
|
if (onFilterChange) onFilterChange(filterResult); |
||||||
|
searching = false; |
||||||
|
resolving = false; |
||||||
|
return; |
||||||
|
} |
||||||
|
} |
||||||
|
} catch (error) { |
||||||
|
console.debug('Error decoding npub/nprofile:', error); |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
// 4. Check NIP-05 (resolve to hex) |
||||||
|
if (isNIP05(query)) { |
||||||
|
resolving = true; |
||||||
|
try { |
||||||
|
// First check cache |
||||||
|
const cachedPubkey = await searchCacheForNIP05(query); |
||||||
|
if (cachedPubkey) { |
||||||
|
const normalizedPubkey = cachedPubkey.toLowerCase(); |
||||||
|
// If kind is selected, search for events with this pubkey in p/q tags |
||||||
|
if (selectedKind !== null && mode === 'search' && hideDropdownResults && onSearchResults) { |
||||||
|
const relays = relayManager.getProfileReadRelays(); |
||||||
|
const filters: any[] = [{ |
||||||
|
kinds: [selectedKind], |
||||||
|
limit: 100 |
||||||
|
}]; |
||||||
|
|
||||||
|
// Search for events with pubkey in p or q tags |
||||||
|
const eventsWithP = await nostrClient.fetchEvents( |
||||||
|
filters.map(f => ({ ...f, '#p': [normalizedPubkey] })), |
||||||
|
relays, |
||||||
|
{ useCache: true, cacheResults: true, timeout: 10000 } |
||||||
|
); |
||||||
|
|
||||||
|
const eventsWithQ = await nostrClient.fetchEvents( |
||||||
|
filters.map(f => ({ ...f, '#q': [normalizedPubkey] })), |
||||||
|
relays, |
||||||
|
{ useCache: true, cacheResults: true, timeout: 10000 } |
||||||
|
); |
||||||
|
|
||||||
|
// Also search by author |
||||||
|
const eventsByAuthor = await nostrClient.fetchEvents( |
||||||
|
filters.map(f => ({ ...f, authors: [normalizedPubkey] })), |
||||||
|
relays, |
||||||
|
{ useCache: true, cacheResults: true, timeout: 10000 } |
||||||
|
); |
||||||
|
|
||||||
|
// Combine and deduplicate |
||||||
|
const allEvents = new Map<string, NostrEvent>(); |
||||||
|
for (const event of [...eventsWithP, ...eventsWithQ, ...eventsByAuthor]) { |
||||||
|
allEvents.set(event.id, event); |
||||||
|
} |
||||||
|
|
||||||
|
foundEvents = Array.from(allEvents.values()); |
||||||
|
onSearchResults({ events: foundEvents, profiles: [] }); |
||||||
|
searching = false; |
||||||
|
resolving = false; |
||||||
|
return; |
||||||
|
} else if (mode === 'search') { |
||||||
|
if (hideDropdownResults && onSearchResults) { |
||||||
|
foundProfiles = [normalizedPubkey]; |
||||||
|
onSearchResults({ events: [], profiles: foundProfiles }); |
||||||
|
} else { |
||||||
|
handleProfileClick(normalizedPubkey); |
||||||
|
} |
||||||
|
searching = false; |
||||||
|
resolving = false; |
||||||
|
return; |
||||||
|
} else { |
||||||
|
filterResult = { type: 'pubkey', value: normalizedPubkey, kind: selectedKind }; |
||||||
|
if (onFilterChange) onFilterChange(filterResult); |
||||||
|
searching = false; |
||||||
|
resolving = false; |
||||||
|
return; |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
// Then check well-known.json |
||||||
|
const wellKnownPubkey = await resolveNIP05FromWellKnown(query); |
||||||
|
if (wellKnownPubkey) { |
||||||
|
const normalizedPubkey = wellKnownPubkey.toLowerCase(); |
||||||
|
// If kind is selected, search for events with this pubkey in p/q tags |
||||||
|
if (selectedKind !== null && mode === 'search' && hideDropdownResults && onSearchResults) { |
||||||
|
const relays = relayManager.getProfileReadRelays(); |
||||||
|
const filters: any[] = [{ |
||||||
|
kinds: [selectedKind], |
||||||
|
limit: 100 |
||||||
|
}]; |
||||||
|
|
||||||
|
// Search for events with pubkey in p or q tags |
||||||
|
const eventsWithP = await nostrClient.fetchEvents( |
||||||
|
filters.map(f => ({ ...f, '#p': [normalizedPubkey] })), |
||||||
|
relays, |
||||||
|
{ useCache: true, cacheResults: true, timeout: 10000 } |
||||||
|
); |
||||||
|
|
||||||
|
const eventsWithQ = await nostrClient.fetchEvents( |
||||||
|
filters.map(f => ({ ...f, '#q': [normalizedPubkey] })), |
||||||
|
relays, |
||||||
|
{ useCache: true, cacheResults: true, timeout: 10000 } |
||||||
|
); |
||||||
|
|
||||||
|
// Also search by author |
||||||
|
const eventsByAuthor = await nostrClient.fetchEvents( |
||||||
|
filters.map(f => ({ ...f, authors: [normalizedPubkey] })), |
||||||
|
relays, |
||||||
|
{ useCache: true, cacheResults: true, timeout: 10000 } |
||||||
|
); |
||||||
|
|
||||||
|
// Combine and deduplicate |
||||||
|
const allEvents = new Map<string, NostrEvent>(); |
||||||
|
for (const event of [...eventsWithP, ...eventsWithQ, ...eventsByAuthor]) { |
||||||
|
allEvents.set(event.id, event); |
||||||
|
} |
||||||
|
|
||||||
|
foundEvents = Array.from(allEvents.values()); |
||||||
|
onSearchResults({ events: foundEvents, profiles: [] }); |
||||||
|
searching = false; |
||||||
|
resolving = false; |
||||||
|
return; |
||||||
|
} else if (mode === 'search') { |
||||||
|
if (hideDropdownResults && onSearchResults) { |
||||||
|
foundProfiles = [normalizedPubkey]; |
||||||
|
onSearchResults({ events: [], profiles: foundProfiles }); |
||||||
|
} else { |
||||||
|
handleProfileClick(normalizedPubkey); |
||||||
|
} |
||||||
|
searching = false; |
||||||
|
resolving = false; |
||||||
|
return; |
||||||
|
} else { |
||||||
|
filterResult = { type: 'pubkey', value: normalizedPubkey, kind: selectedKind }; |
||||||
|
if (onFilterChange) onFilterChange(filterResult); |
||||||
|
searching = false; |
||||||
|
resolving = false; |
||||||
|
return; |
||||||
|
} |
||||||
|
} |
||||||
|
} catch (error) { |
||||||
|
console.debug('Error resolving NIP-05:', error); |
||||||
|
} |
||||||
|
resolving = false; |
||||||
|
} |
||||||
|
|
||||||
|
// 5. Check note, nevent, naddr (resolve to hex event ID) |
||||||
|
if (/^(note|nevent|naddr)1[a-z0-9]+$/i.test(query)) { |
||||||
|
try { |
||||||
|
const decoded = nip19.decode(query); |
||||||
|
let eventId: string | null = null; |
||||||
|
|
||||||
|
if (decoded.type === 'note') { |
||||||
|
eventId = String(decoded.data); |
||||||
|
} else if (decoded.type === 'nevent') { |
||||||
|
if (decoded.data && typeof decoded.data === 'object' && 'id' in decoded.data) { |
||||||
|
eventId = String(decoded.data.id); |
||||||
|
} |
||||||
|
} else if (decoded.type === 'naddr') { |
||||||
|
// naddr is more complex, would need kind+pubkey+d to fetch |
||||||
|
// For now, we'll skip it and treat as text search |
||||||
|
} |
||||||
|
|
||||||
|
if (eventId) { |
||||||
|
let event: NostrEvent | undefined = await getEvent(eventId); |
||||||
|
|
||||||
|
if (!event) { |
||||||
|
const relays = relayManager.getFeedReadRelays(); |
||||||
|
const events = await nostrClient.fetchEvents( |
||||||
|
[{ ids: [eventId] }], |
||||||
|
relays, |
||||||
|
{ useCache: false, cacheResults: true } |
||||||
|
); |
||||||
|
|
||||||
|
if (events.length > 0) { |
||||||
|
event = events[0]; |
||||||
|
await cacheEvent(event); |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
if (event) { |
||||||
|
if (mode === 'search') { |
||||||
|
if (hideDropdownResults && onSearchResults) { |
||||||
|
foundEvents = [event]; |
||||||
|
onSearchResults({ events: foundEvents, profiles: [] }); |
||||||
|
} else { |
||||||
|
searchResults = [{ event, matchType: 'Event ID' }]; |
||||||
|
showResults = true; |
||||||
|
} |
||||||
|
} else { |
||||||
|
filterResult = { type: 'event', value: event.id, kind: selectedKind }; |
||||||
|
if (onFilterChange) onFilterChange(filterResult); |
||||||
|
} |
||||||
|
searching = false; |
||||||
|
resolving = false; |
||||||
|
return; |
||||||
|
} |
||||||
|
} |
||||||
|
} catch (error) { |
||||||
|
console.debug('Error decoding note/nevent/naddr:', error); |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
// 6. Anything else is a full-text search |
||||||
|
if (mode === 'search') { |
||||||
|
// Text search in cached events (title, summary, content) |
||||||
|
const allCached: NostrEvent[] = []; |
||||||
|
|
||||||
|
// If kind filter is selected, only search that kind |
||||||
|
if (selectedKind !== null) { |
||||||
|
const kindEvents = await getEventsByKind(selectedKind, 100); |
||||||
|
allCached.push(...kindEvents); |
||||||
|
} else { |
||||||
|
// Search all kinds we handle |
||||||
|
const kindsToSearch = Object.keys(KIND_LOOKUP).map(k => parseInt(k)).filter(k => !KIND_LOOKUP[k].isSecondaryKind); |
||||||
|
for (const kind of kindsToSearch) { |
||||||
|
try { |
||||||
|
const kindEvents = await getEventsByKind(kind, 50); |
||||||
|
allCached.push(...kindEvents); |
||||||
|
} catch (e) { |
||||||
|
// Skip kinds that fail |
||||||
|
} |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
const queryLower = query.toLowerCase(); |
||||||
|
const matches = allCached.filter(event => { |
||||||
|
const contentMatch = event.content.toLowerCase().includes(queryLower); |
||||||
|
|
||||||
|
const titleTag = event.tags.find(t => t[0] === 'title'); |
||||||
|
const titleMatch = titleTag?.[1]?.toLowerCase().includes(queryLower) || false; |
||||||
|
|
||||||
|
const summaryTag = event.tags.find(t => t[0] === 'summary'); |
||||||
|
const summaryMatch = summaryTag?.[1]?.toLowerCase().includes(queryLower) || false; |
||||||
|
|
||||||
|
return contentMatch || titleMatch || summaryMatch; |
||||||
|
}); |
||||||
|
|
||||||
|
const sorted = matches.sort((a, b) => { |
||||||
|
const aExact = a.content.toLowerCase() === queryLower; |
||||||
|
const bExact = b.content.toLowerCase() === queryLower; |
||||||
|
if (aExact && !bExact) return -1; |
||||||
|
if (!aExact && bExact) return 1; |
||||||
|
return b.created_at - a.created_at; |
||||||
|
}); |
||||||
|
|
||||||
|
const limitedResults = sorted.slice(0, 20); |
||||||
|
|
||||||
|
if (hideDropdownResults && onSearchResults) { |
||||||
|
foundEvents = limitedResults; |
||||||
|
onSearchResults({ events: foundEvents, profiles: [] }); |
||||||
|
} else { |
||||||
|
searchResults = limitedResults.map(e => ({ event: e, matchType: 'Content' })); |
||||||
|
showResults = true; |
||||||
|
} |
||||||
|
} else { |
||||||
|
// Filter mode: treat as text search |
||||||
|
filterResult = { type: 'text', value: query, kind: selectedKind }; |
||||||
|
if (onFilterChange) onFilterChange(filterResult); |
||||||
|
} |
||||||
|
} catch (error) { |
||||||
|
console.error('Search error:', error); |
||||||
|
} finally { |
||||||
|
searching = false; |
||||||
|
resolving = false; |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
function handleSearchInput(e: Event) { |
||||||
|
const target = e.target as HTMLInputElement; |
||||||
|
const newValue = target.value; |
||||||
|
searchQuery = newValue; |
||||||
|
|
||||||
|
if (searchTimeout) { |
||||||
|
clearTimeout(searchTimeout); |
||||||
|
} |
||||||
|
|
||||||
|
if (newValue.trim()) { |
||||||
|
searching = true; |
||||||
|
searchTimeout = setTimeout(() => { |
||||||
|
performSearch(); |
||||||
|
}, 300); |
||||||
|
} else { |
||||||
|
searchResults = []; |
||||||
|
showResults = false; |
||||||
|
searching = false; |
||||||
|
filterResult = { type: null, value: null, kind: selectedKind }; |
||||||
|
if (onFilterChange) onFilterChange(filterResult); |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
function handleKeyDown(e: KeyboardEvent) { |
||||||
|
if (e.key === 'Enter') { |
||||||
|
if (searchTimeout) { |
||||||
|
clearTimeout(searchTimeout); |
||||||
|
} |
||||||
|
performSearch(); |
||||||
|
} else if (e.key === 'Escape') { |
||||||
|
showResults = false; |
||||||
|
searchQuery = ''; |
||||||
|
filterResult = { type: null, value: null, kind: selectedKind }; |
||||||
|
if (onFilterChange) onFilterChange(filterResult); |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
function handleResultClick(event: NostrEvent) { |
||||||
|
showResults = false; |
||||||
|
searchQuery = ''; |
||||||
|
goto(`/event/${event.id}`); |
||||||
|
} |
||||||
|
|
||||||
|
function handleProfileClick(pubkey: string) { |
||||||
|
showResults = false; |
||||||
|
searchQuery = ''; |
||||||
|
goto(`/profile/${pubkey}`); |
||||||
|
} |
||||||
|
|
||||||
|
// Close results when clicking outside |
||||||
|
$effect(() => { |
||||||
|
if (showResults) { |
||||||
|
const handleClickOutside = (e: MouseEvent) => { |
||||||
|
const target = e.target as HTMLElement; |
||||||
|
if (!target.closest('.unified-search-container')) { |
||||||
|
showResults = false; |
||||||
|
} |
||||||
|
}; |
||||||
|
|
||||||
|
document.addEventListener('click', handleClickOutside, true); |
||||||
|
return () => { |
||||||
|
document.removeEventListener('click', handleClickOutside, true); |
||||||
|
}; |
||||||
|
} |
||||||
|
}); |
||||||
|
|
||||||
|
// Cleanup timeout on unmount |
||||||
|
$effect(() => { |
||||||
|
return () => { |
||||||
|
if (searchTimeout) { |
||||||
|
clearTimeout(searchTimeout); |
||||||
|
} |
||||||
|
}; |
||||||
|
}); |
||||||
|
|
||||||
|
// Expose filter result for parent components (filter mode) |
||||||
|
export function getFilterResult(): { type: 'event' | 'pubkey' | 'text' | null; value: string | null; kind?: number | null } { |
||||||
|
return filterResult; |
||||||
|
} |
||||||
|
|
||||||
|
// Expose performSearch for manual trigger |
||||||
|
export function triggerSearch() { |
||||||
|
performSearch(); |
||||||
|
} |
||||||
|
|
||||||
|
// Note: filterResult kind is updated in performSearch, not here to avoid loops |
||||||
|
|
||||||
|
// Get all kinds for dropdown (sorted by number) |
||||||
|
let allKinds = $derived(Object.values(KIND_LOOKUP).sort((a, b) => a.number - b.number)); |
||||||
|
|
||||||
|
function handleKindChange(e: Event) { |
||||||
|
const select = e.target as HTMLSelectElement; |
||||||
|
const kind = select.value === '' ? null : parseInt(select.value); |
||||||
|
if (onKindChange) { |
||||||
|
onKindChange(kind); |
||||||
|
} |
||||||
|
// Update filter result |
||||||
|
filterResult = { ...filterResult, kind }; |
||||||
|
if (onFilterChange) { |
||||||
|
onFilterChange(filterResult); |
||||||
|
} |
||||||
|
// Re-run search if there's a query |
||||||
|
if (searchQuery.trim()) { |
||||||
|
performSearch(); |
||||||
|
} |
||||||
|
} |
||||||
|
</script> |
||||||
|
|
||||||
|
<div class="unified-search-container"> |
||||||
|
<div class="search-input-wrapper"> |
||||||
|
{#if showKindFilter} |
||||||
|
<select |
||||||
|
value={selectedKind?.toString() || ''} |
||||||
|
onchange={handleKindChange} |
||||||
|
class="kind-filter-select" |
||||||
|
aria-label="Filter by kind" |
||||||
|
> |
||||||
|
<option value="">All Kinds</option> |
||||||
|
{#each allKinds as kindInfo} |
||||||
|
<option value={kindInfo.number}>{kindInfo.number}: {kindInfo.description}</option> |
||||||
|
{/each} |
||||||
|
</select> |
||||||
|
{/if} |
||||||
|
<input |
||||||
|
bind:this={searchInput} |
||||||
|
type="text" |
||||||
|
placeholder={placeholder} |
||||||
|
value={searchQuery} |
||||||
|
oninput={handleSearchInput} |
||||||
|
onkeydown={handleKeyDown} |
||||||
|
class="search-input" |
||||||
|
class:resolving={resolving} |
||||||
|
class:with-kind-filter={showKindFilter} |
||||||
|
aria-label="Search" |
||||||
|
/> |
||||||
|
{#if searching || resolving} |
||||||
|
<span class="search-loading">⟳</span> |
||||||
|
{/if} |
||||||
|
</div> |
||||||
|
|
||||||
|
{#if mode === 'search' && !hideDropdownResults && showResults && searchResults.length > 0} |
||||||
|
<div class="search-results"> |
||||||
|
{#each searchResults as { event, matchType }} |
||||||
|
<button |
||||||
|
onclick={() => { |
||||||
|
if (event.kind === KIND.METADATA) { |
||||||
|
handleProfileClick(event.pubkey); |
||||||
|
} else { |
||||||
|
handleResultClick(event); |
||||||
|
} |
||||||
|
}} |
||||||
|
class="search-result-item" |
||||||
|
> |
||||||
|
<div class="search-result-header"> |
||||||
|
<span class="search-result-type">{matchType}</span> |
||||||
|
<span class="search-result-id">{event.id.substring(0, 16)}...</span> |
||||||
|
</div> |
||||||
|
<div class="search-result-content"> |
||||||
|
{event.content.substring(0, 100)}{event.content.length > 100 ? '...' : ''} |
||||||
|
</div> |
||||||
|
<div class="search-result-meta"> |
||||||
|
Kind {event.kind} • {new Date(event.created_at * 1000).toLocaleDateString()} |
||||||
|
</div> |
||||||
|
</button> |
||||||
|
{/each} |
||||||
|
</div> |
||||||
|
{:else if mode === 'search' && !hideDropdownResults && showResults && !searching && searchQuery.trim()} |
||||||
|
<div class="search-results"> |
||||||
|
<div class="search-no-results">No results found</div> |
||||||
|
</div> |
||||||
|
{/if} |
||||||
|
</div> |
||||||
|
|
||||||
|
<style> |
||||||
|
.unified-search-container { |
||||||
|
position: relative; |
||||||
|
width: 100%; |
||||||
|
max-width: 600px; |
||||||
|
} |
||||||
|
|
||||||
|
.search-input-wrapper { |
||||||
|
position: relative; |
||||||
|
display: flex; |
||||||
|
align-items: center; |
||||||
|
gap: 0.5rem; |
||||||
|
} |
||||||
|
|
||||||
|
.kind-filter-select { |
||||||
|
padding: 0.75rem; |
||||||
|
border: 1px solid var(--fog-border, #e5e7eb); |
||||||
|
border-radius: 0.375rem; |
||||||
|
background: var(--fog-post, #ffffff); |
||||||
|
color: var(--fog-text, #1f2937); |
||||||
|
font-size: 0.875rem; |
||||||
|
font-family: monospace; |
||||||
|
cursor: pointer; |
||||||
|
min-width: 150px; |
||||||
|
} |
||||||
|
|
||||||
|
.kind-filter-select:focus { |
||||||
|
outline: none; |
||||||
|
border-color: var(--fog-accent, #64748b); |
||||||
|
box-shadow: 0 0 0 3px rgba(100, 116, 139, 0.1); |
||||||
|
} |
||||||
|
|
||||||
|
:global(.dark) .kind-filter-select { |
||||||
|
border-color: var(--fog-dark-border, #374151); |
||||||
|
background: var(--fog-dark-post, #1f2937); |
||||||
|
color: var(--fog-dark-text, #f9fafb); |
||||||
|
} |
||||||
|
|
||||||
|
:global(.dark) .kind-filter-select:focus { |
||||||
|
border-color: var(--fog-dark-accent, #94a3b8); |
||||||
|
box-shadow: 0 0 0 3px rgba(148, 163, 184, 0.1); |
||||||
|
} |
||||||
|
|
||||||
|
.search-input.with-kind-filter { |
||||||
|
flex: 1; |
||||||
|
} |
||||||
|
|
||||||
|
.search-input { |
||||||
|
width: 100%; |
||||||
|
padding: 0.75rem 1rem; |
||||||
|
border: 1px solid var(--fog-border, #e5e7eb); |
||||||
|
border-radius: 0.375rem; |
||||||
|
background: var(--fog-post, #ffffff); |
||||||
|
color: var(--fog-text, #1f2937); |
||||||
|
font-size: 0.875rem; |
||||||
|
transition: all 0.2s; |
||||||
|
} |
||||||
|
|
||||||
|
.search-input:focus { |
||||||
|
outline: none; |
||||||
|
border-color: var(--fog-accent, #64748b); |
||||||
|
box-shadow: 0 0 0 3px rgba(100, 116, 139, 0.1); |
||||||
|
} |
||||||
|
|
||||||
|
.search-input.resolving { |
||||||
|
border-color: var(--fog-accent, #64748b); |
||||||
|
} |
||||||
|
|
||||||
|
:global(.dark) .search-input { |
||||||
|
background: var(--fog-dark-post, #1f2937); |
||||||
|
border-color: var(--fog-dark-border, #374151); |
||||||
|
color: var(--fog-dark-text, #f9fafb); |
||||||
|
} |
||||||
|
|
||||||
|
:global(.dark) .search-input:focus { |
||||||
|
border-color: var(--fog-dark-accent, #94a3b8); |
||||||
|
box-shadow: 0 0 0 3px rgba(148, 163, 184, 0.1); |
||||||
|
} |
||||||
|
|
||||||
|
.search-loading { |
||||||
|
position: absolute; |
||||||
|
right: 1rem; |
||||||
|
color: var(--fog-text-light, #9ca3af); |
||||||
|
animation: spin 1s linear infinite; |
||||||
|
} |
||||||
|
|
||||||
|
@keyframes spin { |
||||||
|
from { transform: rotate(0deg); } |
||||||
|
to { transform: rotate(360deg); } |
||||||
|
} |
||||||
|
|
||||||
|
.search-results { |
||||||
|
position: absolute; |
||||||
|
top: 100%; |
||||||
|
left: 0; |
||||||
|
right: 0; |
||||||
|
margin-top: 0.25rem; |
||||||
|
background: var(--fog-post, #ffffff); |
||||||
|
border: 1px solid var(--fog-border, #e5e7eb); |
||||||
|
border-radius: 0.375rem; |
||||||
|
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15); |
||||||
|
max-height: 400px; |
||||||
|
overflow-y: auto; |
||||||
|
z-index: 1000; |
||||||
|
} |
||||||
|
|
||||||
|
:global(.dark) .search-results { |
||||||
|
background: var(--fog-dark-post, #1f2937); |
||||||
|
border-color: var(--fog-dark-border, #374151); |
||||||
|
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.3); |
||||||
|
} |
||||||
|
|
||||||
|
.search-result-item { |
||||||
|
width: 100%; |
||||||
|
padding: 0.75rem; |
||||||
|
border: none; |
||||||
|
background: transparent; |
||||||
|
text-align: left; |
||||||
|
cursor: pointer; |
||||||
|
transition: background 0.2s; |
||||||
|
border-bottom: 1px solid var(--fog-border, #e5e7eb); |
||||||
|
} |
||||||
|
|
||||||
|
.search-result-item:last-child { |
||||||
|
border-bottom: none; |
||||||
|
} |
||||||
|
|
||||||
|
.search-result-item:hover { |
||||||
|
background: var(--fog-highlight, #f3f4f6); |
||||||
|
} |
||||||
|
|
||||||
|
:global(.dark) .search-result-item { |
||||||
|
border-bottom-color: var(--fog-dark-border, #374151); |
||||||
|
} |
||||||
|
|
||||||
|
:global(.dark) .search-result-item:hover { |
||||||
|
background: var(--fog-dark-highlight, #374151); |
||||||
|
} |
||||||
|
|
||||||
|
.search-result-header { |
||||||
|
display: flex; |
||||||
|
justify-content: space-between; |
||||||
|
align-items: center; |
||||||
|
margin-bottom: 0.25rem; |
||||||
|
} |
||||||
|
|
||||||
|
.search-result-type { |
||||||
|
font-size: 0.75rem; |
||||||
|
font-weight: 600; |
||||||
|
color: var(--fog-accent, #64748b); |
||||||
|
text-transform: uppercase; |
||||||
|
} |
||||||
|
|
||||||
|
:global(.dark) .search-result-type { |
||||||
|
color: var(--fog-dark-accent, #94a3b8); |
||||||
|
} |
||||||
|
|
||||||
|
.search-result-id { |
||||||
|
font-size: 0.75rem; |
||||||
|
font-family: monospace; |
||||||
|
color: var(--fog-text-light, #9ca3af); |
||||||
|
} |
||||||
|
|
||||||
|
:global(.dark) .search-result-id { |
||||||
|
color: var(--fog-dark-text-light, #6b7280); |
||||||
|
} |
||||||
|
|
||||||
|
.search-result-content { |
||||||
|
font-size: 0.875rem; |
||||||
|
color: var(--fog-text, #1f2937); |
||||||
|
margin-bottom: 0.25rem; |
||||||
|
line-height: 1.4; |
||||||
|
} |
||||||
|
|
||||||
|
:global(.dark) .search-result-content { |
||||||
|
color: var(--fog-dark-text, #f9fafb); |
||||||
|
} |
||||||
|
|
||||||
|
.search-result-meta { |
||||||
|
font-size: 0.75rem; |
||||||
|
color: var(--fog-text-light, #9ca3af); |
||||||
|
} |
||||||
|
|
||||||
|
:global(.dark) .search-result-meta { |
||||||
|
color: var(--fog-dark-text-light, #6b7280); |
||||||
|
} |
||||||
|
|
||||||
|
.search-no-results { |
||||||
|
padding: 1rem; |
||||||
|
text-align: center; |
||||||
|
color: var(--fog-text-light, #9ca3af); |
||||||
|
font-size: 0.875rem; |
||||||
|
} |
||||||
|
|
||||||
|
:global(.dark) .search-no-results { |
||||||
|
color: var(--fog-dark-text-light, #6b7280); |
||||||
|
} |
||||||
|
</style> |
||||||
@ -1,561 +0,0 @@ |
|||||||
<script lang="ts"> |
|
||||||
import FeedPost from './FeedPost.svelte'; |
|
||||||
import CommentThread from '../comments/CommentThread.svelte'; |
|
||||||
import { nostrClient } from '../../services/nostr/nostr-client.js'; |
|
||||||
import { relayManager } from '../../services/nostr/relay-manager.js'; |
|
||||||
import { config } from '../../services/nostr/config.js'; |
|
||||||
import { buildEventHierarchy, getHierarchyChain } from '../../services/nostr/event-hierarchy.js'; |
|
||||||
import { onMount } from 'svelte'; |
|
||||||
import type { NostrEvent } from '../../types/nostr.js'; |
|
||||||
import { KIND } from '../../types/kind-lookup.js'; |
|
||||||
|
|
||||||
interface Props { |
|
||||||
opEvent: NostrEvent | null; |
|
||||||
isOpen: boolean; |
|
||||||
onClose: () => void; |
|
||||||
} |
|
||||||
|
|
||||||
let { opEvent, isOpen, onClose }: Props = $props(); |
|
||||||
|
|
||||||
let drawerElement: HTMLElement | null = $state(null); |
|
||||||
let loading = $state(false); |
|
||||||
let subscriptionId: string | null = $state(null); |
|
||||||
let isInitialized = $state(false); |
|
||||||
let hierarchyChain = $state<NostrEvent[]>([]); |
|
||||||
let currentLoadEventId = $state<string | null>(null); // Track which event is currently being loaded |
|
||||||
let lastOpEventId = $state<string | null>(null); // Track the last event ID to prevent reactive loops |
|
||||||
let loadFullThread = $state(false); // Only load full thread when "View thread" is clicked |
|
||||||
|
|
||||||
// Batch-loaded reactions and zaps for all events in thread |
|
||||||
let reactionsByEventId = $state<Map<string, NostrEvent[]>>(new Map()); |
|
||||||
let zapsByEventId = $state<Map<string, NostrEvent[]>>(new Map()); |
|
||||||
let loadingReactions = $state(false); |
|
||||||
|
|
||||||
// Derive event ID separately to avoid reactive loops from object reference changes |
|
||||||
let opEventId = $derived(opEvent?.id || null); |
|
||||||
|
|
||||||
// Initialize nostr client once |
|
||||||
onMount(async () => { |
|
||||||
if (!isInitialized) { |
|
||||||
await nostrClient.initialize(); |
|
||||||
isInitialized = true; |
|
||||||
} |
|
||||||
}); |
|
||||||
|
|
||||||
// Build event hierarchy when drawer opens |
|
||||||
async function loadHierarchy(abortSignal: AbortSignal, eventId: string) { |
|
||||||
if (!opEvent || !isInitialized) { |
|
||||||
console.warn('loadHierarchy: opEvent or isInitialized missing', { opEvent: !!opEvent, isInitialized }); |
|
||||||
loading = false; |
|
||||||
return; |
|
||||||
} |
|
||||||
|
|
||||||
// If we're already loading this event, don't start another load |
|
||||||
if (currentLoadEventId === eventId && loading) { |
|
||||||
console.log('loadHierarchy: Already loading this event', eventId); |
|
||||||
return; |
|
||||||
} |
|
||||||
|
|
||||||
currentLoadEventId = eventId; |
|
||||||
loading = true; |
|
||||||
console.log('loadHierarchy: Starting load for event', eventId); |
|
||||||
|
|
||||||
try { |
|
||||||
// Add timeout to prevent hanging - use longer timeout for hierarchy building |
|
||||||
// Hierarchy building can take time on slow connections as it fetches parent events recursively |
|
||||||
// Use singleRelayTimeout (15s) as hierarchy building is similar in complexity |
|
||||||
const timeoutPromise = new Promise((_, reject) => { |
|
||||||
setTimeout(() => reject(new Error('buildEventHierarchy timeout - loading thread hierarchy took too long')), config.singleRelayTimeout); |
|
||||||
}); |
|
||||||
|
|
||||||
const hierarchy = await Promise.race([ |
|
||||||
buildEventHierarchy(opEvent), |
|
||||||
timeoutPromise |
|
||||||
]) as Awaited<ReturnType<typeof buildEventHierarchy>>; |
|
||||||
|
|
||||||
// Check if operation was aborted or event changed |
|
||||||
if (abortSignal.aborted || currentLoadEventId !== eventId) { |
|
||||||
console.log('loadHierarchy: Aborted or event changed', { aborted: abortSignal.aborted, currentId: currentLoadEventId, eventId }); |
|
||||||
return; |
|
||||||
} |
|
||||||
|
|
||||||
const chain = getHierarchyChain(hierarchy); |
|
||||||
console.log('loadHierarchy: Got hierarchy chain', chain.length, 'events'); |
|
||||||
|
|
||||||
// Check again before final state update |
|
||||||
if (abortSignal.aborted || currentLoadEventId !== eventId) { |
|
||||||
console.log('loadHierarchy: Aborted before setting chain'); |
|
||||||
return; |
|
||||||
} |
|
||||||
|
|
||||||
hierarchyChain = chain; |
|
||||||
|
|
||||||
// After hierarchy is loaded, batch fetch reactions and zaps for all events |
|
||||||
if (!abortSignal.aborted && currentLoadEventId === eventId) { |
|
||||||
batchLoadReactionsAndZaps(chain); |
|
||||||
} |
|
||||||
} catch (error) { |
|
||||||
// Only update state if not aborted and still loading this event |
|
||||||
if (abortSignal.aborted || currentLoadEventId !== eventId) { |
|
||||||
console.log('loadHierarchy: Error but aborted or event changed'); |
|
||||||
return; |
|
||||||
} |
|
||||||
|
|
||||||
console.error('Error building event hierarchy:', error); |
|
||||||
hierarchyChain = [opEvent]; // Fallback to just the event |
|
||||||
|
|
||||||
// Still try to batch load reactions/zaps for the single event |
|
||||||
if (!abortSignal.aborted && currentLoadEventId === eventId) { |
|
||||||
batchLoadReactionsAndZaps([opEvent]); |
|
||||||
} |
|
||||||
} finally { |
|
||||||
// Only update loading state if not aborted and still loading this event |
|
||||||
if (!abortSignal.aborted && currentLoadEventId === eventId) { |
|
||||||
loading = false; |
|
||||||
console.log('loadHierarchy: Finished loading', eventId); |
|
||||||
} else { |
|
||||||
console.log('loadHierarchy: Not updating loading state', { aborted: abortSignal.aborted, currentId: currentLoadEventId, eventId }); |
|
||||||
} |
|
||||||
} |
|
||||||
} |
|
||||||
|
|
||||||
// Batch fetch reactions and zaps for all events in the thread |
|
||||||
async function batchLoadReactionsAndZaps(events: NostrEvent[]) { |
|
||||||
if (loadingReactions || events.length === 0) return; |
|
||||||
|
|
||||||
loadingReactions = true; |
|
||||||
try { |
|
||||||
// Collect all event IDs from hierarchy |
|
||||||
const allEventIds = new Set<string>(); |
|
||||||
for (const event of events) { |
|
||||||
allEventIds.add(event.id); |
|
||||||
} |
|
||||||
|
|
||||||
await batchLoadReactionsForEvents(Array.from(allEventIds)); |
|
||||||
} catch (error) { |
|
||||||
console.error('[ThreadDrawer] Error batch loading reactions/zaps:', error); |
|
||||||
} finally { |
|
||||||
loadingReactions = false; |
|
||||||
} |
|
||||||
} |
|
||||||
|
|
||||||
// Batch fetch reactions and zaps for a set of event IDs |
|
||||||
async function batchLoadReactionsForEvents(eventIds: string[]) { |
|
||||||
if (eventIds.length === 0) return; |
|
||||||
|
|
||||||
const allEventIds = new Set([...Array.from(reactionsByEventId.keys()), ...eventIds]); |
|
||||||
const eventIdsArray = Array.from(allEventIds); |
|
||||||
|
|
||||||
const reactionRelays = relayManager.getProfileReadRelays(); |
|
||||||
|
|
||||||
// Batch fetch reactions for all events |
|
||||||
const reactionsWithLowerE = await nostrClient.fetchEvents( |
|
||||||
[{ kinds: [KIND.REACTION], '#e': eventIdsArray, limit: config.feedLimit }], |
|
||||||
reactionRelays, |
|
||||||
{ useCache: true, cacheResults: true, timeout: config.mediumTimeout, priority: 'low' } |
|
||||||
); |
|
||||||
const reactionsWithUpperE = await nostrClient.fetchEvents( |
|
||||||
[{ kinds: [KIND.REACTION], '#E': eventIdsArray, limit: config.feedLimit }], |
|
||||||
reactionRelays, |
|
||||||
{ useCache: true, cacheResults: true, timeout: config.mediumTimeout, priority: 'low' } |
|
||||||
); |
|
||||||
|
|
||||||
// Group reactions by event ID |
|
||||||
const reactionsMap = new Map(reactionsByEventId); |
|
||||||
for (const reaction of [...reactionsWithLowerE, ...reactionsWithUpperE]) { |
|
||||||
// Find which event this reaction is for |
|
||||||
const eTag = reaction.tags.find(t => t[0] === 'e' || t[0] === 'E'); |
|
||||||
if (eTag && eTag[1] && allEventIds.has(eTag[1])) { |
|
||||||
const eventId = eTag[1]; |
|
||||||
if (!reactionsMap.has(eventId)) { |
|
||||||
reactionsMap.set(eventId, []); |
|
||||||
} |
|
||||||
reactionsMap.get(eventId)!.push(reaction); |
|
||||||
} |
|
||||||
} |
|
||||||
|
|
||||||
// Batch fetch zap receipts for all events |
|
||||||
const zapReceipts = await nostrClient.fetchEvents( |
|
||||||
[{ kinds: [KIND.ZAP_RECEIPT], '#e': eventIdsArray, limit: config.feedLimit }], |
|
||||||
reactionRelays, |
|
||||||
{ useCache: true, cacheResults: true, timeout: config.mediumTimeout, priority: 'low' } |
|
||||||
); |
|
||||||
|
|
||||||
// Group zap receipts by event ID |
|
||||||
const zapsMap = new Map(zapsByEventId); |
|
||||||
for (const zap of zapReceipts) { |
|
||||||
const eTag = zap.tags.find(t => t[0] === 'e'); |
|
||||||
if (eTag && eTag[1] && allEventIds.has(eTag[1])) { |
|
||||||
const eventId = eTag[1]; |
|
||||||
if (!zapsMap.has(eventId)) { |
|
||||||
zapsMap.set(eventId, []); |
|
||||||
} |
|
||||||
zapsMap.get(eventId)!.push(zap); |
|
||||||
} |
|
||||||
} |
|
||||||
|
|
||||||
reactionsByEventId = reactionsMap; |
|
||||||
zapsByEventId = zapsMap; |
|
||||||
} |
|
||||||
|
|
||||||
// Function to trigger full thread loading |
|
||||||
function loadFullThreadHierarchy() { |
|
||||||
if (!opEvent || !isInitialized || loadFullThread) return; |
|
||||||
|
|
||||||
loadFullThread = true; |
|
||||||
const eventId = opEvent.id; |
|
||||||
|
|
||||||
// Create abort controller to track operation lifecycle |
|
||||||
const abortController = new AbortController(); |
|
||||||
|
|
||||||
// Load hierarchy with abort signal |
|
||||||
loadHierarchy(abortController.signal, eventId).catch((error) => { |
|
||||||
// Handle any unhandled promise rejections |
|
||||||
if (!abortController.signal.aborted) { |
|
||||||
console.error('ThreadDrawer: Unhandled error in loadHierarchy', error); |
|
||||||
loading = false; |
|
||||||
hierarchyChain = opEvent ? [opEvent] : []; |
|
||||||
} |
|
||||||
}); |
|
||||||
} |
|
||||||
|
|
||||||
// Handle drawer open/close - reset state when opening/closing |
|
||||||
$effect(() => { |
|
||||||
const eventId = opEventId; |
|
||||||
|
|
||||||
// Only proceed if event ID actually changed or drawer state changed |
|
||||||
if (!isOpen || !eventId || !isInitialized || !opEvent) { |
|
||||||
// Drawer closed or no event - cleanup |
|
||||||
if (!isOpen || !eventId) { |
|
||||||
currentLoadEventId = null; |
|
||||||
lastOpEventId = null; |
|
||||||
loadFullThread = false; |
|
||||||
if (subscriptionId) { |
|
||||||
nostrClient.unsubscribe(subscriptionId); |
|
||||||
subscriptionId = null; |
|
||||||
} |
|
||||||
hierarchyChain = []; |
|
||||||
loading = false; |
|
||||||
reactionsByEventId.clear(); |
|
||||||
zapsByEventId.clear(); |
|
||||||
} else if (!opEvent) { |
|
||||||
// Event ID exists but opEvent is null - might be loading, set loading to false |
|
||||||
console.warn('ThreadDrawer: opEvent is null but eventId exists', eventId); |
|
||||||
loading = false; |
|
||||||
} |
|
||||||
return; |
|
||||||
} |
|
||||||
|
|
||||||
// Reset loadFullThread when event changes |
|
||||||
if (lastOpEventId !== eventId) { |
|
||||||
loadFullThread = false; |
|
||||||
hierarchyChain = []; |
|
||||||
lastOpEventId = eventId; |
|
||||||
|
|
||||||
// Load reactions for the single event when drawer opens |
|
||||||
if (opEvent) { |
|
||||||
batchLoadReactionsForEvents([opEvent.id]); |
|
||||||
} |
|
||||||
} |
|
||||||
|
|
||||||
// Don't auto-load hierarchy - only load when "View thread" is clicked |
|
||||||
// The effect just handles cleanup and state reset |
|
||||||
}); |
|
||||||
|
|
||||||
// Handle keyboard events |
|
||||||
function handleKeyDown(e: KeyboardEvent) { |
|
||||||
if (e.key === 'Escape' && isOpen) { |
|
||||||
onClose(); |
|
||||||
} |
|
||||||
} |
|
||||||
|
|
||||||
// Handle backdrop click |
|
||||||
function handleBackdropClick(e: MouseEvent) { |
|
||||||
if (e.target === e.currentTarget) { |
|
||||||
onClose(); |
|
||||||
} |
|
||||||
} |
|
||||||
|
|
||||||
// Prevent body scroll when drawer is open |
|
||||||
$effect(() => { |
|
||||||
if (isOpen) { |
|
||||||
document.body.style.overflow = 'hidden'; |
|
||||||
return () => { |
|
||||||
document.body.style.overflow = ''; |
|
||||||
}; |
|
||||||
} |
|
||||||
}); |
|
||||||
</script> |
|
||||||
|
|
||||||
{#if isOpen && opEvent} |
|
||||||
<div |
|
||||||
class="drawer-backdrop" |
|
||||||
onclick={handleBackdropClick} |
|
||||||
onkeydown={handleKeyDown} |
|
||||||
role="button" |
|
||||||
tabindex="0" |
|
||||||
aria-label="Close thread drawer" |
|
||||||
></div> |
|
||||||
<div |
|
||||||
class="thread-drawer drawer-right" |
|
||||||
bind:this={drawerElement} |
|
||||||
onkeydown={handleKeyDown} |
|
||||||
role="dialog" |
|
||||||
aria-modal="true" |
|
||||||
aria-label="Thread drawer" |
|
||||||
tabindex="-1" |
|
||||||
> |
|
||||||
<div class="drawer-header"> |
|
||||||
<h3 class="drawer-title">Thread</h3> |
|
||||||
<button |
|
||||||
onclick={onClose} |
|
||||||
class="drawer-close" |
|
||||||
aria-label="Close thread drawer" |
|
||||||
title="Close" |
|
||||||
> |
|
||||||
× |
|
||||||
</button> |
|
||||||
</div> |
|
||||||
|
|
||||||
<div class="drawer-content"> |
|
||||||
{#if loading} |
|
||||||
<div class="loading-state"> |
|
||||||
<p class="text-fog-text-light dark:text-fog-dark-text-light">Loading thread...</p> |
|
||||||
</div> |
|
||||||
{:else} |
|
||||||
<div class="thread-content"> |
|
||||||
{#if !loadFullThread} |
|
||||||
<!-- Single event view - show just the event with "View thread" button --> |
|
||||||
<div class="op-post"> |
|
||||||
<FeedPost |
|
||||||
post={opEvent} |
|
||||||
fullView={true} |
|
||||||
preloadedReactions={reactionsByEventId.get(opEvent.id)} |
|
||||||
/> |
|
||||||
</div> |
|
||||||
<div class="view-thread-prompt"> |
|
||||||
<button |
|
||||||
onclick={loadFullThreadHierarchy} |
|
||||||
class="view-thread-btn text-fog-accent dark:text-fog-dark-accent hover:underline" |
|
||||||
style="font-size: 1em; padding: 0.5rem 1rem; margin: 1rem 0;" |
|
||||||
> |
|
||||||
View thread |
|
||||||
</button> |
|
||||||
</div> |
|
||||||
{:else if hierarchyChain.length > 0} |
|
||||||
<!-- Display full event hierarchy (root to leaf) --> |
|
||||||
{#each hierarchyChain as parentEvent, index (parentEvent.id)} |
|
||||||
<div class="hierarchy-post"> |
|
||||||
{#if index > 0} |
|
||||||
<div class="hierarchy-divider"> |
|
||||||
<span class="hierarchy-label">Replying to:</span> |
|
||||||
</div> |
|
||||||
{/if} |
|
||||||
<FeedPost |
|
||||||
post={parentEvent} |
|
||||||
fullView={true} |
|
||||||
preloadedReactions={reactionsByEventId.get(parentEvent.id)} |
|
||||||
/> |
|
||||||
</div> |
|
||||||
{/each} |
|
||||||
{:else} |
|
||||||
<!-- Fallback: just show the event --> |
|
||||||
<div class="op-post"> |
|
||||||
<FeedPost |
|
||||||
post={opEvent} |
|
||||||
fullView={true} |
|
||||||
preloadedReactions={reactionsByEventId.get(opEvent.id)} |
|
||||||
/> |
|
||||||
</div> |
|
||||||
{/if} |
|
||||||
|
|
||||||
<!-- Display comments/replies only when full thread is loaded --> |
|
||||||
{#if loadFullThread} |
|
||||||
<div class="comments-section"> |
|
||||||
<CommentThread |
|
||||||
threadId={opEvent.id} |
|
||||||
event={opEvent} |
|
||||||
preloadedReactions={reactionsByEventId} |
|
||||||
onCommentsLoaded={(commentEventIds) => { |
|
||||||
// When comments are loaded, also batch fetch reactions for them |
|
||||||
if (commentEventIds.length > 0) { |
|
||||||
batchLoadReactionsForEvents(commentEventIds); |
|
||||||
} |
|
||||||
}} |
|
||||||
/> |
|
||||||
</div> |
|
||||||
{/if} |
|
||||||
</div> |
|
||||||
{/if} |
|
||||||
</div> |
|
||||||
</div> |
|
||||||
{/if} |
|
||||||
|
|
||||||
<style> |
|
||||||
.drawer-backdrop { |
|
||||||
position: fixed; |
|
||||||
top: 0; |
|
||||||
left: 0; |
|
||||||
right: 0; |
|
||||||
bottom: 0; |
|
||||||
background: rgba(0, 0, 0, 0.5); |
|
||||||
z-index: 999; |
|
||||||
animation: fadeIn 0.3s ease-out; |
|
||||||
} |
|
||||||
|
|
||||||
@keyframes fadeIn { |
|
||||||
from { |
|
||||||
opacity: 0; |
|
||||||
} |
|
||||||
to { |
|
||||||
opacity: 1; |
|
||||||
} |
|
||||||
} |
|
||||||
|
|
||||||
.thread-drawer { |
|
||||||
position: fixed; |
|
||||||
top: 0; |
|
||||||
right: 0; |
|
||||||
bottom: 0; |
|
||||||
width: min(600px, 90vw); |
|
||||||
max-width: 600px; |
|
||||||
background: var(--fog-post, #ffffff); |
|
||||||
border-left: 2px solid var(--fog-border, #cbd5e1); |
|
||||||
box-shadow: -2px 0 10px rgba(0, 0, 0, 0.2); |
|
||||||
padding: 0; |
|
||||||
z-index: 1000; |
|
||||||
display: flex; |
|
||||||
flex-direction: column; |
|
||||||
overflow: hidden; |
|
||||||
animation: slideInRight 0.3s ease-out; |
|
||||||
transform: translateX(0); |
|
||||||
} |
|
||||||
|
|
||||||
:global(.dark) .thread-drawer { |
|
||||||
background: var(--fog-dark-post, #1f2937); |
|
||||||
border-left-color: var(--fog-dark-border, #475569); |
|
||||||
box-shadow: -2px 0 10px rgba(0, 0, 0, 0.5); |
|
||||||
} |
|
||||||
|
|
||||||
.drawer-header { |
|
||||||
display: flex; |
|
||||||
justify-content: space-between; |
|
||||||
align-items: center; |
|
||||||
padding: 1rem; |
|
||||||
border-bottom: 1px solid var(--fog-border, #e5e7eb); |
|
||||||
flex-shrink: 0; |
|
||||||
} |
|
||||||
|
|
||||||
:global(.dark) .drawer-header { |
|
||||||
border-bottom-color: var(--fog-dark-border, #374151); |
|
||||||
} |
|
||||||
|
|
||||||
.drawer-title { |
|
||||||
margin: 0; |
|
||||||
font-size: 1.125rem; |
|
||||||
font-weight: 600; |
|
||||||
color: var(--fog-text, #1f2937); |
|
||||||
} |
|
||||||
|
|
||||||
:global(.dark) .drawer-title { |
|
||||||
color: var(--fog-dark-text, #f9fafb); |
|
||||||
} |
|
||||||
|
|
||||||
.drawer-close { |
|
||||||
background: transparent; |
|
||||||
border: none; |
|
||||||
font-size: 1.5rem; |
|
||||||
line-height: 1; |
|
||||||
cursor: pointer; |
|
||||||
color: var(--fog-text-light, #9ca3af); |
|
||||||
padding: 0.25rem 0.5rem; |
|
||||||
border-radius: 0.25rem; |
|
||||||
transition: all 0.2s; |
|
||||||
} |
|
||||||
|
|
||||||
.drawer-close:hover { |
|
||||||
background: var(--fog-highlight, #f3f4f6); |
|
||||||
color: var(--fog-text, #1f2937); |
|
||||||
} |
|
||||||
|
|
||||||
:global(.dark) .drawer-close { |
|
||||||
color: var(--fog-dark-text-light, #6b7280); |
|
||||||
} |
|
||||||
|
|
||||||
:global(.dark) .drawer-close:hover { |
|
||||||
background: var(--fog-dark-highlight, #374151); |
|
||||||
color: var(--fog-dark-text, #f9fafb); |
|
||||||
} |
|
||||||
|
|
||||||
@keyframes slideInRight { |
|
||||||
from { |
|
||||||
transform: translateX(100%); |
|
||||||
} |
|
||||||
to { |
|
||||||
transform: translateX(0); |
|
||||||
} |
|
||||||
} |
|
||||||
|
|
||||||
.drawer-content { |
|
||||||
overflow-y: auto; |
|
||||||
overflow-x: hidden; |
|
||||||
flex: 1; |
|
||||||
padding: 0; |
|
||||||
} |
|
||||||
|
|
||||||
.loading-state { |
|
||||||
padding: 2rem; |
|
||||||
text-align: center; |
|
||||||
} |
|
||||||
|
|
||||||
.thread-content { |
|
||||||
display: flex; |
|
||||||
flex-direction: column; |
|
||||||
} |
|
||||||
|
|
||||||
.op-post { |
|
||||||
padding: 1rem; |
|
||||||
border-bottom: 2px solid var(--fog-border, #e5e7eb); |
|
||||||
flex-shrink: 0; |
|
||||||
} |
|
||||||
|
|
||||||
:global(.dark) .op-post { |
|
||||||
border-bottom-color: var(--fog-dark-border, #374151); |
|
||||||
} |
|
||||||
|
|
||||||
.hierarchy-post { |
|
||||||
padding: 1rem; |
|
||||||
border-bottom: 1px solid var(--fog-border, #e5e7eb); |
|
||||||
flex-shrink: 0; |
|
||||||
} |
|
||||||
|
|
||||||
:global(.dark) .hierarchy-post { |
|
||||||
border-bottom-color: var(--fog-dark-border, #374151); |
|
||||||
} |
|
||||||
|
|
||||||
.hierarchy-divider { |
|
||||||
padding: 0.5rem 0; |
|
||||||
margin-bottom: 0.5rem; |
|
||||||
border-bottom: 1px dashed var(--fog-border, #e5e7eb); |
|
||||||
} |
|
||||||
|
|
||||||
:global(.dark) .hierarchy-divider { |
|
||||||
border-bottom-color: var(--fog-dark-border, #374151); |
|
||||||
} |
|
||||||
|
|
||||||
.hierarchy-label { |
|
||||||
font-size: 0.875rem; |
|
||||||
color: var(--fog-text-light, #6b7280); |
|
||||||
font-style: italic; |
|
||||||
} |
|
||||||
|
|
||||||
:global(.dark) .hierarchy-label { |
|
||||||
color: var(--fog-dark-text-light, #9ca3af); |
|
||||||
} |
|
||||||
|
|
||||||
.comments-section { |
|
||||||
padding: 1rem; |
|
||||||
flex: 1; |
|
||||||
min-height: 0; |
|
||||||
} |
|
||||||
</style> |
|
||||||
Loading…
Reference in new issue