You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
 
 
 
 
 

676 lines
22 KiB

<script lang="ts">
import { nostrClient } from '../../services/nostr/nostr-client.js';
import { relayManager } from '../../services/nostr/relay-manager.js';
import { config } from '../../services/nostr/config.js';
import FeedPost from './FeedPost.svelte';
import type { NostrEvent } from '../../types/nostr.js';
import { onMount } from 'svelte';
import { KIND, getFeedKinds, getKindInfo } from '../../types/kind-lookup.js';
import { getRecentFeedEvents } from '../../services/cache/event-cache.js';
import { keyboardShortcuts } from '../../services/keyboard-shortcuts.js';
import { browser } from '$app/environment';
interface Props {
singleRelay?: string;
filterResult?: { type: 'event' | 'pubkey' | 'text' | null; value: string | null };
}
let { singleRelay, filterResult = { type: null, value: null } }: Props = $props();
// Expose API for parent component via component reference
// Note: The warning about loadOlderEvents is a false positive - functions don't need to be reactive
export { loadOlderEvents, loadingMore, hasMoreEvents, waitingRoomEvents, loadWaitingRoomEvents };
// Core state
let allEvents = $state<NostrEvent[]>([]);
let loading = $state(true);
let relayError = $state<string | null>(null);
// Waiting room for new events
let waitingRoomEvents = $state<NostrEvent[]>([]);
// Pagination
let oldestTimestamp = $state<number | null>(null);
let loadingMore = $state(false);
let hasMoreEvents = $state(true);
// Subscription
let subscriptionId: string | null = $state(null);
let isMounted = $state(true);
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) {
return allEvents;
}
const queryLower = filterResult.value.toLowerCase();
if (filterResult.type === 'pubkey') {
// Filter by exact pubkey match
return allEvents.filter((event: NostrEvent) => {
if (event.pubkey.toLowerCase() === queryLower) return true;
// Check p tags
if (event.tags.some(tag => tag[0] === 'p' && tag[1]?.toLowerCase() === queryLower)) return true;
// Check q tags
if (event.tags.some(tag => tag[0] === 'q' && tag.some((val, idx) => idx > 0 && val?.toLowerCase() === queryLower))) return true;
return false;
});
} else if (filterResult.type === 'text') {
// Filter by text search in pubkey, p tags, q tags, and content
return allEvents.filter((event: NostrEvent) => {
const pubkeyMatch = event.pubkey.toLowerCase().includes(queryLower);
const pTagMatch = event.tags.some(tag =>
tag[0] === 'p' && tag[1]?.toLowerCase().includes(queryLower)
);
const qTagMatch = event.tags.some(tag =>
tag[0] === 'q' && tag.some((val, idx) => idx > 0 && val?.toLowerCase().includes(queryLower))
);
const contentMatch = event.content.toLowerCase().includes(queryLower);
return pubkeyMatch || pTagMatch || qTagMatch || contentMatch;
});
}
return allEvents;
});
// 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() {
if (waitingRoomEvents.length === 0) return;
const eventMap = new Map(allEvents.map((e: NostrEvent) => [e.id, e]));
for (const event of waitingRoomEvents) {
eventMap.set(event.id, event);
}
allEvents = Array.from(eventMap.values()).sort((a: NostrEvent, b: NostrEvent) => b.created_at - a.created_at);
waitingRoomEvents = [];
}
// Load older events (pagination)
// svelte-ignore non_reactive_update
async function loadOlderEvents() {
if (loadingMore || !hasMoreEvents) return;
const untilTimestamp = oldestTimestamp ?? Math.floor(Date.now() / 1000);
if (!untilTimestamp) return;
loadingMore = true;
try {
const relays = singleRelay ? [singleRelay] : relayManager.getFeedReadRelays();
const feedKinds = getFeedKinds().filter(k => k !== KIND.DISCUSSION_THREAD);
const filters = feedKinds.map(k => ({
kinds: [k],
limit: config.feedLimit,
until: untilTimestamp - 1
}));
const fetched = await nostrClient.fetchEvents(
filters,
relays,
{
useCache: 'relay-first',
cacheResults: true,
timeout: config.standardTimeout
}
);
if (!isMounted) return;
const filtered = fetched.filter(e =>
e.kind !== KIND.DISCUSSION_THREAD &&
getKindInfo(e.kind).showInFeed === true
);
if (filtered.length === 0) {
hasMoreEvents = false;
return;
}
const eventMap = new Map(allEvents.map((e: NostrEvent) => [e.id, e]));
for (const event of filtered) {
eventMap.set(event.id, event);
}
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 {
loadingMore = false;
}
}
// Initial feed load
async function loadFeed() {
if (!isMounted || loadInProgress) return;
loadInProgress = true;
loading = true;
relayError = null;
try {
// Load from cache first (fast)
if (!singleRelay) {
const feedKinds = getFeedKinds().filter(k => k !== KIND.DISCUSSION_THREAD);
const cached = await getRecentFeedEvents(feedKinds, 15 * 60 * 1000, config.feedLimit);
const filtered = cached.filter(e =>
e.kind !== KIND.DISCUSSION_THREAD &&
getKindInfo(e.kind).showInFeed === true
);
if (filtered.length > 0 && isMounted) {
const unique = Array.from(new Map(filtered.map((e: NostrEvent) => [e.id, e])).values());
allEvents = unique.sort((a: NostrEvent, b: NostrEvent) => b.created_at - a.created_at);
oldestTimestamp = Math.min(...allEvents.map((e: NostrEvent) => e.created_at));
loading = false; // Show cached content immediately
}
}
// Fetch fresh data from relays (can be slow)
const relays = singleRelay ? [singleRelay] : relayManager.getFeedReadRelays();
if (singleRelay) {
const relay = await nostrClient.getRelay(singleRelay);
if (!relay) {
relayError = `Relay ${singleRelay} is unavailable.`;
loading = false;
return;
}
}
const feedKinds = getFeedKinds().filter(k => k !== KIND.DISCUSSION_THREAD);
const filters = feedKinds.map(k => ({ kinds: [k], limit: config.feedLimit }));
const fetched = await nostrClient.fetchEvents(
filters,
relays,
singleRelay ? {
useCache: false,
cacheResults: false,
timeout: config.singleRelayTimeout
} : {
useCache: 'relay-first',
cacheResults: true,
timeout: config.standardTimeout
}
);
if (!isMounted) return;
const filtered = fetched.filter(e =>
e.kind !== KIND.DISCUSSION_THREAD &&
getKindInfo(e.kind).showInFeed === true
);
const eventMap = new Map(allEvents.map((e: NostrEvent) => [e.id, e]));
for (const event of filtered) {
eventMap.set(event.id, event);
}
const sorted = Array.from(eventMap.values()).sort((a: NostrEvent, b: NostrEvent) => b.created_at - a.created_at);
allEvents = sorted;
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) {
relayError = 'Failed to load feed.';
}
} finally {
loading = false;
initialLoadComplete = true;
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() {
if (subscriptionId || singleRelay) return;
const relays = relayManager.getFeedReadRelays();
const feedKinds = getFeedKinds().filter(k => k !== KIND.DISCUSSION_THREAD);
const filters = feedKinds.map(k => ({ kinds: [k], limit: config.feedLimit }));
subscriptionId = nostrClient.subscribe(
filters,
relays,
(event: NostrEvent) => {
if (!isMounted || event.kind === KIND.DISCUSSION_THREAD || !initialLoadComplete) return;
// Add to waiting room if not already in feed or waiting room
const eventIds = new Set([...allEvents.map((e: NostrEvent) => e.id), ...waitingRoomEvents.map((e: NostrEvent) => e.id)]);
if (!eventIds.has(event.id)) {
waitingRoomEvents = [...waitingRoomEvents, event].sort((a, b) => b.created_at - a.created_at);
}
},
() => {}
);
}
onMount(() => {
isMounted = true;
loadInProgress = false;
// Use a small delay to ensure previous page cleanup completes
const initTimeout = setTimeout(() => {
if (!isMounted) return;
nostrClient.initialize().then(() => {
if (isMounted && !loadInProgress) {
loadFeed().then(() => {
if (isMounted) {
setupSubscription();
}
});
}
});
}, 50);
// Register j/k navigation shortcuts
if (browser) {
const unregisterJ = keyboardShortcuts.register('j', (e) => {
if (document.activeElement?.tagName === 'INPUT' || document.activeElement?.tagName === 'TEXTAREA' || document.activeElement?.isContentEditable) {
return; // Don't interfere with typing
}
e.preventDefault();
navigateToNextPost();
return false;
});
const unregisterK = keyboardShortcuts.register('k', (e) => {
if (document.activeElement?.tagName === 'INPUT' || document.activeElement?.tagName === 'TEXTAREA' || document.activeElement?.isContentEditable) {
return; // Don't interfere with typing
}
e.preventDefault();
navigateToPreviousPost();
return false;
});
return () => {
clearTimeout(initTimeout);
isMounted = false;
loadInProgress = false;
if (subscriptionId) {
nostrClient.unsubscribe(subscriptionId);
subscriptionId = null;
}
unregisterJ();
unregisterK();
};
}
return () => {
clearTimeout(initTimeout);
isMounted = false;
loadInProgress = false;
if (subscriptionId) {
nostrClient.unsubscribe(subscriptionId);
subscriptionId = null;
}
};
});
function navigateToNextPost() {
const posts = Array.from(document.querySelectorAll('[data-post-id]'));
const currentIndex = posts.findIndex(p => p === document.activeElement || p.contains(document.activeElement));
const nextIndex = currentIndex < posts.length - 1 ? currentIndex + 1 : 0;
if (posts[nextIndex]) {
(posts[nextIndex] as HTMLElement).focus();
posts[nextIndex].scrollIntoView({ behavior: 'smooth', block: 'nearest' });
}
}
function navigateToPreviousPost() {
const posts = Array.from(document.querySelectorAll('[data-post-id]'));
const currentIndex = posts.findIndex(p => p === document.activeElement || p.contains(document.activeElement));
const prevIndex = currentIndex > 0 ? currentIndex - 1 : posts.length - 1;
if (posts[prevIndex]) {
(posts[prevIndex] as HTMLElement).focus();
posts[prevIndex].scrollIntoView({ behavior: 'smooth', block: 'nearest' });
}
}
</script>
<div class="feed-page">
{#if singleRelay}
<div class="relay-info">
<p class="relay-info-text">Showing feed from: <code class="relay-url">{singleRelay}</code></p>
</div>
{/if}
{#if loading}
<div class="loading-state">
<p class="text-fog-text dark:text-fog-dark-text">Loading feed...</p>
</div>
{:else if relayError}
<div class="error-state">
<p class="text-fog-text dark:text-fog-dark-text error-message">{relayError}</p>
</div>
{:else if events.length === 0}
<div class="empty-state">
<p class="text-fog-text dark:text-fog-dark-text">No posts found.</p>
</div>
{:else}
<div class="feed-posts">
{#each events as event (event.id)}
{@const referencedEvent = getReferencedEventForPost(event)}
<FeedPost post={event} preloadedReferencedEvent={referencedEvent} />
{/each}
</div>
<div class="load-more-section">
<button
onclick={loadOlderEvents}
disabled={loadingMore || !hasMoreEvents}
class="see-more-events-btn"
>
{loadingMore ? 'Loading...' : 'See more events'}
</button>
</div>
{/if}
</div>
<style>
.feed-page {
max-width: 100%;
}
.loading-state,
.empty-state,
.error-state {
padding: 2rem;
text-align: center;
}
.error-message {
font-weight: 600;
color: var(--fog-accent, #64748b);
}
:global(.dark) .error-message {
color: var(--fog-dark-accent, #94a3b8);
}
.feed-posts {
display: flex;
flex-direction: column;
gap: 1rem;
}
.relay-info {
margin-bottom: 1.5rem;
padding: 1rem;
background: var(--fog-highlight, #f3f4f6);
border: 1px solid var(--fog-border, #e5e7eb);
border-radius: 0.375rem;
}
:global(.dark) .relay-info {
background: var(--fog-dark-highlight, #374151);
border-color: var(--fog-dark-border, #475569);
}
.relay-info-text {
margin: 0;
font-size: 0.875rem;
color: var(--fog-text, #1f2937);
}
:global(.dark) .relay-info-text {
color: var(--fog-dark-text, #f9fafb);
}
.relay-url {
font-family: monospace;
font-size: 0.875rem;
background: var(--fog-post, #ffffff);
padding: 0.25rem 0.5rem;
border-radius: 0.25rem;
border: 1px solid var(--fog-border, #e5e7eb);
}
:global(.dark) .relay-url {
background: var(--fog-dark-post, #1f2937);
border-color: var(--fog-dark-border, #374151);
}
.see-more-events-btn {
padding: 0.75rem 1.5rem;
background: var(--fog-accent, #64748b);
color: white;
border: none;
border-radius: 0.375rem;
font-size: 0.875rem;
font-weight: 500;
cursor: pointer;
transition: all 0.2s;
}
.see-more-events-btn:hover:not(:disabled) {
background: var(--fog-text, #475569);
}
.see-more-events-btn:disabled {
opacity: 0.5;
cursor: not-allowed;
}
:global(.dark) .see-more-events-btn {
background: var(--fog-dark-accent, #94a3b8);
color: var(--fog-dark-text, #f9fafb);
}
:global(.dark) .see-more-events-btn:hover:not(:disabled) {
background: var(--fog-dark-text, #cbd5e1);
}
.load-more-section {
padding: 2rem;
text-align: center;
}
</style>