19 changed files with 2774 additions and 65 deletions
@ -0,0 +1,418 @@
@@ -0,0 +1,418 @@
|
||||
<script lang="ts"> |
||||
import type { NostrEvent } from '../../types/nostr.js'; |
||||
import { nostrClient } from '../../services/nostr/nostr-client.js'; |
||||
import { relayManager } from '../../services/nostr/relay-manager.js'; |
||||
import { stripMarkdown } from '../../services/text-utils.js'; |
||||
import { goto } from '$app/navigation'; |
||||
import Icon from '../ui/Icon.svelte'; |
||||
import { nip19 } from 'nostr-tools'; |
||||
|
||||
interface Props { |
||||
event: NostrEvent; |
||||
preloadedReferencedEvent?: NostrEvent | null; // Optional preloaded referenced event |
||||
} |
||||
|
||||
let { event, preloadedReferencedEvent }: Props = $props(); |
||||
|
||||
let referencedEvent = $state<NostrEvent | null>(null); |
||||
let loading = $state(false); |
||||
let error = $state<string | null>(null); |
||||
let referenceType = $state<'reply' | 'quote' | 'addressable' | 'website' | null>(null); |
||||
let referenceId = $state<string | null>(null); |
||||
let websiteUrl = $state<string | null>(null); |
||||
let lastFetchedRefId = $state<string | null>(null); // Track what we've already tried to fetch |
||||
|
||||
// Extract reference from tags |
||||
function getReference() { |
||||
// Check for "i" tag (website/URL) first - this doesn't need event fetching |
||||
const iTag = event.tags.find(t => t[0] === 'i' && t[1]); |
||||
if (iTag && iTag[1]) { |
||||
return { type: 'website' as const, id: iTag[1], url: iTag[1] }; |
||||
} |
||||
|
||||
// Check for "q" tag (quote) |
||||
const qTag = event.tags.find(t => t[0] === 'q' && t[1]); |
||||
if (qTag && qTag[1]) { |
||||
return { type: 'quote' as const, id: qTag[1] }; |
||||
} |
||||
|
||||
// Check for "e" tag (reply) |
||||
const eTag = event.tags.find(t => t[0] === 'e' && t[1] && t[1] !== event.id); |
||||
if (eTag && eTag[1]) { |
||||
return { type: 'reply' as const, id: eTag[1] }; |
||||
} |
||||
|
||||
// Check for "a" tag (addressable event) |
||||
const aTag = event.tags.find(t => t[0] === 'a' && t[1]); |
||||
if (aTag && aTag[1]) { |
||||
return { type: 'addressable' as const, id: aTag[1] }; |
||||
} |
||||
|
||||
return null; |
||||
} |
||||
|
||||
// Load referenced event |
||||
async function loadReferencedEvent() { |
||||
const ref = getReference(); |
||||
if (!ref || loading || referencedEvent) return; |
||||
|
||||
// Prevent re-fetching the same reference |
||||
const refKey = `${ref.type}:${ref.id}`; |
||||
if (lastFetchedRefId === refKey) return; |
||||
|
||||
lastFetchedRefId = refKey; |
||||
referenceType = ref.type; |
||||
referenceId = ref.id; |
||||
loading = true; |
||||
error = null; |
||||
|
||||
try { |
||||
const relays = relayManager.getFeedReadRelays(); |
||||
|
||||
if (ref.type === 'addressable') { |
||||
// Parse a-tag: kind:pubkey:d-tag |
||||
const parts = ref.id.split(':'); |
||||
if (parts.length >= 2) { |
||||
const kind = parseInt(parts[0]); |
||||
const pubkey = parts[1]; |
||||
const dTag = parts[2] || ''; |
||||
|
||||
const filter: any = { |
||||
kinds: [kind], |
||||
authors: [pubkey], |
||||
limit: 1 |
||||
}; |
||||
|
||||
if (dTag) { |
||||
filter['#d'] = [dTag]; |
||||
} |
||||
|
||||
const events = await nostrClient.fetchEvents( |
||||
[filter], |
||||
relays, |
||||
{ useCache: true, cacheResults: true } |
||||
); |
||||
|
||||
if (events.length > 0) { |
||||
referencedEvent = events[0]; |
||||
} else { |
||||
error = 'Event not found'; |
||||
} |
||||
} else { |
||||
error = 'Invalid a-tag format'; |
||||
} |
||||
} else { |
||||
// For "e" and "q" tags, fetch by event ID |
||||
const events = await nostrClient.fetchEvents( |
||||
[{ ids: [ref.id], limit: 1 }], |
||||
relays, |
||||
{ useCache: true, cacheResults: true } |
||||
); |
||||
|
||||
if (events.length > 0) { |
||||
referencedEvent = events[0]; |
||||
} else { |
||||
error = 'Event not found'; |
||||
} |
||||
} |
||||
} catch (err) { |
||||
console.error('Error loading referenced event:', err); |
||||
error = 'Failed to load event'; |
||||
} finally { |
||||
loading = false; |
||||
} |
||||
} |
||||
|
||||
// Get title from event |
||||
function getTitle(event: NostrEvent): string | null { |
||||
const titleTag = event.tags.find(t => t[0] === 'title' && t[1]); |
||||
if (titleTag && titleTag[1]) { |
||||
return titleTag[1]; |
||||
} |
||||
|
||||
// Fallback to d-tag in Title Case |
||||
const dTag = event.tags.find(t => t[0] === 'd' && t[1])?.[1]; |
||||
if (dTag) { |
||||
return dTag.split('-').map(word => |
||||
word.charAt(0).toUpperCase() + word.slice(1).toLowerCase() |
||||
).join(' '); |
||||
} |
||||
|
||||
return null; |
||||
} |
||||
|
||||
// Get plain text preview (first 250 chars, no markdown/links) |
||||
function getPreview(event: NostrEvent): string { |
||||
if (!event.content || !event.content.trim()) { |
||||
return 'No content'; |
||||
} |
||||
|
||||
// Strip markdown and get plain text |
||||
const plaintext = stripMarkdown(event.content); |
||||
return plaintext.slice(0, 250) + (plaintext.length > 250 ? '...' : ''); |
||||
} |
||||
|
||||
// Use preloaded event if provided, otherwise load on mount |
||||
$effect(() => { |
||||
// First, check if we have a preloaded event |
||||
if (preloadedReferencedEvent && !referencedEvent) { |
||||
const ref = getReference(); |
||||
if (ref) { |
||||
referencedEvent = preloadedReferencedEvent; |
||||
referenceType = ref.type; |
||||
referenceId = ref.id; |
||||
lastFetchedRefId = `${ref.type}:${ref.id}`; |
||||
} |
||||
return; |
||||
} |
||||
|
||||
const ref = getReference(); |
||||
if (!ref) { |
||||
// Reset state if no reference |
||||
if (referenceType !== null) { |
||||
referenceType = null; |
||||
referenceId = null; |
||||
websiteUrl = null; |
||||
referencedEvent = null; |
||||
lastFetchedRefId = null; |
||||
} |
||||
return; |
||||
} |
||||
|
||||
const refKey = `${ref.type}:${ref.id}`; |
||||
|
||||
// If this is the same reference we already processed, don't re-process |
||||
if (lastFetchedRefId === refKey && (referencedEvent || websiteUrl)) { |
||||
return; |
||||
} |
||||
|
||||
referenceType = ref.type; |
||||
referenceId = ref.id; |
||||
|
||||
// For website references, just set the URL - no event to fetch |
||||
if (ref.type === 'website' && 'url' in ref) { |
||||
websiteUrl = ref.url; |
||||
lastFetchedRefId = refKey; |
||||
return; |
||||
} |
||||
|
||||
// Otherwise, load the referenced event (only if not already loading and not already fetched) |
||||
if (!referencedEvent && !loading && lastFetchedRefId !== refKey) { |
||||
loadReferencedEvent(); |
||||
} |
||||
}); |
||||
|
||||
function handleViewEvent() { |
||||
if (referencedEvent) { |
||||
goto(`/event/${referencedEvent.id}`); |
||||
} |
||||
} |
||||
</script> |
||||
|
||||
{#if getReference()} |
||||
<div class="referenced-event-preview"> |
||||
<div class="referenced-event-header"> |
||||
<span class="referenced-event-label"> |
||||
{referenceType === 'website' ? 'Website:' : referenceType === 'quote' ? 'Quote from:' : referenceType === 'addressable' ? 'Reference:' : 'Reply to:'} |
||||
</span> |
||||
{#if referenceType === 'website' && websiteUrl} |
||||
<a |
||||
href={websiteUrl} |
||||
target="_blank" |
||||
rel="noopener noreferrer" |
||||
class="view-website-button" |
||||
aria-label="Open website" |
||||
title="Open website" |
||||
> |
||||
<Icon name="eye" size={16} /> |
||||
</a> |
||||
{:else if loading} |
||||
<span class="loading-text">Loading...</span> |
||||
{:else if error} |
||||
<span class="error-text">{error}</span> |
||||
{:else if referencedEvent} |
||||
<button |
||||
class="view-event-button" |
||||
onclick={handleViewEvent} |
||||
aria-label="View event" |
||||
title="View event" |
||||
> |
||||
<Icon name="eye" size={16} /> |
||||
</button> |
||||
{/if} |
||||
</div> |
||||
|
||||
{#if referenceType === 'website' && websiteUrl} |
||||
<div class="referenced-event-content"> |
||||
<a |
||||
href={websiteUrl} |
||||
target="_blank" |
||||
rel="noopener noreferrer" |
||||
class="website-link" |
||||
> |
||||
{websiteUrl} |
||||
</a> |
||||
</div> |
||||
{:else if referencedEvent} |
||||
{@const title = getTitle(referencedEvent)} |
||||
<div class="referenced-event-content"> |
||||
{#if title} |
||||
<div class="referenced-event-title">{title}</div> |
||||
{/if} |
||||
<div class="referenced-event-preview-text">{getPreview(referencedEvent)}</div> |
||||
</div> |
||||
{/if} |
||||
</div> |
||||
{/if} |
||||
|
||||
<style> |
||||
.referenced-event-preview { |
||||
margin-bottom: 1rem; |
||||
padding: 0.75rem; |
||||
background: var(--fog-highlight, #f3f4f6); |
||||
border: 1px solid var(--fog-border, #e5e7eb); |
||||
border-left: 3px solid var(--fog-accent, #64748b); |
||||
border-radius: 0.375rem; |
||||
} |
||||
|
||||
:global(.dark) .referenced-event-preview { |
||||
background: var(--fog-dark-highlight, #374151); |
||||
border-color: var(--fog-dark-border, #475569); |
||||
border-left-color: var(--fog-dark-accent, #94a3b8); |
||||
} |
||||
|
||||
.referenced-event-header { |
||||
display: flex; |
||||
align-items: center; |
||||
justify-content: space-between; |
||||
margin-bottom: 0.5rem; |
||||
font-size: 0.875rem; |
||||
} |
||||
|
||||
.referenced-event-label { |
||||
font-weight: 600; |
||||
color: var(--fog-text, #1f2937); |
||||
} |
||||
|
||||
:global(.dark) .referenced-event-label { |
||||
color: var(--fog-dark-text, #f9fafb); |
||||
} |
||||
|
||||
.loading-text, |
||||
.error-text { |
||||
font-size: 0.75rem; |
||||
color: var(--fog-text-light, #6b7280); |
||||
font-style: italic; |
||||
} |
||||
|
||||
:global(.dark) .loading-text, |
||||
:global(.dark) .error-text { |
||||
color: var(--fog-dark-text-light, #9ca3af); |
||||
} |
||||
|
||||
.view-event-button { |
||||
display: flex; |
||||
align-items: center; |
||||
justify-content: center; |
||||
padding: 0.25rem 0.5rem; |
||||
background: transparent; |
||||
border: 1px solid var(--fog-border, #e5e7eb); |
||||
border-radius: 0.25rem; |
||||
cursor: pointer; |
||||
transition: all 0.2s; |
||||
color: var(--fog-text, #1f2937); |
||||
} |
||||
|
||||
.view-event-button:hover { |
||||
background: var(--fog-post, #ffffff); |
||||
border-color: var(--fog-accent, #64748b); |
||||
} |
||||
|
||||
:global(.dark) .view-event-button { |
||||
border-color: var(--fog-dark-border, #475569); |
||||
color: var(--fog-dark-text, #f9fafb); |
||||
} |
||||
|
||||
:global(.dark) .view-event-button:hover { |
||||
background: var(--fog-dark-post, #1f2937); |
||||
border-color: var(--fog-dark-accent, #94a3b8); |
||||
} |
||||
|
||||
.referenced-event-content { |
||||
display: flex; |
||||
flex-direction: column; |
||||
gap: 0.5rem; |
||||
} |
||||
|
||||
.referenced-event-title { |
||||
font-weight: 600; |
||||
font-size: 0.875rem; |
||||
color: var(--fog-text, #1f2937); |
||||
} |
||||
|
||||
:global(.dark) .referenced-event-title { |
||||
color: var(--fog-dark-text, #f9fafb); |
||||
} |
||||
|
||||
.referenced-event-preview-text { |
||||
font-size: 0.875rem; |
||||
color: var(--fog-text-light, #6b7280); |
||||
line-height: 1.5; |
||||
white-space: pre-wrap; |
||||
word-wrap: break-word; |
||||
} |
||||
|
||||
:global(.dark) .referenced-event-preview-text { |
||||
color: var(--fog-dark-text-light, #9ca3af); |
||||
} |
||||
|
||||
.view-website-button { |
||||
display: flex; |
||||
align-items: center; |
||||
justify-content: center; |
||||
padding: 0.25rem 0.5rem; |
||||
background: transparent; |
||||
border: 1px solid var(--fog-border, #e5e7eb); |
||||
border-radius: 0.25rem; |
||||
cursor: pointer; |
||||
transition: all 0.2s; |
||||
color: var(--fog-text, #1f2937); |
||||
text-decoration: none; |
||||
} |
||||
|
||||
.view-website-button:hover { |
||||
background: var(--fog-post, #ffffff); |
||||
border-color: var(--fog-accent, #64748b); |
||||
} |
||||
|
||||
:global(.dark) .view-website-button { |
||||
border-color: var(--fog-dark-border, #475569); |
||||
color: var(--fog-dark-text, #f9fafb); |
||||
} |
||||
|
||||
:global(.dark) .view-website-button:hover { |
||||
background: var(--fog-dark-post, #1f2937); |
||||
border-color: var(--fog-dark-accent, #94a3b8); |
||||
} |
||||
|
||||
.website-link { |
||||
font-size: 0.875rem; |
||||
color: var(--fog-accent, #64748b); |
||||
text-decoration: none; |
||||
word-break: break-all; |
||||
line-height: 1.5; |
||||
} |
||||
|
||||
.website-link:hover { |
||||
text-decoration: underline; |
||||
color: var(--fog-accent-dark, #475569); |
||||
} |
||||
|
||||
:global(.dark) .website-link { |
||||
color: var(--fog-dark-accent, #94a3b8); |
||||
} |
||||
|
||||
:global(.dark) .website-link:hover { |
||||
color: var(--fog-dark-accent-light, #cbd5e1); |
||||
} |
||||
</style> |
||||
@ -0,0 +1,671 @@
@@ -0,0 +1,671 @@
|
||||
<script lang="ts"> |
||||
import { sessionManager } from '../../services/auth/session-manager.js'; |
||||
import { signAndPublish } from '../../services/nostr/auth-handler.js'; |
||||
import { uploadFileToServer, buildImetaTag } from '../../services/nostr/file-upload.js'; |
||||
import { relayManager } from '../../services/nostr/relay-manager.js'; |
||||
import { fetchRelayLists } from '../../services/user-data.js'; |
||||
import { getDraft, saveDraft, deleteDraft } from '../../services/cache/draft-store.js'; |
||||
import PublicationStatusModal from '../../components/modals/PublicationStatusModal.svelte'; |
||||
import MarkdownRenderer from '../../components/content/MarkdownRenderer.svelte'; |
||||
import MediaAttachments from '../../components/content/MediaAttachments.svelte'; |
||||
import RichTextEditor from '../../components/content/RichTextEditor.svelte'; |
||||
import { KIND } from '../../types/kind-lookup.js'; |
||||
import { shouldIncludeClientTag } from '../../services/client-tag-preference.js'; |
||||
import { cacheEvent } from '../../services/cache/event-cache.js'; |
||||
import { autoExtractTags } from '../../services/auto-tagging.js'; |
||||
import type { NostrEvent } from '../../types/nostr.js'; |
||||
|
||||
interface Props { |
||||
url: string; // The RSS item URL |
||||
onPublished?: () => void; |
||||
onCancel?: () => void; |
||||
} |
||||
|
||||
let { url, onPublished, onCancel }: Props = $props(); |
||||
|
||||
// Create unique draft ID based on URL |
||||
const DRAFT_ID = $derived(`rss_comment_${url}`); |
||||
|
||||
let content = $state(''); |
||||
let publishing = $state(false); |
||||
|
||||
// Restore draft from IndexedDB on mount |
||||
$effect(() => { |
||||
if (typeof window === 'undefined') return; |
||||
|
||||
(async () => { |
||||
try { |
||||
const draft = await getDraft(DRAFT_ID); |
||||
if (draft && draft.content !== undefined && content === '') { |
||||
content = draft.content; |
||||
} |
||||
} catch (error) { |
||||
console.error('Error restoring RSS comment draft:', error); |
||||
} |
||||
})(); |
||||
}); |
||||
|
||||
// Save draft to IndexedDB when content changes |
||||
$effect(() => { |
||||
if (typeof window === 'undefined') return; |
||||
if (publishing) return; |
||||
|
||||
const timeoutId = setTimeout(async () => { |
||||
try { |
||||
if (content.trim()) { |
||||
await saveDraft(DRAFT_ID, { content }); |
||||
} else { |
||||
await deleteDraft(DRAFT_ID); |
||||
} |
||||
} catch (error) { |
||||
console.error('Error saving RSS comment draft:', error); |
||||
} |
||||
}, 500); |
||||
|
||||
return () => clearTimeout(timeoutId); |
||||
}); |
||||
|
||||
let showStatusModal = $state(false); |
||||
let publicationResults: { success: string[]; failed: Array<{ relay: string; error: string }> } | null = $state(null); |
||||
let showJsonModal = $state(false); |
||||
let showPreviewModal = $state(false); |
||||
let previewContent = $state<string>(''); |
||||
let previewEvent = $state<NostrEvent | null>(null); |
||||
let richTextEditorRef: { clearUploadedFiles: () => void; getUploadedFiles: () => Array<{ url: string; imetaTag: string[] }> } | null = $state(null); |
||||
let uploadedFiles: Array<{ url: string; imetaTag: string[] }> = $state([]); |
||||
let eventJson = $state('{}'); |
||||
const isLoggedIn = $derived(sessionManager.isLoggedIn()); |
||||
|
||||
async function publishComment() { |
||||
if (!isLoggedIn || !content.trim() || publishing) return; |
||||
|
||||
publishing = true; |
||||
try { |
||||
const session = sessionManager.getSession(); |
||||
if (!session) { |
||||
throw new Error('Not logged in'); |
||||
} |
||||
|
||||
const tags: string[][] = []; |
||||
|
||||
// For RSS items, we use kind 1111 comments |
||||
// Add "i" tag with the URL |
||||
tags.push(['i', url]); |
||||
|
||||
// Add "r" tag with the URL for relay hints |
||||
tags.push(['r', url]); |
||||
|
||||
if (shouldIncludeClientTag()) { |
||||
tags.push(['client', 'aitherboard']); |
||||
} |
||||
|
||||
// Add file attachments as imeta tags |
||||
let contentWithUrls = content.trim(); |
||||
for (const file of uploadedFiles) { |
||||
const imetaTag = Array.isArray(file.imetaTag) ? [...file.imetaTag] : file.imetaTag; |
||||
tags.push(imetaTag); |
||||
if (file.url) { |
||||
contentWithUrls += `\n${file.url}`; |
||||
} |
||||
} |
||||
|
||||
// Auto-extract tags from content (hashtags, mentions, nostr: links) |
||||
const autoTagsResult = await autoExtractTags({ |
||||
content: contentWithUrls, |
||||
existingTags: tags, |
||||
kind: KIND.COMMENT |
||||
}); |
||||
tags.push(...autoTagsResult.tags); |
||||
|
||||
// Process content to add "nostr:" prefix if needed |
||||
const { processNostrLinks } = await import('../../utils/nostr-link-processor.js'); |
||||
const processedContent = processNostrLinks(content.trim()); |
||||
|
||||
const plainTags: string[][] = tags.map(tag => [...tag]); |
||||
|
||||
const event: Omit<NostrEvent, 'sig' | 'id'> = { |
||||
kind: KIND.COMMENT, |
||||
pubkey: session.pubkey, |
||||
created_at: Math.floor(Date.now() / 1000), |
||||
tags: plainTags, |
||||
content: processedContent |
||||
}; |
||||
|
||||
const relayLists = await fetchRelayLists(session.pubkey); |
||||
const allRelays = [...new Set([...relayLists.inbox, ...relayLists.outbox])]; |
||||
const publishRelays = relayManager.getPublishRelays(allRelays, true); |
||||
|
||||
// Sign the event first |
||||
const signedEvent = await sessionManager.signEvent(event); |
||||
|
||||
// Cache the event before publishing |
||||
await cacheEvent(signedEvent); |
||||
|
||||
// Publish the event |
||||
await signAndPublish(event, publishRelays); |
||||
|
||||
// Clear draft |
||||
await deleteDraft(DRAFT_ID); |
||||
|
||||
// Clear form |
||||
content = ''; |
||||
if (richTextEditorRef) { |
||||
richTextEditorRef.clearUploadedFiles(); |
||||
} |
||||
uploadedFiles = []; |
||||
|
||||
if (onPublished) { |
||||
onPublished(); |
||||
} |
||||
} catch (error) { |
||||
console.error('Error publishing RSS comment:', error); |
||||
alert(error instanceof Error ? error.message : 'Failed to publish comment'); |
||||
} finally { |
||||
publishing = false; |
||||
} |
||||
} |
||||
|
||||
function handleFilesUploaded(files: Array<{ url: string; imetaTag: string[] }>) { |
||||
uploadedFiles = files; |
||||
} |
||||
|
||||
function handleCancel() { |
||||
if (onCancel) { |
||||
onCancel(); |
||||
} |
||||
} |
||||
|
||||
function clearForm() { |
||||
content = ''; |
||||
if (richTextEditorRef) { |
||||
richTextEditorRef.clearUploadedFiles(); |
||||
} |
||||
uploadedFiles = []; |
||||
deleteDraft(DRAFT_ID).catch(err => { |
||||
console.error('Error deleting draft:', err); |
||||
}); |
||||
} |
||||
|
||||
async function getEventJson(): Promise<string> { |
||||
const session = sessionManager.getSession(); |
||||
if (!session) { |
||||
return '{}'; |
||||
} |
||||
|
||||
const tags: string[][] = []; |
||||
tags.push(['i', url]); |
||||
tags.push(['r', url]); |
||||
|
||||
if (shouldIncludeClientTag()) { |
||||
tags.push(['client', 'aitherboard']); |
||||
} |
||||
|
||||
let contentWithUrls = content.trim(); |
||||
for (const file of uploadedFiles) { |
||||
const imetaTag = Array.isArray(file.imetaTag) ? [...file.imetaTag] : file.imetaTag; |
||||
tags.push(imetaTag); |
||||
if (file.url) { |
||||
contentWithUrls += `\n${file.url}`; |
||||
} |
||||
} |
||||
|
||||
const autoTagsResult = await autoExtractTags({ |
||||
content: contentWithUrls, |
||||
existingTags: tags, |
||||
kind: KIND.COMMENT |
||||
}); |
||||
tags.push(...autoTagsResult.tags); |
||||
|
||||
// Process content to add "nostr:" prefix if needed |
||||
const { processNostrLinks } = await import('../../utils/nostr-link-processor.js'); |
||||
const processedContent = processNostrLinks(content.trim()); |
||||
|
||||
const event: Omit<NostrEvent, 'id' | 'sig'> = { |
||||
kind: KIND.COMMENT, |
||||
pubkey: session.pubkey, |
||||
created_at: Math.floor(Date.now() / 1000), |
||||
tags, |
||||
content: processedContent |
||||
}; |
||||
|
||||
return JSON.stringify(event, null, 2); |
||||
} |
||||
|
||||
async function showPreview() { |
||||
// Generate preview content with all processing applied |
||||
let contentWithUrls = content.trim(); |
||||
for (const file of uploadedFiles) { |
||||
if (!contentWithUrls.includes(file.url)) { |
||||
if (contentWithUrls && !contentWithUrls.endsWith('\n')) { |
||||
contentWithUrls += '\n'; |
||||
} |
||||
contentWithUrls += `${file.url}\n`; |
||||
} |
||||
} |
||||
|
||||
// Process content to add "nostr:" prefix |
||||
const { processNostrLinks } = await import('../../utils/nostr-link-processor.js'); |
||||
previewContent = processNostrLinks(contentWithUrls.trim()); |
||||
|
||||
// Build preview event with all tags |
||||
const previewTags: string[][] = []; |
||||
previewTags.push(['i', url]); |
||||
previewTags.push(['r', url]); |
||||
|
||||
for (const file of uploadedFiles) { |
||||
previewTags.push(file.imetaTag); |
||||
} |
||||
|
||||
// Auto-extract tags |
||||
const autoTagsResult = await autoExtractTags({ |
||||
content: contentWithUrls, |
||||
existingTags: previewTags, |
||||
kind: KIND.COMMENT |
||||
}); |
||||
previewTags.push(...autoTagsResult.tags); |
||||
|
||||
if (shouldIncludeClientTag()) { |
||||
previewTags.push(['client', 'aitherboard']); |
||||
} |
||||
|
||||
previewEvent = { |
||||
kind: KIND.COMMENT, |
||||
pubkey: sessionManager.getCurrentPubkey() || '', |
||||
created_at: Math.floor(Date.now() / 1000), |
||||
tags: previewTags, |
||||
content: previewContent, |
||||
id: '', |
||||
sig: '' |
||||
} as NostrEvent; |
||||
|
||||
showPreviewModal = true; |
||||
} |
||||
</script> |
||||
|
||||
<div class="rss-comment-form"> |
||||
<RichTextEditor |
||||
bind:this={richTextEditorRef} |
||||
bind:value={content} |
||||
placeholder="Write a comment..." |
||||
onFilesUploaded={handleFilesUploaded} |
||||
showToolbar={true} |
||||
uploadContext="RSSCommentForm" |
||||
/> |
||||
|
||||
<div class="form-actions flex items-center justify-between mt-2"> |
||||
<div class="form-actions-left flex items-center gap-2"> |
||||
<button |
||||
type="button" |
||||
onclick={async () => { |
||||
eventJson = await getEventJson(); |
||||
showJsonModal = true; |
||||
}} |
||||
class="btn-action" |
||||
disabled={publishing} |
||||
title="View JSON" |
||||
> |
||||
View JSON |
||||
</button> |
||||
<button |
||||
type="button" |
||||
onclick={showPreview} |
||||
class="btn-action" |
||||
disabled={publishing} |
||||
title="Preview" |
||||
> |
||||
Preview |
||||
</button> |
||||
<button |
||||
type="button" |
||||
onclick={clearForm} |
||||
class="btn-action" |
||||
disabled={publishing} |
||||
title="Clear comment" |
||||
> |
||||
Clear |
||||
</button> |
||||
</div> |
||||
<div class="form-actions-right flex items-center gap-2"> |
||||
{#if onCancel} |
||||
<button |
||||
onclick={handleCancel} |
||||
class="btn-secondary" |
||||
disabled={publishing} |
||||
> |
||||
Cancel |
||||
</button> |
||||
{/if} |
||||
<button |
||||
onclick={publishComment} |
||||
disabled={!content.trim() || publishing || !isLoggedIn} |
||||
class="btn-primary" |
||||
> |
||||
{publishing ? 'Publishing...' : 'Publish Comment'} |
||||
</button> |
||||
</div> |
||||
</div> |
||||
|
||||
<!-- JSON View Modal --> |
||||
{#if showJsonModal} |
||||
<div |
||||
class="modal-overlay" |
||||
onclick={() => showJsonModal = false} |
||||
onkeydown={(e) => { |
||||
if (e.key === 'Escape' || e.key === 'Enter' || e.key === ' ') { |
||||
e.preventDefault(); |
||||
showJsonModal = false; |
||||
} |
||||
}} |
||||
role="dialog" |
||||
aria-modal="true" |
||||
tabindex="0" |
||||
> |
||||
<div |
||||
class="modal-content" |
||||
onclick={(e) => e.stopPropagation()} |
||||
onkeydown={(e) => e.stopPropagation()} |
||||
role="none" |
||||
> |
||||
<div class="modal-header"> |
||||
<h2>Event JSON</h2> |
||||
<button onclick={() => showJsonModal = false} class="close-button">×</button> |
||||
</div> |
||||
<div class="modal-body"> |
||||
<pre class="json-preview">{eventJson}</pre> |
||||
</div> |
||||
<div class="modal-footer"> |
||||
<button onclick={() => { |
||||
navigator.clipboard.writeText(eventJson); |
||||
alert('JSON copied to clipboard'); |
||||
}}>Copy</button> |
||||
<button onclick={() => showJsonModal = false}>Close</button> |
||||
</div> |
||||
</div> |
||||
</div> |
||||
{/if} |
||||
|
||||
<!-- Preview Modal --> |
||||
{#if showPreviewModal} |
||||
<div |
||||
class="modal-overlay" |
||||
onclick={() => showPreviewModal = false} |
||||
onkeydown={(e) => { |
||||
if (e.key === 'Escape' || e.key === 'Enter' || e.key === ' ') { |
||||
e.preventDefault(); |
||||
showPreviewModal = false; |
||||
} |
||||
}} |
||||
role="dialog" |
||||
aria-modal="true" |
||||
tabindex="0" |
||||
> |
||||
<div |
||||
class="modal-content preview-modal" |
||||
onclick={(e) => e.stopPropagation()} |
||||
onkeydown={(e) => e.stopPropagation()} |
||||
role="none" |
||||
> |
||||
<div class="modal-header"> |
||||
<h2>Preview</h2> |
||||
<button onclick={() => showPreviewModal = false} class="close-button">×</button> |
||||
</div> |
||||
<div class="modal-body preview-body"> |
||||
{#if previewEvent && previewContent} |
||||
<MediaAttachments event={previewEvent} /> |
||||
<MarkdownRenderer content={previewContent} event={previewEvent} /> |
||||
{:else if content.trim() || uploadedFiles.length > 0} |
||||
<p class="text-muted">Loading preview...</p> |
||||
{:else} |
||||
<p class="text-muted">No content to preview</p> |
||||
{/if} |
||||
</div> |
||||
<div class="modal-footer"> |
||||
<button onclick={() => showPreviewModal = false}>Close</button> |
||||
</div> |
||||
</div> |
||||
</div> |
||||
{/if} |
||||
</div> |
||||
|
||||
<style> |
||||
.rss-comment-form { |
||||
padding: 1rem; |
||||
background: var(--fog-post, #ffffff); |
||||
border: 1px solid var(--fog-border, #e5e7eb); |
||||
border-radius: 0.5rem; |
||||
} |
||||
|
||||
:global(.dark) .rss-comment-form { |
||||
background: var(--fog-dark-post, #1f2937); |
||||
border-color: var(--fog-dark-border, #374151); |
||||
} |
||||
|
||||
.form-actions { |
||||
margin-top: 0.5rem; |
||||
} |
||||
|
||||
.form-actions-left, |
||||
.form-actions-right { |
||||
display: flex; |
||||
align-items: center; |
||||
gap: 0.5rem; |
||||
} |
||||
|
||||
.btn-action { |
||||
padding: 0.5rem 1rem; |
||||
background: var(--fog-highlight, #f3f4f6); |
||||
color: var(--fog-text, #475569); |
||||
border: 1px solid var(--fog-border, #e5e7eb); |
||||
border-radius: 0.25rem; |
||||
cursor: pointer; |
||||
font-size: 0.875rem; |
||||
transition: all 0.2s; |
||||
} |
||||
|
||||
.btn-action:hover:not(:disabled) { |
||||
background: var(--fog-border, #e5e7eb); |
||||
} |
||||
|
||||
.btn-action:disabled { |
||||
opacity: 0.5; |
||||
cursor: not-allowed; |
||||
} |
||||
|
||||
:global(.dark) .btn-action { |
||||
background: var(--fog-dark-highlight, #374151); |
||||
color: var(--fog-dark-text, #cbd5e1); |
||||
border-color: var(--fog-dark-border, #475569); |
||||
} |
||||
|
||||
:global(.dark) .btn-action:hover:not(:disabled) { |
||||
background: var(--fog-dark-border, #475569); |
||||
} |
||||
|
||||
/* Modal styles */ |
||||
.modal-overlay { |
||||
position: fixed; |
||||
top: 0; |
||||
left: 0; |
||||
right: 0; |
||||
bottom: 0; |
||||
background: rgba(0, 0, 0, 0.5); |
||||
display: flex; |
||||
align-items: center; |
||||
justify-content: center; |
||||
z-index: 1000; |
||||
padding: 1rem; |
||||
} |
||||
|
||||
.modal-content { |
||||
background: var(--fog-post, #ffffff); |
||||
border-radius: 0.5rem; |
||||
max-width: 90vw; |
||||
max-height: 90vh; |
||||
width: 100%; |
||||
max-width: 800px; |
||||
display: flex; |
||||
flex-direction: column; |
||||
box-shadow: 0 10px 25px rgba(0, 0, 0, 0.2); |
||||
} |
||||
|
||||
:global(.dark) .modal-content { |
||||
background: var(--fog-dark-post, #1f2937); |
||||
} |
||||
|
||||
.preview-modal { |
||||
max-width: 1000px; |
||||
} |
||||
|
||||
.modal-header { |
||||
display: flex; |
||||
justify-content: space-between; |
||||
align-items: center; |
||||
padding: 1rem; |
||||
border-bottom: 1px solid var(--fog-border, #e5e7eb); |
||||
} |
||||
|
||||
:global(.dark) .modal-header { |
||||
border-bottom-color: var(--fog-dark-border, #475569); |
||||
} |
||||
|
||||
.modal-header h2 { |
||||
margin: 0; |
||||
font-size: 1.25rem; |
||||
font-weight: 600; |
||||
color: var(--fog-text, #1f2937); |
||||
} |
||||
|
||||
:global(.dark) .modal-header h2 { |
||||
color: var(--fog-dark-text, #f9fafb); |
||||
} |
||||
|
||||
.close-button { |
||||
background: none; |
||||
border: none; |
||||
font-size: 1.5rem; |
||||
cursor: pointer; |
||||
color: var(--fog-text, #1f2937); |
||||
padding: 0; |
||||
width: 2rem; |
||||
height: 2rem; |
||||
display: flex; |
||||
align-items: center; |
||||
justify-content: center; |
||||
border-radius: 0.25rem; |
||||
} |
||||
|
||||
.close-button:hover { |
||||
background: var(--fog-highlight, #f3f4f6); |
||||
} |
||||
|
||||
:global(.dark) .close-button { |
||||
color: var(--fog-dark-text, #f9fafb); |
||||
} |
||||
|
||||
:global(.dark) .close-button:hover { |
||||
background: var(--fog-dark-highlight, #374151); |
||||
} |
||||
|
||||
.modal-body { |
||||
padding: 1rem; |
||||
overflow-y: auto; |
||||
flex: 1; |
||||
} |
||||
|
||||
.preview-body { |
||||
max-height: 60vh; |
||||
} |
||||
|
||||
.json-preview { |
||||
background: var(--fog-highlight, #f3f4f6); |
||||
padding: 1rem; |
||||
border-radius: 0.25rem; |
||||
overflow-x: auto; |
||||
font-family: 'Courier New', monospace; |
||||
font-size: 0.875rem; |
||||
white-space: pre-wrap; |
||||
word-wrap: break-word; |
||||
} |
||||
|
||||
:global(.dark) .json-preview { |
||||
background: var(--fog-dark-highlight, #374151); |
||||
color: var(--fog-dark-text, #f9fafb); |
||||
} |
||||
|
||||
.modal-footer { |
||||
display: flex; |
||||
justify-content: flex-end; |
||||
gap: 0.5rem; |
||||
padding: 1rem; |
||||
border-top: 1px solid var(--fog-border, #e5e7eb); |
||||
} |
||||
|
||||
:global(.dark) .modal-footer { |
||||
border-top-color: var(--fog-dark-border, #475569); |
||||
} |
||||
|
||||
.modal-footer button { |
||||
padding: 0.5rem 1rem; |
||||
background: var(--fog-accent, #64748b); |
||||
color: white; |
||||
border: none; |
||||
border-radius: 0.25rem; |
||||
cursor: pointer; |
||||
font-weight: 500; |
||||
} |
||||
|
||||
.modal-footer button:hover { |
||||
opacity: 0.9; |
||||
} |
||||
|
||||
.text-muted { |
||||
color: var(--fog-text-light, #6b7280); |
||||
font-style: italic; |
||||
} |
||||
|
||||
:global(.dark) .text-muted { |
||||
color: var(--fog-dark-text-light, #9ca3af); |
||||
} |
||||
|
||||
.btn-primary { |
||||
padding: 0.5rem 1rem; |
||||
background: var(--fog-accent, #64748b); |
||||
color: white; |
||||
border: none; |
||||
border-radius: 0.25rem; |
||||
cursor: pointer; |
||||
font-weight: 500; |
||||
} |
||||
|
||||
.btn-primary:hover:not(:disabled) { |
||||
background: var(--fog-accent-dark, #475569); |
||||
} |
||||
|
||||
.btn-primary:disabled { |
||||
opacity: 0.5; |
||||
cursor: not-allowed; |
||||
} |
||||
|
||||
.btn-secondary { |
||||
padding: 0.5rem 1rem; |
||||
background: var(--fog-highlight, #f3f4f6); |
||||
color: var(--fog-text, #475569); |
||||
border: 1px solid var(--fog-border, #e5e7eb); |
||||
border-radius: 0.25rem; |
||||
cursor: pointer; |
||||
} |
||||
|
||||
:global(.dark) .btn-secondary { |
||||
background: var(--fog-dark-highlight, #374151); |
||||
color: var(--fog-dark-text, #cbd5e1); |
||||
border-color: var(--fog-dark-border, #475569); |
||||
} |
||||
|
||||
.btn-secondary:hover { |
||||
background: var(--fog-border, #e5e7eb); |
||||
} |
||||
|
||||
:global(.dark) .btn-secondary:hover { |
||||
background: var(--fog-dark-border, #475569); |
||||
} |
||||
</style> |
||||
@ -0,0 +1,46 @@
@@ -0,0 +1,46 @@
|
||||
/** |
||||
* Utility functions for generating event links |
||||
*/ |
||||
|
||||
import { nip19 } from 'nostr-tools'; |
||||
import type { NostrEvent } from '../types/nostr.js'; |
||||
|
||||
/** |
||||
* Generate a link to view an event |
||||
* Returns nevent for regular events, naddr for parameterized replaceable events |
||||
*/ |
||||
export function getEventLink(event: NostrEvent): string { |
||||
// Check if this is a parameterized replaceable event (kind 30000-39999)
|
||||
if (event.kind >= 30000 && event.kind <= 39999) { |
||||
const dTag = event.tags.find(t => t[0] === 'd' && t[1]); |
||||
if (dTag && dTag[1]) { |
||||
// Generate naddr for parameterized replaceable events
|
||||
try { |
||||
const naddr = nip19.naddrEncode({ |
||||
kind: event.kind, |
||||
pubkey: event.pubkey, |
||||
identifier: dTag[1], |
||||
relays: [] |
||||
}); |
||||
return `/event/${naddr}`; |
||||
} catch (error) { |
||||
console.error('Error encoding naddr:', error); |
||||
// Fallback to nevent
|
||||
} |
||||
} |
||||
} |
||||
|
||||
// For regular events, use nevent
|
||||
try { |
||||
const nevent = nip19.neventEncode({ |
||||
id: event.id, |
||||
author: event.pubkey, |
||||
relays: [] |
||||
}); |
||||
return `/event/${nevent}`; |
||||
} catch (error) { |
||||
console.error('Error encoding nevent:', error); |
||||
// Fallback to hex ID
|
||||
return `/event/${event.id}`; |
||||
} |
||||
} |
||||
@ -0,0 +1,769 @@
@@ -0,0 +1,769 @@
|
||||
<script lang="ts"> |
||||
import Header from '../../lib/components/layout/Header.svelte'; |
||||
import HighlightCard from '../../lib/modules/feed/HighlightCard.svelte'; |
||||
import UnifiedSearch from '../../lib/components/layout/UnifiedSearch.svelte'; |
||||
import ProfileBadge from '../../lib/components/layout/ProfileBadge.svelte'; |
||||
import { nostrClient } from '../../lib/services/nostr/nostr-client.js'; |
||||
import { relayManager } from '../../lib/services/nostr/relay-manager.js'; |
||||
import { config } from '../../lib/services/nostr/config.js'; |
||||
import { onMount } from 'svelte'; |
||||
import type { NostrEvent } from '../../lib/types/nostr.js'; |
||||
import { KIND } from '../../lib/types/kind-lookup.js'; |
||||
import { goto } from '$app/navigation'; |
||||
import Icon from '../../lib/components/ui/Icon.svelte'; |
||||
|
||||
interface HighlightItem { |
||||
event: NostrEvent; |
||||
authorPubkey: string; // Who created the highlight |
||||
} |
||||
|
||||
let allItems = $state<HighlightItem[]>([]); |
||||
let loading = $state(true); |
||||
let error = $state<string | null>(null); |
||||
let currentPage = $state(1); |
||||
let filterResult = $state<{ type: 'event' | 'pubkey' | 'text' | null; value: string | null }>({ type: null, value: null }); |
||||
let searchResults = $state<{ events: NostrEvent[]; profiles: string[] }>({ events: [], profiles: [] }); |
||||
let unifiedSearchComponent: { triggerSearch: () => void } | null = $state(null); |
||||
let hasLoadedOnce = $state(false); |
||||
|
||||
function handleFilterChange(result: { type: 'event' | 'pubkey' | 'text' | null; value: string | null }) { |
||||
filterResult = result; |
||||
} |
||||
|
||||
function handleSearchResults(results: { events: NostrEvent[]; profiles: string[] }) { |
||||
searchResults = results; |
||||
} |
||||
|
||||
function handleSearch() { |
||||
if (unifiedSearchComponent) { |
||||
unifiedSearchComponent.triggerSearch(); |
||||
} |
||||
} |
||||
const itemsPerPage = 100; |
||||
const maxTotalItems = 500; |
||||
|
||||
// Computed: filtered items based on filter result |
||||
let filteredItems = $derived.by(() => { |
||||
let filtered = allItems; |
||||
|
||||
// Filter by pubkey if provided |
||||
if (filterResult.value && filterResult.type === 'pubkey') { |
||||
const normalizedPubkey = filterResult.value.toLowerCase(); |
||||
if (/^[a-f0-9]{64}$/i.test(normalizedPubkey)) { |
||||
filtered = filtered.filter(item => item.authorPubkey.toLowerCase() === normalizedPubkey.toLowerCase()); |
||||
} |
||||
} |
||||
|
||||
return filtered; |
||||
}); |
||||
|
||||
// Computed: get events for current page |
||||
let paginatedItems = $derived.by(() => { |
||||
const start = (currentPage - 1) * itemsPerPage; |
||||
const end = start + itemsPerPage; |
||||
return filteredItems.slice(start, end); |
||||
}); |
||||
|
||||
// Computed: total pages |
||||
let totalPages = $derived.by(() => Math.ceil(filteredItems.length / itemsPerPage)); |
||||
|
||||
async function loadHighlights() { |
||||
loading = true; |
||||
error = null; |
||||
allItems = []; |
||||
currentPage = 1; |
||||
|
||||
try { |
||||
const relays = relayManager.getFeedReadRelays(); |
||||
const profileRelays = relayManager.getProfileReadRelays(); |
||||
const allRelaysForHighlights = [...new Set([...relays, ...profileRelays])]; |
||||
|
||||
// Fetch highlight events (kind 9802) - limit 100 |
||||
const highlightFilter: any = { kinds: [KIND.HIGHLIGHTED_ARTICLE], limit: 100 }; |
||||
|
||||
const highlightEvents = await nostrClient.fetchEvents( |
||||
[highlightFilter], |
||||
allRelaysForHighlights, |
||||
{ |
||||
useCache: true, |
||||
cacheResults: true, |
||||
timeout: config.standardTimeout |
||||
} |
||||
); |
||||
|
||||
console.log(`[Highlights] Found ${highlightEvents.length} highlight events from ${allRelaysForHighlights.length} relays`); |
||||
|
||||
// For highlights, we store the highlight event itself, mapped by source event ID |
||||
const highlightBySourceEvent = new Map<string, { highlight: NostrEvent; authorPubkey: string }>(); |
||||
const aTagHighlights = new Map<string, { highlight: NostrEvent; pubkey: string }>(); |
||||
const highlightsWithoutRefs: { highlight: NostrEvent; authorPubkey: string }[] = []; |
||||
|
||||
let highlightsWithETags = 0; |
||||
let highlightsWithATags = 0; |
||||
let highlightsWithNoRefs = 0; |
||||
|
||||
// First pass: extract e-tags and collect a-tags |
||||
for (const highlight of highlightEvents) { |
||||
let hasRef = false; |
||||
|
||||
// Extract e-tag (direct event reference) |
||||
const eTag = highlight.tags.find(t => t[0] === 'e' && t[1]); |
||||
if (eTag && eTag[1]) { |
||||
highlightBySourceEvent.set(eTag[1], { highlight, authorPubkey: highlight.pubkey }); |
||||
highlightsWithETags++; |
||||
hasRef = true; |
||||
} |
||||
|
||||
// Extract a-tag (addressable event: kind:pubkey:d-tag) |
||||
const aTag = highlight.tags.find(t => t[0] === 'a' && t[1]); |
||||
if (aTag && aTag[1]) { |
||||
aTagHighlights.set(aTag[1], { highlight, pubkey: highlight.pubkey }); |
||||
if (!hasRef) highlightsWithATags++; |
||||
hasRef = true; |
||||
} |
||||
|
||||
if (!hasRef) { |
||||
highlightsWithNoRefs++; |
||||
highlightsWithoutRefs.push({ highlight, authorPubkey: highlight.pubkey }); |
||||
if (highlightsWithNoRefs <= 3) { |
||||
console.debug(`[Highlights] Highlight ${highlight.id.substring(0, 16)}... has no e-tag or a-tag. Tags:`, highlight.tags.map(t => t[0]).join(', ')); |
||||
} |
||||
} |
||||
} |
||||
|
||||
console.log(`[Highlights] Found ${highlightBySourceEvent.size} e-tag references and ${aTagHighlights.size} a-tag references`); |
||||
console.log(`[Highlights] Highlights breakdown: ${highlightsWithETags} with e-tags, ${highlightsWithATags} with a-tags only, ${highlightsWithNoRefs} with no event references`); |
||||
|
||||
// Second pass: fetch events for a-tags in batches (grouped by kind+pubkey+d-tag) |
||||
if (aTagHighlights.size > 0) { |
||||
// Group a-tags by kind+pubkey+d-tag to create efficient filters |
||||
const aTagGroups = new Map<string, { aTags: string[]; pubkey: string; kind: number; dTag?: string }>(); |
||||
|
||||
for (const [aTag, info] of aTagHighlights.entries()) { |
||||
const aTagParts = aTag.split(':'); |
||||
if (aTagParts.length >= 2) { |
||||
const kind = parseInt(aTagParts[0]); |
||||
const pubkey = aTagParts[1]; |
||||
const dTag = aTagParts[2] || ''; |
||||
|
||||
const groupKey = `${kind}:${pubkey}:${dTag}`; |
||||
|
||||
if (!aTagGroups.has(groupKey)) { |
||||
aTagGroups.set(groupKey, { |
||||
aTags: [], |
||||
pubkey: info.pubkey, |
||||
kind, |
||||
dTag: dTag || undefined |
||||
}); |
||||
} |
||||
aTagGroups.get(groupKey)!.aTags.push(aTag); |
||||
} |
||||
} |
||||
|
||||
// Create batched filters (one per group) |
||||
const aTagFilters: any[] = []; |
||||
const filterToATags = new Map<number, string[]>(); |
||||
|
||||
for (const [groupKey, group] of aTagGroups.entries()) { |
||||
const firstATag = group.aTags[0]; |
||||
const aTagParts = firstATag.split(':'); |
||||
if (aTagParts.length >= 2) { |
||||
const pubkey = aTagParts[1]; |
||||
|
||||
const filter: any = { |
||||
kinds: [group.kind], |
||||
authors: [pubkey], |
||||
limit: 100 |
||||
}; |
||||
|
||||
if (group.dTag) { |
||||
filter['#d'] = [group.dTag]; |
||||
} |
||||
|
||||
const filterIndex = aTagFilters.length; |
||||
aTagFilters.push(filter); |
||||
filterToATags.set(filterIndex, group.aTags); |
||||
} |
||||
} |
||||
|
||||
// Fetch all a-tag events in one batch |
||||
if (aTagFilters.length > 0) { |
||||
try { |
||||
const aTagEvents = await nostrClient.fetchEvents( |
||||
aTagFilters, |
||||
allRelaysForHighlights, |
||||
{ |
||||
useCache: true, |
||||
cacheResults: true, |
||||
timeout: config.standardTimeout |
||||
} |
||||
); |
||||
|
||||
// Match a-tag events back to highlights |
||||
const eventToATag = new Map<string, string>(); |
||||
|
||||
for (let filterIndex = 0; filterIndex < aTagFilters.length; filterIndex++) { |
||||
const filter = aTagFilters[filterIndex]; |
||||
const aTags = filterToATags.get(filterIndex) || []; |
||||
const kind = filter.kinds[0]; |
||||
const pubkey = filter.authors[0]; |
||||
const dTag = filter['#d']?.[0]; |
||||
|
||||
const matchingEvents = aTagEvents.filter(event => |
||||
event.kind === kind && |
||||
event.pubkey === pubkey && |
||||
(!dTag || event.tags.find(t => t[0] === 'd' && t[1] === dTag)) |
||||
); |
||||
|
||||
for (const event of matchingEvents) { |
||||
for (const aTag of aTags) { |
||||
const aTagParts = aTag.split(':'); |
||||
if (aTagParts.length >= 2) { |
||||
const aTagKind = parseInt(aTagParts[0]); |
||||
const aTagPubkey = aTagParts[1]; |
||||
const aTagDTag = aTagParts[2] || ''; |
||||
|
||||
if (event.kind === aTagKind && event.pubkey === aTagPubkey) { |
||||
if (aTagDTag) { |
||||
const eventDTag = event.tags.find(t => t[0] === 'd' && t[1]); |
||||
if (eventDTag && eventDTag[1] === aTagDTag) { |
||||
eventToATag.set(event.id, aTag); |
||||
break; |
||||
} |
||||
} else { |
||||
eventToATag.set(event.id, aTag); |
||||
break; |
||||
} |
||||
} |
||||
} |
||||
} |
||||
} |
||||
} |
||||
|
||||
// Map events to highlights |
||||
for (const [eventId, aTag] of eventToATag.entries()) { |
||||
const info = aTagHighlights.get(aTag); |
||||
if (info) { |
||||
highlightBySourceEvent.set(eventId, { highlight: info.highlight, authorPubkey: info.pubkey }); |
||||
} |
||||
} |
||||
|
||||
console.log(`[Highlights] Resolved ${eventToATag.size} events from ${aTagGroups.size} a-tag groups`); |
||||
} catch (err) { |
||||
console.error('[Highlights] Error fetching events for a-tags:', err); |
||||
} |
||||
} |
||||
} |
||||
|
||||
// Get source event IDs for highlights (to fetch them for sorting/display) |
||||
const highlightSourceEventIds = Array.from(highlightBySourceEvent.keys()); |
||||
console.log(`[Highlights] Total extracted ${highlightSourceEventIds.length} source event IDs from ${highlightEvents.length} highlight events`); |
||||
|
||||
// Limit to maxTotalItems |
||||
const eventIds = highlightSourceEventIds.slice(0, maxTotalItems); |
||||
if (highlightSourceEventIds.length > maxTotalItems) { |
||||
console.log(`[Highlights] Limiting to ${maxTotalItems} items (found ${highlightSourceEventIds.length})`); |
||||
} |
||||
|
||||
// Fetch the actual events - batch to avoid relay limits |
||||
const batchSize = 100; |
||||
const allFetchedEvents: NostrEvent[] = []; |
||||
|
||||
console.log(`[Highlights] Fetching ${eventIds.length} events in batches of ${batchSize}`); |
||||
|
||||
for (let i = 0; i < eventIds.length; i += batchSize) { |
||||
const batch = eventIds.slice(i, i + batchSize); |
||||
const filters = [{ ids: batch }]; |
||||
|
||||
console.log(`[Highlights] Fetching batch ${Math.floor(i / batchSize) + 1}/${Math.ceil(eventIds.length / batchSize)} (${batch.length} events)`); |
||||
|
||||
const batchEvents = await nostrClient.fetchEvents( |
||||
filters, |
||||
relays, |
||||
{ |
||||
useCache: true, |
||||
cacheResults: true, |
||||
timeout: config.mediumTimeout |
||||
} |
||||
); |
||||
|
||||
console.log(`[Highlights] Batch ${Math.floor(i / batchSize) + 1} returned ${batchEvents.length} events`); |
||||
allFetchedEvents.push(...batchEvents); |
||||
} |
||||
|
||||
console.log(`[Highlights] Total fetched: ${allFetchedEvents.length} events`); |
||||
|
||||
// Track which highlights we've already added (to avoid duplicates) |
||||
const addedHighlightIds = new Set<string>(); |
||||
const items: HighlightItem[] = []; |
||||
|
||||
// Create HighlightItem items |
||||
for (const event of allFetchedEvents) { |
||||
const highlightInfo = highlightBySourceEvent.get(event.id); |
||||
|
||||
if (highlightInfo) { |
||||
// For highlights, use the highlight event itself, not the source event |
||||
if (!addedHighlightIds.has(highlightInfo.highlight.id)) { |
||||
items.push({ |
||||
event: highlightInfo.highlight, |
||||
authorPubkey: highlightInfo.authorPubkey |
||||
}); |
||||
addedHighlightIds.add(highlightInfo.highlight.id); |
||||
} |
||||
} |
||||
} |
||||
|
||||
// Add ALL highlights with e-tag or a-tag references, even if source event wasn't found |
||||
for (const [sourceEventId, highlightInfo] of highlightBySourceEvent.entries()) { |
||||
if (!addedHighlightIds.has(highlightInfo.highlight.id)) { |
||||
items.push({ |
||||
event: highlightInfo.highlight, |
||||
authorPubkey: highlightInfo.authorPubkey |
||||
}); |
||||
addedHighlightIds.add(highlightInfo.highlight.id); |
||||
} |
||||
} |
||||
|
||||
// Add highlights without e-tag or a-tag references (URL-only highlights, etc.) |
||||
for (const highlightInfo of highlightsWithoutRefs) { |
||||
if (!addedHighlightIds.has(highlightInfo.highlight.id)) { |
||||
items.push({ |
||||
event: highlightInfo.highlight, |
||||
authorPubkey: highlightInfo.authorPubkey |
||||
}); |
||||
addedHighlightIds.add(highlightInfo.highlight.id); |
||||
} |
||||
} |
||||
|
||||
// Sort by created_at (newest first) and limit to maxTotalItems |
||||
allItems = items.sort((a, b) => b.event.created_at - a.event.created_at).slice(0, maxTotalItems); |
||||
|
||||
// Pre-fetch all profiles for event authors in one batch to avoid individual fetches |
||||
const uniquePubkeys = new Set<string>(); |
||||
for (const item of allItems) { |
||||
uniquePubkeys.add(item.event.pubkey); |
||||
uniquePubkeys.add(item.authorPubkey); |
||||
} |
||||
|
||||
if (uniquePubkeys.size > 0) { |
||||
const profileRelays = relayManager.getProfileReadRelays(); |
||||
const pubkeyArray = Array.from(uniquePubkeys); |
||||
|
||||
nostrClient.fetchEvents( |
||||
[{ kinds: [KIND.METADATA], authors: pubkeyArray, limit: 1 }], |
||||
profileRelays, |
||||
{ |
||||
useCache: true, |
||||
cacheResults: true, |
||||
priority: 'low', |
||||
timeout: config.standardTimeout |
||||
} |
||||
).catch(err => { |
||||
console.debug('[Highlights] Error pre-fetching profiles:', err); |
||||
}); |
||||
} |
||||
} catch (err) { |
||||
console.error('Error loading highlights:', err); |
||||
error = err instanceof Error ? err.message : 'Failed to load highlights'; |
||||
} finally { |
||||
loading = false; |
||||
hasLoadedOnce = true; |
||||
} |
||||
} |
||||
|
||||
// Reset to page 1 when filter changes |
||||
$effect(() => { |
||||
filterResult; |
||||
currentPage = 1; |
||||
}); |
||||
|
||||
onMount(async () => { |
||||
await nostrClient.initialize(); |
||||
await loadHighlights(); |
||||
}); |
||||
</script> |
||||
|
||||
<Header /> |
||||
|
||||
<main class="container mx-auto px-4 py-8"> |
||||
<div class="highlights-page"> |
||||
<h1 class="font-bold text-fog-text dark:text-fog-dark-text font-mono mb-6" style="font-size: 1.5em;">/Highlights</h1> |
||||
|
||||
{#if loading} |
||||
<div class="loading-state"> |
||||
<p class="text-fog-text dark:text-fog-dark-text">Loading highlights...</p> |
||||
</div> |
||||
{:else if error} |
||||
<div class="error-state"> |
||||
<p class="text-fog-text dark:text-fog-dark-text error-message">{error}</p> |
||||
</div> |
||||
{:else if allItems.length === 0} |
||||
<div class="empty-state"> |
||||
<p class="text-fog-text dark:text-fog-dark-text">No highlights found.</p> |
||||
</div> |
||||
{:else} |
||||
<div class="filters-section-sticky mb-4"> |
||||
<div class="filters-row"> |
||||
<div class="search-filter-section"> |
||||
<UnifiedSearch |
||||
mode="search" |
||||
bind:this={unifiedSearchComponent} |
||||
allowedKinds={[KIND.HIGHLIGHTED_ARTICLE]} |
||||
hideDropdownResults={true} |
||||
onSearchResults={handleSearchResults} |
||||
onFilterChange={handleFilterChange} |
||||
placeholder="Search highlights by pubkey, event ID, or content..." |
||||
/> |
||||
</div> |
||||
</div> |
||||
|
||||
{#if totalPages > 1 && !searchResults.events.length && !searchResults.profiles.length} |
||||
<div class="pagination pagination-top"> |
||||
<button |
||||
class="pagination-button" |
||||
disabled={currentPage === 1} |
||||
onclick={() => { |
||||
if (currentPage > 1) currentPage--; |
||||
}} |
||||
aria-label="Previous page" |
||||
> |
||||
Previous |
||||
</button> |
||||
|
||||
<div class="pagination-info"> |
||||
<span class="text-fog-text dark:text-fog-dark-text"> |
||||
Page {currentPage} of {totalPages} |
||||
</span> |
||||
</div> |
||||
|
||||
<button |
||||
class="pagination-button" |
||||
disabled={currentPage === totalPages} |
||||
onclick={() => { |
||||
if (currentPage < totalPages) currentPage++; |
||||
}} |
||||
aria-label="Next page" |
||||
> |
||||
Next |
||||
</button> |
||||
</div> |
||||
{/if} |
||||
</div> |
||||
|
||||
{#if searchResults.events.length > 0 || searchResults.profiles.length > 0} |
||||
<div class="search-results-section"> |
||||
<h2 class="results-title">Search Results</h2> |
||||
|
||||
{#if searchResults.profiles.length > 0} |
||||
<div class="results-group"> |
||||
<h3>Profiles</h3> |
||||
<div class="profile-results"> |
||||
{#each searchResults.profiles as pubkey} |
||||
<a href="/profile/{pubkey}" class="profile-result-card"> |
||||
<ProfileBadge pubkey={pubkey} /> |
||||
</a> |
||||
{/each} |
||||
</div> |
||||
</div> |
||||
{/if} |
||||
|
||||
{#if searchResults.events.length > 0} |
||||
<div class="results-group"> |
||||
<h3>Events ({searchResults.events.length})</h3> |
||||
<div class="event-results"> |
||||
{#each searchResults.events as event} |
||||
{#if event.kind === KIND.HIGHLIGHTED_ARTICLE} |
||||
<div class="event-result-card"> |
||||
<HighlightCard highlight={event} onOpenEvent={(e) => goto(`/event/${e.id}`)} /> |
||||
</div> |
||||
{/if} |
||||
{/each} |
||||
</div> |
||||
</div> |
||||
{/if} |
||||
</div> |
||||
{:else} |
||||
<div class="highlights-info"> |
||||
<p class="text-fog-text dark:text-fog-dark-text text-sm"> |
||||
Showing {paginatedItems.length} of {filteredItems.length} items |
||||
{#if allItems.length >= maxTotalItems} |
||||
(limited to {maxTotalItems}) |
||||
{/if} |
||||
{#if filterResult.value} |
||||
(filtered) |
||||
{/if} |
||||
</p> |
||||
</div> |
||||
|
||||
<div class="highlights-posts"> |
||||
{#each paginatedItems as item (item.event.id)} |
||||
<div class="highlight-item-wrapper"> |
||||
<HighlightCard highlight={item.event} onOpenEvent={(event) => goto(`/event/${event.id}`)} /> |
||||
</div> |
||||
{/each} |
||||
</div> |
||||
|
||||
{#if totalPages > 1} |
||||
<div class="pagination pagination-bottom"> |
||||
<button |
||||
class="pagination-button" |
||||
disabled={currentPage === 1} |
||||
onclick={() => { |
||||
if (currentPage > 1) currentPage--; |
||||
}} |
||||
aria-label="Previous page" |
||||
> |
||||
Previous |
||||
</button> |
||||
|
||||
<div class="pagination-info"> |
||||
<span class="text-fog-text dark:text-fog-dark-text"> |
||||
Page {currentPage} of {totalPages} |
||||
</span> |
||||
</div> |
||||
|
||||
<button |
||||
class="pagination-button" |
||||
disabled={currentPage === totalPages} |
||||
onclick={() => { |
||||
if (currentPage < totalPages) currentPage++; |
||||
}} |
||||
aria-label="Next page" |
||||
> |
||||
Next |
||||
</button> |
||||
</div> |
||||
{/if} |
||||
{/if} |
||||
{/if} |
||||
</div> |
||||
</main> |
||||
|
||||
<style> |
||||
.highlights-page { |
||||
max-width: var(--content-width); |
||||
margin: 0 auto; |
||||
} |
||||
|
||||
.filters-section-sticky { |
||||
position: sticky; |
||||
top: 0; |
||||
background: var(--fog-bg, #ffffff); |
||||
background-color: var(--fog-bg, #ffffff); |
||||
z-index: 10; |
||||
padding-top: 1rem; |
||||
padding-bottom: 1rem; |
||||
margin-bottom: 1rem; |
||||
border-bottom: 1px solid var(--fog-border, #e5e7eb); |
||||
backdrop-filter: none; |
||||
opacity: 1; |
||||
} |
||||
|
||||
:global(.dark) .filters-section-sticky { |
||||
background: var(--fog-dark-bg, #0f172a); |
||||
background-color: var(--fog-dark-bg, #0f172a); |
||||
opacity: 1; |
||||
border-bottom-color: var(--fog-dark-border, #1e293b); |
||||
} |
||||
|
||||
.filters-row { |
||||
display: flex; |
||||
flex-wrap: wrap; |
||||
gap: 1rem; |
||||
align-items: center; |
||||
margin-bottom: 1rem; |
||||
} |
||||
|
||||
.search-filter-section { |
||||
flex: 1; |
||||
min-width: 200px; |
||||
} |
||||
|
||||
.highlights-info { |
||||
margin-bottom: 1rem; |
||||
} |
||||
|
||||
.highlights-posts { |
||||
display: flex; |
||||
flex-direction: column; |
||||
gap: 1rem; |
||||
} |
||||
|
||||
.highlight-item-wrapper { |
||||
margin-bottom: 1rem; |
||||
} |
||||
|
||||
.pagination { |
||||
display: flex; |
||||
justify-content: center; |
||||
align-items: center; |
||||
gap: 1rem; |
||||
margin-top: 2rem; |
||||
margin-bottom: 2rem; |
||||
} |
||||
|
||||
.pagination-top { |
||||
margin-top: 0; |
||||
margin-bottom: 0; |
||||
} |
||||
|
||||
.pagination-bottom { |
||||
margin-top: 2rem; |
||||
} |
||||
|
||||
.pagination-button { |
||||
padding: 0.5rem 1rem; |
||||
border: 1px solid var(--fog-border, #e5e7eb); |
||||
border-radius: 0.25rem; |
||||
background: var(--fog-post, #ffffff); |
||||
color: var(--fog-text, #1e293b); |
||||
cursor: pointer; |
||||
transition: all 0.2s; |
||||
} |
||||
|
||||
.pagination-button:hover:not(:disabled) { |
||||
background: var(--fog-highlight, #f1f5f9); |
||||
border-color: var(--fog-accent, #94a3b8); |
||||
} |
||||
|
||||
.pagination-button:disabled { |
||||
opacity: 0.5; |
||||
cursor: not-allowed; |
||||
} |
||||
|
||||
:global(.dark) .pagination-button { |
||||
background: var(--fog-dark-post, #1f2937); |
||||
border-color: var(--fog-dark-border, #374151); |
||||
color: var(--fog-dark-text, #f9fafb); |
||||
} |
||||
|
||||
:global(.dark) .pagination-button:hover:not(:disabled) { |
||||
background: var(--fog-dark-highlight, #475569); |
||||
border-color: var(--fog-dark-accent, #64748b); |
||||
} |
||||
|
||||
.pagination-info { |
||||
min-width: 120px; |
||||
text-align: center; |
||||
} |
||||
|
||||
.search-results-section { |
||||
margin-top: 2rem; |
||||
border: 1px solid var(--fog-border, #e5e7eb); |
||||
border-radius: 0.5rem; |
||||
padding: 2rem; |
||||
background: var(--fog-post, #ffffff); |
||||
} |
||||
|
||||
:global(.dark) .search-results-section { |
||||
border-color: var(--fog-dark-border, #374151); |
||||
background: var(--fog-dark-post, #1f2937); |
||||
} |
||||
|
||||
.results-title { |
||||
margin: 0 0 1.5rem 0; |
||||
font-size: 1.25rem; |
||||
font-weight: 600; |
||||
color: var(--fog-text, #475569); |
||||
} |
||||
|
||||
:global(.dark) .results-title { |
||||
color: var(--fog-dark-text, #cbd5e1); |
||||
} |
||||
|
||||
.results-group { |
||||
margin-bottom: 2rem; |
||||
} |
||||
|
||||
.results-group:last-child { |
||||
margin-bottom: 0; |
||||
} |
||||
|
||||
.results-group h3 { |
||||
margin: 0 0 1rem 0; |
||||
font-size: 1rem; |
||||
font-weight: 500; |
||||
color: var(--fog-text-light, #6b7280); |
||||
} |
||||
|
||||
:global(.dark) .results-group h3 { |
||||
color: var(--fog-dark-text-light, #9ca3af); |
||||
} |
||||
|
||||
.profile-results { |
||||
display: flex; |
||||
flex-direction: column; |
||||
gap: 1rem; |
||||
} |
||||
|
||||
.profile-result-card { |
||||
padding: 1rem; |
||||
border: 1px solid var(--fog-border, #e5e7eb); |
||||
border-radius: 0.375rem; |
||||
background: var(--fog-post, #ffffff); |
||||
text-decoration: none; |
||||
transition: all 0.2s; |
||||
} |
||||
|
||||
:global(.dark) .profile-result-card { |
||||
border-color: var(--fog-dark-border, #374151); |
||||
background: var(--fog-dark-post, #1f2937); |
||||
} |
||||
|
||||
.profile-result-card:hover { |
||||
border-color: var(--fog-accent, #64748b); |
||||
transform: translateY(-1px); |
||||
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1); |
||||
} |
||||
|
||||
:global(.dark) .profile-result-card:hover { |
||||
border-color: var(--fog-dark-accent, #94a3b8); |
||||
} |
||||
|
||||
.event-results { |
||||
display: flex; |
||||
flex-direction: column; |
||||
gap: 1rem; |
||||
} |
||||
|
||||
.event-result-card { |
||||
display: block; |
||||
border: 1px solid var(--fog-border, #e5e7eb); |
||||
border-radius: 0.375rem; |
||||
background: var(--fog-post, #ffffff); |
||||
overflow: hidden; |
||||
transition: all 0.2s; |
||||
text-decoration: none; |
||||
color: inherit; |
||||
} |
||||
|
||||
:global(.dark) .event-result-card { |
||||
border-color: var(--fog-dark-border, #374151); |
||||
background: var(--fog-dark-post, #1f2937); |
||||
} |
||||
|
||||
.event-result-card:hover { |
||||
border-color: var(--fog-accent, #64748b); |
||||
transform: translateY(-1px); |
||||
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1); |
||||
} |
||||
|
||||
:global(.dark) .event-result-card:hover { |
||||
border-color: var(--fog-dark-accent, #94a3b8); |
||||
} |
||||
|
||||
.loading-state, |
||||
.error-state, |
||||
.empty-state { |
||||
text-align: center; |
||||
padding: 2rem; |
||||
} |
||||
|
||||
.error-message { |
||||
color: var(--fog-error, #ef4444); |
||||
} |
||||
|
||||
:global(.dark) .error-message { |
||||
color: var(--fog-dark-error, #f87171); |
||||
} |
||||
</style> |
||||
@ -0,0 +1,265 @@
@@ -0,0 +1,265 @@
|
||||
<script lang="ts"> |
||||
import Header from '../../lib/components/layout/Header.svelte'; |
||||
import FeedPost from '../../lib/modules/feed/FeedPost.svelte'; |
||||
import { sessionManager } from '../../lib/services/auth/session-manager.js'; |
||||
import { nostrClient } from '../../lib/services/nostr/nostr-client.js'; |
||||
import { relayManager } from '../../lib/services/nostr/relay-manager.js'; |
||||
import { config } from '../../lib/services/nostr/config.js'; |
||||
import { KIND, getFeedKinds, getKindInfo } from '../../lib/types/kind-lookup.js'; |
||||
import type { NostrEvent } from '../../lib/types/nostr.js'; |
||||
import { onMount } from 'svelte'; |
||||
import { goto } from '$app/navigation'; |
||||
|
||||
interface ListInfo { |
||||
kind: number; |
||||
name: string; |
||||
dTag?: string; |
||||
pubkeys: string[]; |
||||
event: NostrEvent; |
||||
} |
||||
|
||||
let lists = $state<ListInfo[]>([]); |
||||
let selectedList: ListInfo | null = $state(null); |
||||
let events = $state<NostrEvent[]>([]); |
||||
let loading = $state(true); |
||||
let loadingEvents = $state(false); |
||||
let hasLists = $derived(lists.length > 0); |
||||
const isLoggedIn = $derived(sessionManager.isLoggedIn()); |
||||
const currentPubkey = $derived(sessionManager.getCurrentPubkey()); |
||||
|
||||
// Get all relays (default + profile + user inbox) |
||||
function getAllRelays(): string[] { |
||||
const allRelays = [ |
||||
...config.defaultRelays, |
||||
...config.profileRelays, |
||||
...relayManager.getFeedReadRelays() |
||||
]; |
||||
// Deduplicate |
||||
return [...new Set(allRelays)]; |
||||
} |
||||
|
||||
async function loadLists() { |
||||
if (!currentPubkey) { |
||||
loading = false; |
||||
return; |
||||
} |
||||
|
||||
try { |
||||
const relays = getAllRelays(); |
||||
|
||||
// Fetch kind 3 (contacts) - replaceable, one per user |
||||
const contactsEvents = await nostrClient.fetchEvents( |
||||
[{ kinds: [KIND.CONTACTS], authors: [currentPubkey], limit: 1 }], |
||||
relays, |
||||
{ useCache: true, cacheResults: true } |
||||
); |
||||
|
||||
// Fetch kind 30000 (follow_set) - parameterized replaceable, multiple per user with d-tags |
||||
const followSetEvents = await nostrClient.fetchEvents( |
||||
[{ kinds: [KIND.FOLLOW_SET], authors: [currentPubkey] }], |
||||
relays, |
||||
{ useCache: true, cacheResults: true } |
||||
); |
||||
|
||||
const allLists: ListInfo[] = []; |
||||
|
||||
// Process kind 3 contacts |
||||
if (contactsEvents.length > 0) { |
||||
const contactsEvent = contactsEvents[0]; |
||||
const pubkeys = contactsEvent.tags |
||||
.filter(tag => tag[0] === 'p' && tag[1]) |
||||
.map(tag => tag[1]); |
||||
|
||||
if (pubkeys.length > 0) { |
||||
allLists.push({ |
||||
kind: KIND.CONTACTS, |
||||
name: 'Follows', |
||||
pubkeys, |
||||
event: contactsEvent |
||||
}); |
||||
} |
||||
} |
||||
|
||||
// Process kind 30000 follow_set events |
||||
for (const followSetEvent of followSetEvents) { |
||||
const dTag = followSetEvent.tags.find(tag => tag[0] === 'd' && tag[1])?.[1]; |
||||
const pubkeys = followSetEvent.tags |
||||
.filter(tag => tag[0] === 'p' && tag[1]) |
||||
.map(tag => tag[1]); |
||||
|
||||
if (pubkeys.length > 0) { |
||||
allLists.push({ |
||||
kind: KIND.FOLLOW_SET, |
||||
name: dTag || 'Follow Set', |
||||
dTag, |
||||
pubkeys, |
||||
event: followSetEvent |
||||
}); |
||||
} |
||||
} |
||||
|
||||
// Sort by kind (3 first, then 30000), then by name |
||||
allLists.sort((a, b) => { |
||||
if (a.kind !== b.kind) { |
||||
return a.kind - b.kind; // 3 comes before 30000 |
||||
} |
||||
return a.name.localeCompare(b.name); |
||||
}); |
||||
|
||||
lists = allLists; |
||||
|
||||
// Auto-select first list if available |
||||
if (lists.length > 0 && !selectedList) { |
||||
selectedList = lists[0]; |
||||
await loadListEvents(lists[0]); |
||||
} |
||||
} catch (error) { |
||||
console.error('Error loading lists:', error); |
||||
} finally { |
||||
loading = false; |
||||
} |
||||
} |
||||
|
||||
async function loadListEvents(list: ListInfo) { |
||||
if (!list || list.pubkeys.length === 0) { |
||||
events = []; |
||||
return; |
||||
} |
||||
|
||||
loadingEvents = true; |
||||
try { |
||||
const relays = getAllRelays(); |
||||
const feedKinds = getFeedKinds(); // Get all kinds with showInFeed: true |
||||
|
||||
// Fetch events from all pubkeys in the list, with showInFeed kinds |
||||
const fetchedEvents = await nostrClient.fetchEvents( |
||||
[{ |
||||
kinds: feedKinds, |
||||
authors: list.pubkeys, |
||||
limit: 100 |
||||
}], |
||||
relays, |
||||
{ useCache: true, cacheResults: true } |
||||
); |
||||
|
||||
// Sort by created_at descending (newest first) |
||||
fetchedEvents.sort((a, b) => b.created_at - a.created_at); |
||||
|
||||
events = fetchedEvents; |
||||
} catch (error) { |
||||
console.error('Error loading list events:', error); |
||||
events = []; |
||||
} finally { |
||||
loadingEvents = false; |
||||
} |
||||
} |
||||
|
||||
function handleListChange(event: Event) { |
||||
const select = event.target as HTMLSelectElement; |
||||
const listIndex = parseInt(select.value); |
||||
if (listIndex >= 0 && listIndex < lists.length) { |
||||
selectedList = lists[listIndex]; |
||||
loadListEvents(selectedList); |
||||
} |
||||
} |
||||
|
||||
onMount(async () => { |
||||
await nostrClient.initialize(); |
||||
|
||||
if (!isLoggedIn) { |
||||
goto('/login'); |
||||
return; |
||||
} |
||||
|
||||
await loadLists(); |
||||
}); |
||||
</script> |
||||
|
||||
<Header /> |
||||
|
||||
<main class="container mx-auto px-4 py-8"> |
||||
{#if !isLoggedIn} |
||||
<div class="text-center py-8"> |
||||
<p>Please log in to view your lists.</p> |
||||
<a href="/login" class="text-fog-accent dark:text-fog-dark-accent hover:underline">Go to login</a> |
||||
</div> |
||||
{:else if loading} |
||||
<div class="text-center py-8"> |
||||
<p>Loading lists...</p> |
||||
</div> |
||||
{:else if !hasLists} |
||||
<div class="text-center py-8"> |
||||
<h1 class="font-bold text-fog-text dark:text-fog-dark-text font-mono mb-4" style="font-size: 1.5em;">/Lists</h1> |
||||
<p>You don't have any lists yet.</p> |
||||
<p class="text-sm text-fog-text-light dark:text-fog-dark-text-light mt-2"> |
||||
Create a kind 3 (contacts) or kind 30000 (follow_set) event to get started. |
||||
</p> |
||||
</div> |
||||
{:else} |
||||
<div class="lists-header mb-6"> |
||||
<h1 class="font-bold text-fog-text dark:text-fog-dark-text font-mono mb-4" style="font-size: 1.5em;">/Lists</h1> |
||||
|
||||
<div class="list-selector mb-4"> |
||||
<label for="list-select" class="block text-sm font-medium text-fog-text dark:text-fog-dark-text mb-2"> |
||||
Select a list: |
||||
</label> |
||||
<select |
||||
id="list-select" |
||||
onchange={handleListChange} |
||||
class="w-full max-w-md px-4 py-2 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" |
||||
> |
||||
{#each lists as list, index} |
||||
<option value={index} selected={selectedList === list}> |
||||
Kind {list.kind}: {list.name} ({list.pubkeys.length} {list.pubkeys.length === 1 ? 'person' : 'people'}) |
||||
</option> |
||||
{/each} |
||||
</select> |
||||
</div> |
||||
</div> |
||||
|
||||
{#if loadingEvents} |
||||
<div class="text-center py-8"> |
||||
<p>Loading events...</p> |
||||
</div> |
||||
{:else if selectedList && events.length === 0} |
||||
<div class="text-center py-8"> |
||||
<p>No events found for this list.</p> |
||||
</div> |
||||
{:else if selectedList} |
||||
<div class="events-list"> |
||||
{#each events as event (event.id)} |
||||
<FeedPost post={event} fullView={false} /> |
||||
{/each} |
||||
</div> |
||||
{/if} |
||||
{/if} |
||||
</main> |
||||
|
||||
<style> |
||||
.lists-header { |
||||
border-bottom: 1px solid var(--fog-border, #e5e7eb); |
||||
padding-bottom: 1rem; |
||||
} |
||||
|
||||
:global(.dark) .lists-header { |
||||
border-bottom-color: var(--fog-dark-border, #475569); |
||||
} |
||||
|
||||
.list-selector select { |
||||
cursor: pointer; |
||||
} |
||||
|
||||
.list-selector select:hover { |
||||
border-color: var(--fog-accent, #64748b); |
||||
} |
||||
|
||||
:global(.dark) .list-selector select:hover { |
||||
border-color: var(--fog-dark-accent, #94a3b8); |
||||
} |
||||
|
||||
.events-list { |
||||
display: flex; |
||||
flex-direction: column; |
||||
gap: 1rem; |
||||
} |
||||
</style> |
||||
Loading…
Reference in new issue