Browse Source

add the reply-to blurb back onto feed cards

master
Silberengel 1 month ago
parent
commit
b0253cc340
  1. 22
      src/lib/components/content/MetadataCard.svelte
  2. 418
      src/lib/components/content/ReferencedEventPreview.svelte
  3. 5
      src/lib/components/layout/Header.svelte
  4. 7
      src/lib/components/profile/ProfileMenu.svelte
  5. 24
      src/lib/modules/comments/Comment.svelte
  6. 17
      src/lib/modules/discussions/DiscussionCard.svelte
  7. 197
      src/lib/modules/feed/FeedPage.svelte
  8. 38
      src/lib/modules/feed/FeedPost.svelte
  9. 21
      src/lib/modules/feed/HighlightCard.svelte
  10. 20
      src/lib/modules/feed/Reply.svelte
  11. 19
      src/lib/modules/feed/ZapReceiptReply.svelte
  12. 119
      src/lib/modules/profiles/ProfilePage.svelte
  13. 671
      src/lib/modules/rss/RSSCommentForm.svelte
  14. 46
      src/lib/services/event-links.ts
  15. 3
      src/lib/types/kind-lookup.ts
  16. 136
      src/routes/bookmarks/+page.svelte
  17. 769
      src/routes/highlights/+page.svelte
  18. 265
      src/routes/lists/+page.svelte
  19. 42
      src/routes/rss/+page.svelte

22
src/lib/components/content/MetadataCard.svelte

@ -1,6 +1,10 @@ @@ -1,6 +1,10 @@
<script lang="ts">
import type { NostrEvent } from '../../types/nostr.js';
import EventMenu from '../EventMenu.svelte';
import { getEventLink } from '../../services/event-links.js';
import { goto } from '$app/navigation';
import IconButton from '../ui/IconButton.svelte';
import { sessionManager } from '../../services/auth/session-manager.js';
interface Props {
event: NostrEvent;
@ -76,7 +80,23 @@ @@ -76,7 +80,23 @@
<h2 class="metadata-title">{title}</h2>
{/if}
{#if showMenu}
<EventMenu event={event} showContentActions={false} />
<div class="flex items-center gap-2">
<IconButton
icon="eye"
label="View event"
size={16}
onclick={() => goto(getEventLink(event))}
/>
{#if sessionManager.isLoggedIn()}
<IconButton
icon="message-square"
label="Reply"
size={16}
onclick={() => {}}
/>
{/if}
<EventMenu event={event} showContentActions={false} />
</div>
{/if}
</div>

418
src/lib/components/content/ReferencedEventPreview.svelte

@ -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>

5
src/lib/components/layout/Header.svelte

@ -64,7 +64,10 @@ @@ -64,7 +64,10 @@
<a href="/relay" class="text-fog-text dark:text-fog-dark-text hover:text-fog-accent dark:hover:text-fog-dark-accent transition-colors whitespace-nowrap">/Relay</a>
<a href="/topics" class="text-fog-text dark:text-fog-dark-text hover:text-fog-accent dark:hover:text-fog-dark-accent transition-colors whitespace-nowrap">/Topics</a>
<a href="/repos" class="text-fog-text dark:text-fog-dark-text hover:text-fog-accent dark:hover:text-fog-dark-accent transition-colors whitespace-nowrap">/Repos</a>
<a href="/bookmarks" class="text-fog-text dark:text-fog-dark-text hover:text-fog-accent dark:hover:text-fog-dark-accent transition-colors whitespace-nowrap">/Bookmarks</a>
<a href="/highlights" class="text-fog-text dark:text-fog-dark-text hover:text-fog-accent dark:hover:text-fog-dark-accent transition-colors whitespace-nowrap">/Highlights</a>
{#if isLoggedIn}
<a href="/lists" class="text-fog-text dark:text-fog-dark-text hover:text-fog-accent dark:hover:text-fog-dark-accent transition-colors whitespace-nowrap">/Lists</a>
{/if}
</div>
<div class="flex items-center gap-1.5 sm:gap-2 md:gap-4 min-w-0 flex-shrink-0 nav-links">
<a href="/cache" class="text-fog-text dark:text-fog-dark-text hover:text-fog-accent dark:hover:text-fog-dark-accent transition-colors flex items-center justify-center p-1.5 rounded hover:bg-fog-highlight dark:hover:bg-fog-dark-highlight" title="Cache">

7
src/lib/components/profile/ProfileMenu.svelte

@ -377,13 +377,6 @@ @@ -377,13 +377,6 @@
</button>
{/if}
{#if isOwnProfile && onOpenBookmarks}
<div class="menu-divider"></div>
<button class="menu-item" onclick={seeBookmarks} role="menuitem">
<span class="menu-item-icon">🔖</span>
<span class="menu-item-text">See bookmarks</span>
</button>
{/if}
</div>
{/if}
</div>

24
src/lib/modules/comments/Comment.svelte

@ -2,7 +2,7 @@ @@ -2,7 +2,7 @@
import ProfileBadge from '../../components/layout/ProfileBadge.svelte';
import MarkdownRenderer from '../../components/content/MarkdownRenderer.svelte';
import MediaAttachments from '../../components/content/MediaAttachments.svelte';
import ReplyContext from '../../components/content/ReplyContext.svelte';
import ReferencedEventPreview from '../../components/content/ReferencedEventPreview.svelte';
import FeedReactionButtons from '../reactions/FeedReactionButtons.svelte';
import DiscussionVoteButtons from '../discussions/DiscussionVoteButtons.svelte';
import EventMenu from '../../components/EventMenu.svelte';
@ -12,6 +12,10 @@ @@ -12,6 +12,10 @@
import { onMount } from 'svelte';
import type { NostrEvent } from '../../types/nostr.js';
import { getKindInfo, KIND } from '../../types/kind-lookup.js';
import { getEventLink } from '../../services/event-links.js';
import { goto } from '$app/navigation';
import IconButton from '../../components/ui/IconButton.svelte';
import { sessionManager } from '../../services/auth/session-manager.js';
interface Props {
comment: NostrEvent;
@ -102,7 +106,7 @@ @@ -102,7 +106,7 @@
>
<div class="card-content" class:expanded bind:this={contentElement}>
{#if parentEvent}
<ReplyContext {parentEvent} targetId="comment-{parentEvent.id}" />
<ReferencedEventPreview event={comment} />
{/if}
<div class="comment-header flex items-center gap-2 mb-2">
@ -111,7 +115,21 @@ @@ -111,7 +115,21 @@
{#if getClientName()}
<span class="text-fog-text-light dark:text-fog-dark-text-light whitespace-nowrap" style="font-size: 0.75em;">via {getClientName()}</span>
{/if}
<div class="ml-auto">
<div class="ml-auto flex items-center gap-2">
<IconButton
icon="eye"
label="View event"
size={16}
onclick={() => goto(getEventLink(comment))}
/>
{#if sessionManager.isLoggedIn() && onReply}
<IconButton
icon="message-square"
label="Reply"
size={16}
onclick={() => handleReply()}
/>
{/if}
<EventMenu event={comment} showContentActions={true} onReply={handleReply} />
</div>
</div>

17
src/lib/modules/discussions/DiscussionCard.svelte

@ -15,6 +15,9 @@ @@ -15,6 +15,9 @@
import { getKindInfo, KIND } from '../../types/kind-lookup.js';
import { stripMarkdown } from '../../services/text-utils.js';
import Icon from '../../components/ui/Icon.svelte';
import { getEventLink } from '../../services/event-links.js';
import { goto } from '$app/navigation';
import IconButton from '../../components/ui/IconButton.svelte';
interface Props {
thread: NostrEvent;
@ -218,6 +221,20 @@ @@ -218,6 +221,20 @@
</h3>
<div class="flex items-center gap-2">
<span class="text-fog-text-light dark:text-fog-dark-text-light whitespace-nowrap" style="font-size: 0.875em;">{getRelativeTime()}</span>
<IconButton
icon="eye"
label="View event"
size={16}
onclick={() => goto(getEventLink(thread))}
/>
{#if isLoggedIn}
<IconButton
icon="message-square"
label="Reply"
size={16}
onclick={() => showReplyForm = !showReplyForm}
/>
{/if}
<div
class="interactive-element"
onclick={(e) => e.stopPropagation()}

197
src/lib/modules/feed/FeedPage.svelte

@ -38,6 +38,9 @@ @@ -38,6 +38,9 @@
let initialLoadComplete = $state(false);
let loadInProgress = $state(false);
// Preloaded referenced events (e, a, q tags) - eventId -> referenced event
let preloadedReferencedEvents = $state<Map<string, NostrEvent>>(new Map());
// Filtered events based on filterResult
let filteredEvents = $derived.by(() => {
if (!filterResult.value) {
@ -76,6 +79,49 @@ @@ -76,6 +79,49 @@
// Use filteredEvents for display
let events = $derived(filteredEvents);
// Get preloaded referenced event for a post (from e, a, or q tag)
function getReferencedEventForPost(event: NostrEvent): NostrEvent | null {
// Check q tag first
const qTag = event.tags.find(t => t[0] === 'q' && t[1]);
if (qTag && qTag[1]) {
return preloadedReferencedEvents.get(qTag[1]) || null;
}
// Check e tag
const eTag = event.tags.find(t => t[0] === 'e' && t[1] && t[1] !== event.id);
if (eTag && eTag[1]) {
return preloadedReferencedEvents.get(eTag[1]) || null;
}
// Check a tag - need to match by kind+pubkey+d-tag
const aTag = event.tags.find(t => t[0] === 'a' && t[1]);
if (aTag && aTag[1]) {
const parts = aTag[1].split(':');
if (parts.length >= 2) {
const kind = parseInt(parts[0]);
const pubkey = parts[1];
const dTag = parts[2] || '';
// Find matching event in preloaded events
for (const [eventId, refEvent] of preloadedReferencedEvents.entries()) {
if (refEvent.kind === kind && refEvent.pubkey === pubkey) {
if (dTag) {
const eventDTag = refEvent.tags.find(t => t[0] === 'd' && t[1]);
if (eventDTag && eventDTag[1] === dTag) {
return refEvent;
}
} else {
// No d-tag, just match kind and pubkey
return refEvent;
}
}
}
}
}
return null;
}
// Load waiting room events into feed
function loadWaitingRoomEvents() {
@ -138,6 +184,9 @@ @@ -138,6 +184,9 @@
const sorted = Array.from(eventMap.values()).sort((a: NostrEvent, b: NostrEvent) => b.created_at - a.created_at);
allEvents = sorted;
oldestTimestamp = Math.min(...sorted.map((e: NostrEvent) => e.created_at));
// Batch fetch referenced events for paginated events too
await batchFetchReferencedEvents(filtered);
} catch (error) {
console.error('Error loading older events:', error);
} finally {
@ -218,6 +267,9 @@ @@ -218,6 +267,9 @@
if (sorted.length > 0) {
oldestTimestamp = Math.min(...sorted.map((e: NostrEvent) => e.created_at));
}
// Batch fetch referenced events (e, a, q tags) after main events are loaded
await batchFetchReferencedEvents(sorted);
} catch (error) {
console.error('Error loading feed:', error);
if (!events.length) {
@ -229,6 +281,148 @@ @@ -229,6 +281,148 @@
loadInProgress = false;
}
}
// Collect and batch fetch all referenced events from e, a, q tags
async function batchFetchReferencedEvents(events: NostrEvent[]) {
if (!isMounted || events.length === 0) return;
const eventIds = new Set<string>(); // For e and q tags
const aTagGroups = new Map<string, { kind: number; pubkey: string; dTag?: string }>(); // For a tags
// Collect all references
for (const event of events) {
// Check q tag (quote)
const qTag = event.tags.find(t => t[0] === 'q' && t[1]);
if (qTag && qTag[1] && qTag[1] !== event.id) {
eventIds.add(qTag[1]);
}
// Check e tag (reply) - skip if it's the event's own ID
const eTag = event.tags.find(t => t[0] === 'e' && t[1] && t[1] !== event.id);
if (eTag && eTag[1]) {
eventIds.add(eTag[1]);
}
// Check a tag (addressable event)
const aTag = event.tags.find(t => t[0] === 'a' && t[1]);
if (aTag && aTag[1]) {
const parts = aTag[1].split(':');
if (parts.length >= 2) {
const kind = parseInt(parts[0]);
const pubkey = parts[1];
const dTag = parts[2] || undefined;
const groupKey = `${kind}:${pubkey}:${dTag || ''}`;
if (!aTagGroups.has(groupKey)) {
aTagGroups.set(groupKey, { kind, pubkey, dTag });
}
}
}
}
// Remove event IDs that are already in the feed (no need to fetch them)
const feedEventIds = new Set(events.map(e => e.id));
const eventIdsToFetch = Array.from(eventIds).filter(id => !feedEventIds.has(id));
if (eventIdsToFetch.length === 0 && aTagGroups.size === 0) {
return; // Nothing to fetch
}
const relays = singleRelay ? [singleRelay] : relayManager.getFeedReadRelays();
const fetchedEvents = new Map<string, NostrEvent>();
try {
// Fetch events by ID (e and q tags) in batches
if (eventIdsToFetch.length > 0) {
const batchSize = 100;
for (let i = 0; i < eventIdsToFetch.length; i += batchSize) {
const batch = eventIdsToFetch.slice(i, i + batchSize);
const events = await nostrClient.fetchEvents(
[{ ids: batch, limit: batch.length }],
relays,
{
useCache: true,
cacheResults: true,
priority: 'low', // Low priority - don't block main feed
timeout: config.standardTimeout
}
);
for (const event of events) {
fetchedEvents.set(event.id, event);
}
}
}
// Fetch addressable events (a tags) - group by kind+pubkey+d-tag
if (aTagGroups.size > 0) {
const aTagFilters: any[] = [];
const filterToATag = new Map<number, string>(); // filter index -> a-tag string
for (const [groupKey, group] of aTagGroups.entries()) {
const filter: any = {
kinds: [group.kind],
authors: [group.pubkey],
limit: 100
};
if (group.dTag) {
filter['#d'] = [group.dTag];
}
const filterIndex = aTagFilters.length;
aTagFilters.push(filter);
filterToATag.set(filterIndex, groupKey);
}
if (aTagFilters.length > 0) {
const aTagEvents = await nostrClient.fetchEvents(
aTagFilters,
relays,
{
useCache: true,
cacheResults: true,
priority: 'low',
timeout: config.standardTimeout
}
);
// Map a-tag events back to their a-tag strings
for (let filterIndex = 0; filterIndex < aTagFilters.length; filterIndex++) {
const filter = aTagFilters[filterIndex];
const groupKey = filterToATag.get(filterIndex);
if (!groupKey) continue;
const [, pubkey, dTag] = groupKey.split(':');
const kind = filter.kinds[0];
// Find matching events
const matchingEvents = aTagEvents.filter(e =>
e.kind === kind &&
e.pubkey === pubkey &&
(!dTag || e.tags.find(t => t[0] === 'd' && t[1] === dTag))
);
for (const event of matchingEvents) {
// Store by event ID (will be matched by ReferencedEventPreview)
fetchedEvents.set(event.id, event);
}
}
}
}
// Update preloaded events map (merge with existing)
if (fetchedEvents.size > 0 && isMounted) {
const merged = new Map(preloadedReferencedEvents);
for (const [id, event] of fetchedEvents.entries()) {
merged.set(id, event);
}
preloadedReferencedEvents = merged;
}
} catch (error) {
console.debug('[FeedPage] Error batch fetching referenced events:', error);
// Don't block on errors - components will fetch individually if needed
}
}
// Setup subscription (only adds to waiting room)
function setupSubscription() {
@ -308,7 +502,8 @@ @@ -308,7 +502,8 @@
{:else}
<div class="feed-posts">
{#each events as event (event.id)}
<FeedPost post={event} />
{@const referencedEvent = getReferencedEventForPost(event)}
<FeedPost post={event} preloadedReferencedEvent={referencedEvent} />
{/each}
</div>

38
src/lib/modules/feed/FeedPost.svelte

@ -6,6 +6,7 @@ @@ -6,6 +6,7 @@
import MetadataCard from '../../components/content/MetadataCard.svelte';
import ReplyContext from '../../components/content/ReplyContext.svelte';
import QuotedContext from '../../components/content/QuotedContext.svelte';
import ReferencedEventPreview from '../../components/content/ReferencedEventPreview.svelte';
import FeedReactionButtons from '../reactions/FeedReactionButtons.svelte';
import MediaViewer from '../../components/content/MediaViewer.svelte';
import CommentForm from '../comments/CommentForm.svelte';
@ -17,6 +18,9 @@ @@ -17,6 +18,9 @@
import { findNIP21Links, parseNIP21 } from '../../services/nostr/nip21-parser.js';
import { nip19 } from 'nostr-tools';
import { onMount } from 'svelte';
import { getEventLink } from '../../services/event-links.js';
import { goto } from '$app/navigation';
import IconButton from '../../components/ui/IconButton.svelte';
interface Props {
post: NostrEvent;
@ -25,9 +29,10 @@ @@ -25,9 +29,10 @@
parentEvent?: NostrEvent; // Optional parent event if already loaded
quotedEvent?: NostrEvent; // Optional quoted event if already loaded
hideTitle?: boolean; // If true, don't render the title (useful when title is rendered elsewhere)
preloadedReferencedEvent?: NostrEvent | null; // Preloaded referenced event from e/a/q tags
}
let { post, fullView = false, preloadedReactions, parentEvent: providedParentEvent, quotedEvent: providedQuotedEvent, hideTitle = false }: Props = $props();
let { post, fullView = false, preloadedReactions, parentEvent: providedParentEvent, quotedEvent: providedQuotedEvent, hideTitle = false, preloadedReferencedEvent }: Props = $props();
// Check if this event is bookmarked (async, so we use state)
// Only check if user is logged in
@ -634,6 +639,20 @@ @@ -634,6 +639,20 @@
{#if isLoggedIn && bookmarked}
<span class="bookmark-indicator bookmarked" title="Bookmarked">🔖</span>
{/if}
<IconButton
icon="eye"
label="View event"
size={16}
onclick={() => goto(getEventLink(post))}
/>
{#if isLoggedIn}
<IconButton
icon="message-square"
label="Reply"
size={16}
onclick={() => showReplyForm = !showReplyForm}
/>
{/if}
<EventMenu event={post} showContentActions={true} onReply={() => showReplyForm = !showReplyForm} />
</div>
<hr class="post-header-divider" />
@ -679,6 +698,9 @@ @@ -679,6 +698,9 @@
</h2>
{/if}
<!-- Show referenced event preview in feed view -->
<ReferencedEventPreview event={post} preloadedReferencedEvent={preloadedReferencedEvent} />
<!-- Show metadata in feed view when content is empty -->
{#if !fullView && (!post.content || !post.content.trim())}
<MetadataCard event={post} hideTitle={true} hideImageIfInMedia={true} />
@ -787,6 +809,20 @@ @@ -787,6 +809,20 @@
{#if isLoggedIn && bookmarked}
<span class="bookmark-indicator bookmarked" title="Bookmarked">🔖</span>
{/if}
<IconButton
icon="eye"
label="View event"
size={16}
onclick={() => goto(getEventLink(post))}
/>
{#if isLoggedIn}
<IconButton
icon="message-square"
label="Reply"
size={16}
onclick={() => showReplyForm = !showReplyForm}
/>
{/if}
<EventMenu event={post} showContentActions={true} onReply={() => showReplyForm = !showReplyForm} />
</div>
<div class="kind-badge feed-card-kind-badge">

21
src/lib/modules/feed/HighlightCard.svelte

@ -11,6 +11,9 @@ @@ -11,6 +11,9 @@
import { isBookmarked } from '../../services/user-actions.js';
import { sessionManager } from '../../services/auth/session-manager.js';
import Icon from '../../components/ui/Icon.svelte';
import { getEventLink } from '../../services/event-links.js';
import { goto } from '$app/navigation';
import IconButton from '../../components/ui/IconButton.svelte';
interface Props {
highlight: NostrEvent; // The highlight event (kind 9802)
@ -322,7 +325,23 @@ @@ -322,7 +325,23 @@
{/if}
<div class="ml-auto flex items-center gap-2">
{#if isLoggedIn && bookmarked}
<Icon name="bookmark" size={16} class="bookmark-indicator bookmarked" title="Bookmarked" />
<span title="Bookmarked">
<Icon name="bookmark" size={16} class="bookmark-indicator bookmarked" />
</span>
{/if}
<IconButton
icon="eye"
label="View event"
size={16}
onclick={() => goto(getEventLink(highlight))}
/>
{#if isLoggedIn}
<IconButton
icon="message-square"
label="Reply"
size={16}
onclick={() => {}}
/>
{/if}
<EventMenu event={highlight} showContentActions={true} onReply={() => {}} />
</div>

20
src/lib/modules/feed/Reply.svelte

@ -7,6 +7,10 @@ @@ -7,6 +7,10 @@
import EventMenu from '../../components/EventMenu.svelte';
import type { NostrEvent } from '../../types/nostr.js';
import { getKindInfo } from '../../types/kind-lookup.js';
import { getEventLink } from '../../services/event-links.js';
import { goto } from '$app/navigation';
import IconButton from '../../components/ui/IconButton.svelte';
import { sessionManager } from '../../services/auth/session-manager.js';
interface Props {
reply: NostrEvent;
@ -91,7 +95,21 @@ @@ -91,7 +95,21 @@
{#if getClientName()}
<span class="text-xs text-fog-text-light dark:text-fog-dark-text-light">via {getClientName()}</span>
{/if}
<div class="ml-auto">
<div class="ml-auto flex items-center gap-2">
<IconButton
icon="eye"
label="View event"
size={16}
onclick={() => goto(getEventLink(reply))}
/>
{#if sessionManager.isLoggedIn() && onReply}
<IconButton
icon="message-square"
label="Reply"
size={16}
onclick={() => onReply(reply)}
/>
{/if}
<EventMenu event={reply} showContentActions={true} onReply={() => {}} />
</div>
</div>

19
src/lib/modules/feed/ZapReceiptReply.svelte

@ -5,6 +5,9 @@ @@ -5,6 +5,9 @@
import type { NostrEvent } from '../../types/nostr.js';
import { getKindInfo } from '../../types/kind-lookup.js';
import Icon from '../../components/ui/Icon.svelte';
import { getEventLink } from '../../services/event-links.js';
import { goto } from '$app/navigation';
import IconButton from '../../components/ui/IconButton.svelte';
interface Props {
zapReceipt: NostrEvent; // Kind 9735 zap receipt
@ -103,7 +106,21 @@ @@ -103,7 +106,21 @@
<Icon name="zap" size={18} />
<span class="text-sm font-semibold">{getAmount().toLocaleString()} sats</span>
<span class="text-xs text-fog-text-light dark:text-fog-dark-text-light">{getRelativeTime()}</span>
<div class="ml-auto">
<div class="ml-auto flex items-center gap-2">
<IconButton
icon="eye"
label="View event"
size={16}
onclick={() => goto(getEventLink(zapReceipt))}
/>
{#if onReply}
<IconButton
icon="message-square"
label="Reply"
size={16}
onclick={() => onReply(zapReceipt)}
/>
{/if}
<EventMenu event={zapReceipt} showContentActions={true} />
</div>
</div>

119
src/lib/modules/profiles/ProfilePage.svelte

@ -6,7 +6,6 @@ @@ -6,7 +6,6 @@
import CommentComponent from '../comments/Comment.svelte';
import ProfileEventsPanel from '../../components/profile/ProfileEventsPanel.svelte';
import ProfileMenu from '../../components/profile/ProfileMenu.svelte';
import BookmarksPanel from '../../components/profile/BookmarksPanel.svelte';
import { fetchProfile, fetchUserStatus, fetchUserStatusEvent, type ProfileData } from '../../services/user-data.js';
import { nostrClient } from '../../services/nostr/nostr-client.js';
import { relayManager } from '../../services/nostr/relay-manager.js';
@ -30,7 +29,7 @@ @@ -30,7 +29,7 @@
let wallComments = $state<NostrEvent[]>([]); // Kind 1111 comments on the wall
let loading = $state(true);
let loadingWall = $state(false);
let activeTab = $state<'pins' | 'notifications' | 'interactions' | 'wall'>('pins');
let activeTab = $state<'pins' | 'notifications' | 'interactions' | 'wall' | 'bookmarks'>('pins');
let nip05Validations = $state<Record<string, boolean | null>>({}); // null = checking, true = valid, false = invalid
// Compute pubkey from route params
let profilePubkey = $derived.by(() => decodePubkey($page.params.pubkey));
@ -45,12 +44,13 @@ @@ -45,12 +44,13 @@
// Profile events panel state
let profileEventsPanelOpen = $state(false);
// Bookmarks panel state
let bookmarksPanelOpen = $state(false);
// Pins state
let pins = $state<NostrEvent[]>([]);
// Bookmarks state
let bookmarks = $state<NostrEvent[]>([]);
let loadingBookmarks = $state(false);
// Cleanup tracking
let isMounted = $state(true);
let activeFetchPromises = $state<Set<Promise<any>>>(new Set());
@ -79,14 +79,6 @@ @@ -79,14 +79,6 @@
profileEventsPanelOpen = false;
}
function openBookmarksPanel() {
bookmarksPanelOpen = true;
}
function closeBookmarksPanel() {
bookmarksPanelOpen = false;
}
const isOwnProfile = $derived.by(() => {
const pubkey = decodePubkey($page.params.pubkey);
return currentUserPubkey && pubkey && currentUserPubkey === pubkey;
@ -238,6 +230,80 @@ @@ -238,6 +230,80 @@
}
}
async function loadBookmarks(pubkey: string) {
if (!isMounted) return;
loadingBookmarks = true;
try {
// Fetch the user's bookmark list (kind 10003)
const profileRelays = relayManager.getProfileReadRelays();
const bookmarkLists = await nostrClient.fetchEvents(
[{ kinds: [KIND.BOOKMARKS], authors: [pubkey], limit: 400 }],
profileRelays,
{ useCache: true, cacheResults: true, timeout: config.mediumTimeout }
);
if (!isMounted || bookmarkLists.length === 0) {
if (isMounted) {
bookmarks = [];
loadingBookmarks = false;
}
return;
}
// Extract event IDs from bookmark lists
const bookmarkedIds = new Set<string>();
for (const bookmarkList of bookmarkLists) {
for (const tag of bookmarkList.tags) {
if (tag[0] === 'e' && tag[1]) {
bookmarkedIds.add(tag[1]);
}
}
}
if (bookmarkedIds.size === 0) {
if (isMounted) {
bookmarks = [];
loadingBookmarks = false;
}
return;
}
// Fetch the actual bookmarked events in batches
const batchSize = 100;
const allBookmarkedEvents: NostrEvent[] = [];
const bookmarkedIdsArray = Array.from(bookmarkedIds);
for (let i = 0; i < bookmarkedIdsArray.length; i += batchSize) {
const batch = bookmarkedIdsArray.slice(i, i + batchSize);
const fetchPromise = nostrClient.fetchEvents(
[{ ids: batch, limit: batch.length }],
profileRelays,
{ useCache: true, cacheResults: true, timeout: config.mediumTimeout }
);
activeFetchPromises.add(fetchPromise);
const batchEvents = await fetchPromise;
activeFetchPromises.delete(fetchPromise);
if (!isMounted) return;
allBookmarkedEvents.push(...batchEvents);
}
if (!isMounted) return;
// Sort by created_at descending
bookmarks = allBookmarkedEvents.sort((a, b) => b.created_at - a.created_at);
} catch (error) {
console.error('Error loading bookmarks:', error);
if (isMounted) {
bookmarks = [];
}
} finally {
if (isMounted) {
loadingBookmarks = false;
}
}
}
async function loadNotifications(pubkey: string) {
if (!isMounted) return;
try {
@ -616,6 +682,9 @@ @@ -616,6 +682,9 @@
// Step 2: Load pins for the profile being viewed
await loadPins(pubkey);
// Step 2.5: Load bookmarks for the profile being viewed
await loadBookmarks(pubkey);
// Step 3: Load notifications or interactions
if (isOwnProfile) {
await loadNotifications(pubkey);
@ -729,7 +798,7 @@ @@ -729,7 +798,7 @@
<div class="profile-npub-section mb-2">
<div class="npub-display">
<code class="npub-text">{nip19.npubEncode(profilePubkey)}</code>
<ProfileMenu pubkey={profilePubkey} onOpenBookmarks={isOwnProfile ? openBookmarksPanel : undefined} />
<ProfileMenu pubkey={profilePubkey} />
</div>
</div>
{/if}
@ -783,6 +852,12 @@ @@ -783,6 +852,12 @@
Interactions with me ({interactionsWithMe.length})
</button>
{/if}
<button
onclick={() => activeTab = 'bookmarks'}
class="px-4 py-2 font-semibold {activeTab === 'bookmarks' ? 'border-b-2 border-fog-accent dark:border-fog-dark-accent' : ''}"
>
Bookmarks ({bookmarks.length})
</button>
</div>
{#if activeTab === 'pins'}
@ -857,6 +932,18 @@ @@ -857,6 +932,18 @@
{/each}
</div>
{/if}
{:else if activeTab === 'bookmarks'}
{#if loadingBookmarks}
<p class="text-fog-text-light dark:text-fog-dark-text-light">Loading bookmarks...</p>
{:else if bookmarks.length === 0}
<p class="text-fog-text-light dark:text-fog-dark-text-light">No bookmarks yet.</p>
{:else}
<div class="bookmarks-list">
{#each bookmarks as bookmark (bookmark.id)}
<FeedPost post={bookmark} />
{/each}
</div>
{/if}
{/if}
</div>
{:else}
@ -869,10 +956,6 @@ @@ -869,10 +956,6 @@
pubkey={decodePubkey($page.params.pubkey) || ''}
onClose={closeProfileEventsPanel}
/>
<BookmarksPanel
isOpen={bookmarksPanelOpen}
onClose={closeBookmarksPanel}
/>
{/if}
</div>

671
src/lib/modules/rss/RSSCommentForm.svelte

@ -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>

46
src/lib/services/event-links.ts

@ -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}`;
}
}

3
src/lib/types/kind-lookup.ts

@ -58,6 +58,7 @@ export const KIND = { @@ -58,6 +58,7 @@ export const KIND = {
METADATA: 0,
SHORT_TEXT_NOTE: 1,
CONTACTS: 3,
FOLLOW_SET: 30000,
EVENT_DELETION: 5,
REACTION: 7,
DISCUSSION_THREAD: 11,
@ -163,7 +164,7 @@ export const KIND_LOOKUP: Record<number, KindInfo> = { @@ -163,7 +164,7 @@ export const KIND_LOOKUP: Record<number, KindInfo> = {
[KIND.EMOJI_PACK]: { number: KIND.EMOJI_PACK, description: 'Emoji Pack', showInFeed: false, isSecondaryKind: false },
[KIND.MUTE_LIST]: { number: KIND.MUTE_LIST, description: 'Mute List', showInFeed: false, isSecondaryKind: false },
[KIND.BADGES]: { number: KIND.BADGES, description: 'Badges', showInFeed: false, isSecondaryKind: false },
[KIND.FOLOW_SET]: { number: KIND.FOLOW_SET, description: 'Follow Set', showInFeed: false, isSecondaryKind: false },
[KIND.FOLLOW_SET]: { number: KIND.FOLLOW_SET, description: 'Follow Set', showInFeed: false, isSecondaryKind: false },
[KIND.HTTP_AUTH]: { number: KIND.HTTP_AUTH, description: 'HTTP Auth', showInFeed: false, isSecondaryKind: false },
// Repository (NIP-34)

136
src/routes/bookmarks/+page.svelte

@ -194,10 +194,10 @@ @@ -194,10 +194,10 @@
console.log(`[Bookmarks] Found ${highlightBySourceEvent.size} e-tag references and ${aTagHighlights.size} a-tag references`);
console.log(`[Bookmarks] 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
// Second pass: fetch events for a-tags in batches (grouped by kind+pubkey+d-tag)
if (aTagHighlights.size > 0) {
const aTagFilters: any[] = [];
const aTagToPubkey = new Map<string, string>();
// 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(':');
@ -206,22 +206,49 @@ @@ -206,22 +206,49 @@
const pubkey = aTagParts[1];
const dTag = aTagParts[2] || '';
// Create a key for grouping: kind:pubkey:d-tag
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[]>(); // filter index -> a-tags
for (const [groupKey, group] of aTagGroups.entries()) {
// Extract pubkey from the first a-tag in the group
const firstATag = group.aTags[0];
const aTagParts = firstATag.split(':');
if (aTagParts.length >= 2) {
const pubkey = aTagParts[1];
const filter: any = {
kinds: [kind],
kinds: [group.kind],
authors: [pubkey],
limit: 1
limit: 100 // Fetch up to 100 events for this kind+pubkey combination
};
if (dTag) {
filter['#d'] = [dTag];
if (group.dTag) {
filter['#d'] = [group.dTag];
}
const filterIndex = aTagFilters.length;
aTagFilters.push(filter);
aTagToPubkey.set(aTag, info.pubkey);
filterToATags.set(filterIndex, group.aTags);
}
}
// Fetch all a-tag events
// Fetch all a-tag events in one batch
if (aTagFilters.length > 0) {
try {
const aTagEvents = await nostrClient.fetchEvents(
@ -235,34 +262,57 @@ @@ -235,34 +262,57 @@
);
// Match a-tag events back to highlights
for (const event of aTagEvents) {
// Find which a-tag this event matches
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] || '';
if (event.kind === kind && event.pubkey === pubkey) {
// Check d-tag if present
if (dTag) {
const eventDTag = event.tags.find(t => t[0] === 'd' && t[1]);
if (eventDTag && eventDTag[1] === dTag) {
highlightBySourceEvent.set(event.id, { highlight: info.highlight, authorPubkey: info.pubkey });
const eventToATag = new Map<string, string>(); // event id -> a-tag
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];
// Find events that match this filter
const matchingEvents = aTagEvents.filter(event =>
event.kind === kind &&
event.pubkey === pubkey &&
(!dTag || event.tags.find(t => t[0] === 'd' && t[1] === dTag))
);
// Match events to a-tags
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;
}
} else {
// No d-tag, just match kind and pubkey
highlightBySourceEvent.set(event.id, { highlight: info.highlight, authorPubkey: info.pubkey });
break;
}
}
}
}
}
console.log(`[Bookmarks] Resolved ${aTagEvents.length} events from a-tags`);
// 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(`[Bookmarks] Resolved ${eventToATag.size} events from ${aTagGroups.size} a-tag groups`);
} catch (err) {
console.error('[Bookmarks] Error fetching events for a-tags:', err);
}
@ -368,6 +418,34 @@ @@ -368,6 +418,34 @@
// 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
// This prevents ProfileBadge components from making hundreds of individual requests
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);
// Batch fetch all profiles at once (fire and forget - don't block)
nostrClient.fetchEvents(
[{ kinds: [KIND.METADATA], authors: pubkeyArray, limit: 1 }],
profileRelays,
{
useCache: true,
cacheResults: true,
priority: 'low', // Low priority - let events load first
timeout: config.standardTimeout
}
).catch(err => {
console.debug('[Bookmarks] Error pre-fetching profiles:', err);
// Don't block on profile fetch errors
});
}
} catch (err) {
console.error('Error loading bookmarks and highlights:', err);
error = err instanceof Error ? err.message : 'Failed to load bookmarks and highlights';

769
src/routes/highlights/+page.svelte

@ -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>

265
src/routes/lists/+page.svelte

@ -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>

42
src/routes/rss/+page.svelte

@ -9,6 +9,8 @@ @@ -9,6 +9,8 @@
import type { NostrEvent } from '../../lib/types/nostr.js';
import { KIND } from '../../lib/types/kind-lookup.js';
import MarkdownRenderer from '../../lib/components/content/MarkdownRenderer.svelte';
import RSSCommentForm from '../../lib/modules/rss/RSSCommentForm.svelte';
import IconButton from '../../lib/components/ui/IconButton.svelte';
const RSS_FEED_KIND = 10895;
@ -282,6 +284,23 @@ @@ -282,6 +284,23 @@
function handleCreateRss() {
goto(`/write?kind=${RSS_FEED_KIND}`);
}
// Track which RSS item has reply form open
let openReplyFormFor = $state<string | null>(null); // URL of the item
function toggleReplyForm(itemLink: string) {
if (openReplyFormFor === itemLink) {
openReplyFormFor = null;
} else {
openReplyFormFor = itemLink;
}
}
// Generate a deterministic thread ID from the URL (for RSS items, we use the URL as threadId)
function getThreadIdFromUrl(url: string): string {
// Use the URL as the threadId for RSS items
return url;
}
</script>
<Header />
@ -390,6 +409,29 @@ @@ -390,6 +409,29 @@
<MarkdownRenderer content={item.description} />
</div>
{/if}
{#if sessionManager.isLoggedIn()}
<div class="rss-item-actions flex items-center gap-2 mt-2">
<IconButton
icon="message-square"
label="Reply"
size={16}
onclick={() => toggleReplyForm(item.link)}
/>
</div>
{/if}
{#if openReplyFormFor === item.link}
<div class="rss-item-reply-form mt-4">
<RSSCommentForm
url={item.link}
onPublished={() => {
openReplyFormFor = null;
}}
onCancel={() => {
openReplyFormFor = null;
}}
/>
</div>
{/if}
</article>
{/each}
</div>

Loading…
Cancel
Save