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.
861 lines
28 KiB
861 lines
28 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, getCachedReactionsForEvents } from '../../services/cache/event-cache.js'; |
|
import { keyboardShortcuts } from '../../services/keyboard-shortcuts.js'; |
|
import { browser } from '$app/environment'; |
|
import { page } from '$app/stores'; |
|
import Pagination from '../../components/ui/Pagination.svelte'; |
|
import { getPaginatedItems, getCurrentPage, ITEMS_PER_PAGE } from '../../utils/pagination.js'; |
|
import { isReply } from '../../utils/event-utils.js'; |
|
import { filterEvents } from '../../services/event-filter.js'; |
|
|
|
interface Props { |
|
singleRelay?: string; |
|
showOnlyOPs?: boolean; |
|
} |
|
|
|
let { singleRelay, showOnlyOPs = false }: 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 }; |
|
|
|
// Refresh function for parent component |
|
async function refresh() { |
|
if (!isMounted) return; |
|
allEvents = []; |
|
oldestTimestamp = null; |
|
waitingRoomEvents = []; |
|
await loadFeed(); |
|
} |
|
export { refresh }; |
|
|
|
// 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()); |
|
|
|
// Preloaded reactions for kind 11 events - eventId -> reactions[] |
|
let preloadedReactionsMap = $state<Map<string, NostrEvent[]>>(new Map()); |
|
|
|
// Virtual scrolling for performance |
|
let Virtualizer: any = $state(null); |
|
let virtualizerLoading = $state(false); |
|
let virtualizerContainer = $state<HTMLElement | null>(null); |
|
|
|
async function loadVirtualizer() { |
|
if (Virtualizer) return Virtualizer; |
|
if (virtualizerLoading) return null; |
|
|
|
virtualizerLoading = true; |
|
try { |
|
const module = await import('@tanstack/svelte-virtual'); |
|
// Ensure we're getting the component, not a class constructor |
|
if (module && module.Virtualizer && typeof module.Virtualizer === 'function') { |
|
Virtualizer = module.Virtualizer; |
|
return Virtualizer; |
|
} |
|
return null; |
|
} catch (error) { |
|
console.warn('Virtual scrolling initialization failed:', error); |
|
return null; |
|
} finally { |
|
virtualizerLoading = false; |
|
} |
|
} |
|
|
|
// Filter events based on showOnlyOPs and mute list |
|
let events = $derived( |
|
filterEvents( |
|
showOnlyOPs |
|
? allEvents.filter(event => !isReply(event)) |
|
: allEvents |
|
) |
|
); |
|
|
|
// Pagination |
|
let currentPage = $derived(getCurrentPage($page.url.searchParams)); |
|
let paginatedEvents = $derived( |
|
events.length > ITEMS_PER_PAGE |
|
? getPaginatedItems(events, currentPage, ITEMS_PER_PAGE) |
|
: events |
|
); |
|
|
|
// 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; |
|
|
|
// Filter by kind and mute list |
|
const filtered = filterEvents( |
|
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) { |
|
// Failed to load older events |
|
} 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 - instant display) |
|
if (!singleRelay) { |
|
const feedKinds = getFeedKinds().filter(k => k !== KIND.DISCUSSION_THREAD); |
|
const cached = await getRecentFeedEvents(feedKinds, 60 * 60 * 1000, config.feedLimit); // 1 hour cache |
|
// Filter by kind and mute list |
|
const filtered = filterEvents( |
|
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 })); |
|
|
|
// Stream events as they arrive from relays (progressive enhancement) |
|
// Don't wait for all relays - update UI as each relay responds |
|
const fetched = await nostrClient.fetchEvents( |
|
filters, |
|
relays, |
|
singleRelay ? { |
|
useCache: false, |
|
cacheResults: false, |
|
timeout: config.singleRelayTimeout |
|
} : { |
|
useCache: 'cache-first', // Already shown cache above |
|
cacheResults: true, |
|
timeout: config.standardTimeout, |
|
// Stream events as they arrive from each relay |
|
onUpdate: (newEvents) => { |
|
if (!isMounted) return; |
|
|
|
// Filter by kind and mute list |
|
const filtered = filterEvents( |
|
newEvents.filter(e => |
|
e.kind !== KIND.DISCUSSION_THREAD && |
|
getKindInfo(e.kind).showInFeed === true |
|
) |
|
); |
|
|
|
if (filtered.length === 0) return; |
|
|
|
// Merge with existing events (deduplicate by ID) |
|
const eventMap = new Map(allEvents.map((e: NostrEvent) => [e.id, e])); |
|
for (const event of filtered) { |
|
eventMap.set(event.id, event); |
|
} |
|
|
|
// Update UI immediately with new events |
|
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)); |
|
} |
|
} |
|
} |
|
); |
|
|
|
if (!isMounted) return; |
|
|
|
// Final merge of any remaining events (for single relay mode or fallback) |
|
// Filter by kind and mute list |
|
const filtered = filterEvents( |
|
fetched.filter(e => |
|
e.kind !== KIND.DISCUSSION_THREAD && |
|
getKindInfo(e.kind).showInFeed === true |
|
) |
|
); |
|
|
|
if (filtered.length > 0) { |
|
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 |
|
if (allEvents.length > 0) { |
|
await batchFetchReferencedEvents(allEvents); |
|
// Batch load reactions for kind 11 events |
|
await batchLoadReactionsForKind11(allEvents); |
|
} |
|
} catch (error) { |
|
// Failed to load feed |
|
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) { |
|
// Failed to fetch referenced events (non-critical) |
|
// Don't block on errors - components will fetch individually if needed |
|
} |
|
} |
|
|
|
// Batch load reactions for kind 11 events (optimized cache-first loading) |
|
async function batchLoadReactionsForKind11(events: NostrEvent[]) { |
|
if (!isMounted || events.length === 0) return; |
|
|
|
// Filter to only kind 11 events |
|
const kind11Events = events.filter(e => e.kind === KIND.DISCUSSION_THREAD); |
|
if (kind11Events.length === 0) return; |
|
|
|
const eventIds = kind11Events.map(e => e.id); |
|
const reactionRelays = relayManager.getProfileReadRelays(); |
|
const allReactionsMap = new Map<string, NostrEvent>(); |
|
|
|
try { |
|
// Step 1: Load from cache first (instant) |
|
const cachedReactionsMap = await getCachedReactionsForEvents(eventIds); |
|
|
|
// Add cached reactions immediately |
|
for (const [eventId, reactions] of cachedReactionsMap) { |
|
for (const reaction of reactions) { |
|
allReactionsMap.set(reaction.id, reaction); |
|
} |
|
} |
|
|
|
// Process cached reactions and update preloaded map |
|
const reactionsByEvent = new Map<string, NostrEvent[]>(); |
|
for (const [eventId, reactions] of cachedReactionsMap) { |
|
reactionsByEvent.set(eventId, reactions); |
|
} |
|
|
|
// Update preloaded reactions map with cached data |
|
if (reactionsByEvent.size > 0 && isMounted) { |
|
const merged = new Map(preloadedReactionsMap); |
|
for (const [eventId, reactions] of reactionsByEvent.entries()) { |
|
merged.set(eventId, reactions); |
|
} |
|
preloadedReactionsMap = merged; |
|
} |
|
|
|
// Step 2: Fetch from relays in parallel (fast, non-blocking) |
|
const reactionsFetchPromise = nostrClient.fetchEvents( |
|
[ |
|
{ kinds: [KIND.REACTION], '#e': eventIds, limit: config.feedLimit }, |
|
{ kinds: [KIND.REACTION], '#E': eventIds, limit: config.feedLimit } |
|
], |
|
reactionRelays, |
|
{ |
|
useCache: 'cache-first', |
|
cacheResults: true, |
|
timeout: config.shortTimeout, |
|
priority: 'low' |
|
} |
|
); |
|
|
|
// Don't await - let it update in background |
|
reactionsFetchPromise.then((allReactions) => { |
|
if (!isMounted) return; |
|
|
|
// Group reactions by event ID |
|
const updatedReactionsByEvent = new Map<string, NostrEvent[]>(); |
|
|
|
for (const reaction of allReactions) { |
|
// Find the event ID this reaction references |
|
const eventIdTag = reaction.tags.find(t => { |
|
const tagName = t[0]; |
|
return (tagName === 'e' || tagName === 'E') && t[1] && eventIds.includes(t[1]); |
|
}); |
|
|
|
if (eventIdTag && eventIdTag[1]) { |
|
const eventId = eventIdTag[1]; |
|
if (!updatedReactionsByEvent.has(eventId)) { |
|
updatedReactionsByEvent.set(eventId, []); |
|
} |
|
updatedReactionsByEvent.get(eventId)!.push(reaction); |
|
} |
|
} |
|
|
|
// Update preloaded reactions map |
|
if (updatedReactionsByEvent.size > 0 && isMounted) { |
|
const merged = new Map(preloadedReactionsMap); |
|
for (const [eventId, reactions] of updatedReactionsByEvent.entries()) { |
|
merged.set(eventId, reactions); |
|
} |
|
preloadedReactionsMap = merged; |
|
} |
|
}).catch(() => { |
|
// Silently fail - reactions are non-critical |
|
}); |
|
} catch (error) { |
|
// Failed to load reactions (non-critical) |
|
} |
|
} |
|
|
|
// 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; |
|
|
|
// Load virtualizer for better performance with large feeds |
|
loadVirtualizer(); |
|
|
|
// 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) => { |
|
const activeElement = document.activeElement as HTMLElement | null; |
|
if (activeElement?.tagName === 'INPUT' || activeElement?.tagName === 'TEXTAREA' || activeElement?.isContentEditable) { |
|
return; // Don't interfere with typing |
|
} |
|
e.preventDefault(); |
|
navigateToNextPost(); |
|
return false; |
|
}); |
|
|
|
const unregisterK = keyboardShortcuts.register('k', (e) => { |
|
const activeElement = document.activeElement as HTMLElement | null; |
|
if (activeElement?.tagName === 'INPUT' || activeElement?.tagName === 'TEXTAREA' || 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} |
|
{#if Virtualizer && typeof Virtualizer === 'function' && events.length > 50} |
|
<!-- Use virtual scrolling for large feeds (50+ items) --> |
|
<!-- Note: Virtualizer is disabled due to compatibility issues --> |
|
<!-- Fallback to regular rendering --> |
|
<div class="feed-posts"> |
|
{#each paginatedEvents as event (event.id)} |
|
{@const referencedEvent = getReferencedEventForPost(event)} |
|
{@const preloadedReactions = preloadedReactionsMap.get(event.id) ?? []} |
|
<FeedPost post={event} preloadedReferencedEvent={referencedEvent} preloadedReactions={preloadedReactions} /> |
|
{/each} |
|
</div> |
|
{:else} |
|
<!-- Fallback to regular rendering for small feeds or when virtualizer not loaded --> |
|
<div class="feed-posts"> |
|
{#each paginatedEvents as event (event.id)} |
|
{@const referencedEvent = getReferencedEventForPost(event)} |
|
{@const preloadedReactions = preloadedReactionsMap.get(event.id) ?? []} |
|
<FeedPost post={event} preloadedReferencedEvent={referencedEvent} preloadedReactions={preloadedReactions} /> |
|
{/each} |
|
</div> |
|
{/if} |
|
|
|
{#if events.length > ITEMS_PER_PAGE} |
|
<Pagination totalItems={events.length} itemsPerPage={ITEMS_PER_PAGE} /> |
|
{:else} |
|
<div class="load-more-section"> |
|
<button |
|
onclick={loadOlderEvents} |
|
disabled={loadingMore || !hasMoreEvents} |
|
class="see-more-events-btn" |
|
> |
|
{loadingMore ? 'Loading...' : 'See more events'} |
|
</button> |
|
</div> |
|
{/if} |
|
|
|
{/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>
|
|
|