19 changed files with 2774 additions and 65 deletions
@ -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 @@ |
|||||||
|
<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 @@ |
|||||||
|
/** |
||||||
|
* 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 @@ |
|||||||
|
<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 @@ |
|||||||
|
<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