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.
774 lines
28 KiB
774 lines
28 KiB
<script lang="ts"> |
|
import FeedPost from './FeedPost.svelte'; |
|
import ReplaceableEventCard from './ReplaceableEventCard.svelte'; |
|
import ThreadDrawer from './ThreadDrawer.svelte'; |
|
import { nostrClient } from '../../services/nostr/nostr-client.js'; |
|
import { relayManager } from '../../services/nostr/relay-manager.js'; |
|
import { sessionManager } from '../../services/auth/session-manager.js'; |
|
import { onMount } from 'svelte'; |
|
import type { NostrEvent } from '../../types/nostr.js'; |
|
import { getFeedKinds, getReplaceableKinds } from '../../types/kind-lookup.js'; |
|
|
|
let posts = $state<NostrEvent[]>([]); |
|
let replaceableEvents = $state<NostrEvent[]>([]); |
|
let loading = $state(true); |
|
let hasMore = $state(true); |
|
let loadingMore = $state(false); |
|
let newPostsCount = $state(0); |
|
let lastPostId = $state<string | null>(null); |
|
let showOPsOnly = $state(false); |
|
let showResponsesToMe = $state(false); |
|
let isLoadingFeed = false; // Guard to prevent concurrent loads |
|
let scrollTimeout: ReturnType<typeof setTimeout> | null = null; |
|
let pendingNewPosts = $state<NostrEvent[]>([]); // Store new posts until user clicks button |
|
let pendingNewReplaceable = $state<NostrEvent[]>([]); // Store new replaceable events |
|
let loadedParentQuotedEvents = $state<Map<string, NostrEvent>>(new Map()); // Store loaded parent/quoted events separately (doesn't trigger feed re-render) |
|
|
|
// Thread drawer state |
|
let drawerOpen = $state(false); |
|
let selectedEvent = $state<NostrEvent | null>(null); |
|
|
|
// Get current user pubkey |
|
let currentPubkey = $state<string | null>(sessionManager.getCurrentPubkey()); |
|
|
|
// Subscribe to session changes |
|
$effect(() => { |
|
const unsubscribe = sessionManager.session.subscribe(() => { |
|
currentPubkey = sessionManager.getCurrentPubkey(); |
|
}); |
|
return unsubscribe; |
|
}); |
|
|
|
onMount(() => { |
|
nostrClient.initialize().then(() => { |
|
loadFeed(); |
|
}); |
|
|
|
// Set up infinite scroll |
|
window.addEventListener('scroll', handleScroll); |
|
|
|
return () => { |
|
window.removeEventListener('scroll', handleScroll); |
|
if (scrollTimeout) { |
|
clearTimeout(scrollTimeout); |
|
scrollTimeout = null; |
|
} |
|
}; |
|
}); |
|
|
|
async function loadFeed(reset = true) { |
|
// Prevent concurrent loads |
|
if (isLoadingFeed) { |
|
return; |
|
} |
|
|
|
isLoadingFeed = true; |
|
|
|
if (reset) { |
|
loading = true; |
|
posts = []; |
|
hasMore = true; |
|
} else { |
|
loadingMore = true; |
|
} |
|
|
|
try { |
|
const oldestTimestamp = posts.length > 0 |
|
? Math.min(...posts.map(p => p.created_at)) |
|
: undefined; |
|
|
|
const relays = relayManager.getFeedReadRelays(); |
|
const feedKinds = getFeedKinds(); |
|
const replaceableKinds = getReplaceableKinds(); |
|
|
|
// Phase 1: Fetch all feed kinds - one request per relay, sent in parallel |
|
// Update cache in background (10 second timeout), view updates when cache is done |
|
const feedFilter = feedKinds.length > 0 ? [{ |
|
kinds: feedKinds, |
|
limit: 100, |
|
...(oldestTimestamp ? { until: oldestTimestamp } : {}) |
|
}] : []; |
|
|
|
let allFeedEvents: NostrEvent[] = []; |
|
|
|
if (feedFilter.length > 0) { |
|
// Debounce onUpdate to prevent rapid-fire updates |
|
let updateTimeout: ReturnType<typeof setTimeout> | null = null; |
|
let pendingUpdate: NostrEvent[] | null = null; |
|
|
|
// Capture reset value to avoid stale closure |
|
const isReset = reset; |
|
|
|
const debouncedOnUpdate = (updated: NostrEvent[]) => { |
|
pendingUpdate = updated; |
|
|
|
if (updateTimeout) { |
|
clearTimeout(updateTimeout); |
|
} |
|
|
|
updateTimeout = setTimeout(() => { |
|
if (!pendingUpdate) return; |
|
|
|
const updated = pendingUpdate; |
|
pendingUpdate = null; |
|
|
|
// Don't update the feed automatically - only store new posts for manual update |
|
// This prevents feed jumping and allows user to control when to refresh |
|
// Works the same whether drawer is open or closed - events go to pending arrays |
|
|
|
const updatedRegularPosts = updated.filter((e: NostrEvent) => e.kind === 1); |
|
const updatedReplaceable = updated.filter((e: NostrEvent) => |
|
replaceableKinds.includes(e.kind) && |
|
e.tags.some(t => t[0] === 'd') |
|
); |
|
const updatedOtherFeedEvents = updated.filter((e: NostrEvent) => |
|
e.kind !== 1 && |
|
!replaceableKinds.includes(e.kind) && |
|
feedKinds.includes(e.kind) |
|
); |
|
|
|
// NEVER update the feed automatically from onUpdate callback |
|
// This prevents feed jumping - user must click button to see updates |
|
// Only store new posts in pending arrays (works for both drawer open and closed) |
|
if (!isReset) { |
|
// Store new posts in pending arrays instead of updating feed automatically |
|
const existingIds = new Set([...posts, ...pendingNewPosts].map(p => p.id)); |
|
const allNewEvents = [...updatedRegularPosts, ...updatedOtherFeedEvents]; |
|
const newPosts = allNewEvents.filter(e => !existingIds.has(e.id)); |
|
|
|
if (newPosts.length > 0) { |
|
// Add to pending posts instead of directly to feed |
|
const existingPendingIds = new Set(pendingNewPosts.map(p => p.id)); |
|
const trulyNewPosts = newPosts.filter(e => !existingPendingIds.has(e.id)); |
|
if (trulyNewPosts.length > 0) { |
|
pendingNewPosts = [...pendingNewPosts, ...trulyNewPosts]; |
|
|
|
// Update counter for new posts (only if drawer is closed, to avoid confusion) |
|
if (!drawerOpen) { |
|
if (lastPostId) { |
|
const newCount = trulyNewPosts.filter(e => e.id !== lastPostId).length; |
|
if (newCount > 0) { |
|
newPostsCount += newCount; |
|
} |
|
} else { |
|
// If no lastPostId, count all new posts |
|
newPostsCount += trulyNewPosts.length; |
|
} |
|
} |
|
} |
|
} |
|
|
|
// Store new replaceable events in pending array |
|
const existingReplaceableIds = new Set([...replaceableEvents, ...pendingNewReplaceable].map(e => e.id)); |
|
const newReplaceable = updatedReplaceable.filter(e => !existingReplaceableIds.has(e.id)); |
|
if (newReplaceable.length > 0) { |
|
pendingNewReplaceable = [...pendingNewReplaceable, ...newReplaceable]; |
|
} |
|
} |
|
|
|
allFeedEvents = updated; |
|
}, 1000); // Debounce to 1 second to reduce update frequency |
|
}; |
|
|
|
// For initial load (reset), don't use onUpdate to prevent incremental updates |
|
// Wait for the full fetch to complete before displaying anything |
|
const fetchOptions = reset |
|
? { |
|
useCache: true, |
|
cacheResults: true, |
|
timeout: 10000 |
|
// No onUpdate during initial load to prevent scrolling |
|
} |
|
: { |
|
useCache: true, |
|
cacheResults: true, |
|
timeout: 10000, |
|
onUpdate: debouncedOnUpdate |
|
}; |
|
|
|
// Get cached events first for immediate display, then refresh in background |
|
// For initial load, wait for full fetch without onUpdate to prevent scrolling |
|
const cachedEvents = await nostrClient.fetchEvents( |
|
feedFilter, |
|
relays, |
|
fetchOptions |
|
); |
|
|
|
// Process cached events |
|
// Load ALL feed events into posts array (including replies and kind 1111) |
|
// Filtering happens client-side in getFilteredPosts() based on showOPsOnly checkbox |
|
const regularPosts = cachedEvents.filter((e: NostrEvent) => e.kind === 1); |
|
const replaceable = cachedEvents.filter((e: NostrEvent) => |
|
replaceableKinds.includes(e.kind) && |
|
e.tags.some(t => t[0] === 'd') |
|
); |
|
// Include all other feed kinds (including kind 1111 comments) |
|
const otherFeedEvents = cachedEvents.filter((e: NostrEvent) => |
|
e.kind !== 1 && |
|
!replaceableKinds.includes(e.kind) && |
|
feedKinds.includes(e.kind) |
|
); |
|
|
|
if (reset) { |
|
// For initial load, batch all updates at once to prevent scrolling |
|
// Load ALL events into posts array - filtering happens client-side |
|
// Only sort if we have posts to prevent unnecessary re-renders |
|
if (regularPosts.length > 0 || otherFeedEvents.length > 0) { |
|
posts = sortPosts([...regularPosts, ...otherFeedEvents]); |
|
} |
|
replaceableEvents = replaceable.sort((a, b) => b.created_at - a.created_at); |
|
lastPostId = regularPosts.length > 0 ? regularPosts[0].id : null; |
|
} else { |
|
// For infinite scroll (loading more), add new posts directly to feed |
|
// This is a user-initiated action, so update immediately |
|
// BUT: Don't update if drawer is open - freeze the feed |
|
if (!drawerOpen) { |
|
// Don't re-sort existing posts - just append new ones to prevent jumping |
|
const existingIds = new Set(posts.map(p => p.id)); |
|
const allNewEvents = [...regularPosts, ...otherFeedEvents]; |
|
const newPosts = allNewEvents.filter(e => !existingIds.has(e.id)); |
|
|
|
if (newPosts.length > 0) { |
|
// Sort only the new posts, then append to existing (preserve existing order) |
|
const sortedNewPosts = sortPosts(newPosts); |
|
posts = [...posts, ...sortedNewPosts]; |
|
} |
|
|
|
const existingReplaceableIds = new Set(replaceableEvents.map(e => e.id)); |
|
const newReplaceable = replaceable.filter(e => !existingReplaceableIds.has(e.id)); |
|
if (newReplaceable.length > 0) { |
|
// Append new replaceable events without re-sorting existing ones |
|
replaceableEvents = [...replaceableEvents, ...newReplaceable.sort((a, b) => b.created_at - a.created_at)]; |
|
} |
|
} |
|
} |
|
|
|
allFeedEvents = cachedEvents; |
|
} |
|
|
|
// For initial load, wait a moment to ensure all data is processed before showing feed |
|
// This prevents scrolling issues from incremental updates |
|
if (reset) { |
|
// Small delay to ensure DOM is ready and prevent scroll jumping |
|
await new Promise(resolve => setTimeout(resolve, 100)); |
|
} |
|
|
|
// Background refresh is handled automatically by fetchEvents with useCache: true |
|
// For initial load, we don't use onUpdate, so background refresh won't cause scrolling |
|
|
|
// Phase 2: Fetch secondary kinds (reactions, zaps) for displayed events |
|
// One request per relay with all filters, sent in parallel, update cache in background (10s timeout) |
|
// Only fetch if we're not in a loading state to prevent excessive requests |
|
// Don't fetch during initial load to prevent scrolling |
|
if (!isLoadingFeed && !loading && !loadingMore && !reset) { |
|
const displayedEventIds = [...posts, ...replaceableEvents].map(e => e.id); |
|
if (displayedEventIds.length > 0) { |
|
// Fetch reactions (kind 7) and zap receipts (kind 9735) for displayed events |
|
const secondaryFilter = [{ |
|
kinds: [7, 9735], |
|
'#e': displayedEventIds.slice(0, 100) // Limit to avoid huge requests |
|
}]; |
|
|
|
// Fetch in background, update cache, view will update automatically via cache |
|
// Don't use onUpdate here to prevent triggering UI updates that cause jumping |
|
nostrClient.fetchEvents( |
|
secondaryFilter, |
|
relays, |
|
{ |
|
useCache: true, |
|
cacheResults: true, |
|
timeout: 10000 |
|
} |
|
).catch(err => { |
|
console.error('Error fetching secondary events:', err); |
|
}); |
|
} |
|
|
|
// Phase 3: Fetch kind 0 profiles, statuses, and activity data for npubs in feed |
|
// Batch fetch all at once to prevent individual requests from ProfileBadge components |
|
// This runs on both initial load and background refresh |
|
const uniquePubkeys = new Set<string>(); |
|
for (const event of [...posts, ...replaceableEvents]) { |
|
uniquePubkeys.add(event.pubkey); |
|
} |
|
|
|
if (uniquePubkeys.size > 0) { |
|
const pubkeysArray = Array.from(uniquePubkeys).slice(0, 100); // Limit to avoid huge requests |
|
|
|
// Batch fetch profiles (kind 0) |
|
const profileFilter = [{ |
|
kinds: [0], |
|
authors: pubkeysArray |
|
}]; |
|
|
|
// Batch fetch user statuses (kind 30315) |
|
const statusFilter = [{ |
|
kinds: [30315], |
|
authors: pubkeysArray, |
|
'#d': ['general'], |
|
limit: 1 |
|
} as any]; |
|
|
|
// Batch fetch activity data (recent events from each pubkey) |
|
const activityFilter = [{ |
|
authors: pubkeysArray, |
|
kinds: [0, 1, 7, 11, 1111], |
|
limit: 1 |
|
}]; |
|
|
|
// Fetch all in parallel, update cache, view will update automatically via cache |
|
// Don't use onUpdate here to prevent triggering UI updates that cause jumping |
|
// Run in background to not block initial render |
|
Promise.all([ |
|
nostrClient.fetchEvents(profileFilter, relays, { |
|
useCache: true, |
|
cacheResults: true, |
|
timeout: 10000 |
|
}), |
|
nostrClient.fetchEvents(statusFilter, relays, { |
|
useCache: true, |
|
cacheResults: true, |
|
timeout: 10000 |
|
}), |
|
nostrClient.fetchEvents(activityFilter, relays, { |
|
useCache: true, |
|
cacheResults: true, |
|
timeout: 10000 |
|
}) |
|
]).catch(err => { |
|
console.error('Error fetching profiles/statuses/activity:', err); |
|
}); |
|
} |
|
|
|
// Fetch missing parent events (batch, one request per relay) |
|
const allEventIds = new Set(posts.map(e => e.id)); |
|
const missingParentIds = new Set<string>(); |
|
|
|
for (const event of posts) { |
|
const replyTag = event.tags.find((t) => t[0] === 'e' && t[3] === 'reply'); |
|
const parentId = replyTag?.[1]; |
|
if (parentId && !allEventIds.has(parentId)) { |
|
missingParentIds.add(parentId); |
|
} |
|
} |
|
|
|
if (missingParentIds.size > 0) { |
|
const parentIdsArray = Array.from(missingParentIds).slice(0, 50); |
|
// Don't use onUpdate here to prevent triggering UI updates that cause jumping |
|
nostrClient.fetchEvents( |
|
[{ kinds: [1], ids: parentIdsArray }], |
|
relays, |
|
{ useCache: true, cacheResults: true, timeout: 10000 } |
|
).catch(err => { |
|
console.error('Error fetching parent events:', err); |
|
}); |
|
} |
|
} |
|
|
|
hasMore = allFeedEvents.length >= 100; |
|
} catch (error) { |
|
console.error('Error loading feed:', error); |
|
} finally { |
|
loading = false; |
|
loadingMore = false; |
|
isLoadingFeed = false; |
|
} |
|
} |
|
|
|
function handleScroll() { |
|
// Throttle scroll handler to prevent doom loops |
|
if (scrollTimeout) { |
|
return; |
|
} |
|
|
|
scrollTimeout = setTimeout(() => { |
|
scrollTimeout = null; |
|
}, 500); // Increase throttle to 500ms to reduce frequency |
|
|
|
if (loadingMore || !hasMore || isLoadingFeed) return; |
|
|
|
const scrollTop = window.scrollY || document.documentElement.scrollTop; |
|
const windowHeight = window.innerHeight; |
|
const documentHeight = document.documentElement.scrollHeight; |
|
|
|
// Load more when within 500px of bottom (increased threshold to reduce triggers) |
|
if (scrollTop + windowHeight >= documentHeight - 500) { |
|
loadFeed(false); |
|
} |
|
} |
|
|
|
function sortPosts(events: NostrEvent[]): NostrEvent[] { |
|
// Build thread structure |
|
const eventMap = new Map<string, NostrEvent>(); |
|
const replyMap = new Map<string, string[]>(); // parentId -> childIds[] |
|
const rootEvents: NostrEvent[] = []; |
|
|
|
// First pass: build maps |
|
for (const event of events) { |
|
eventMap.set(event.id, event); |
|
|
|
// Check if this is a reply |
|
const replyTag = event.tags.find((t) => t[0] === 'e' && t[3] === 'reply'); |
|
const parentId = replyTag?.[1]; |
|
|
|
if (parentId && eventMap.has(parentId)) { |
|
// This is a reply to an event we have |
|
if (!replyMap.has(parentId)) { |
|
replyMap.set(parentId, []); |
|
} |
|
replyMap.get(parentId)!.push(event.id); |
|
} else { |
|
// This is a root event (not a reply, or parent not loaded) |
|
rootEvents.push(event); |
|
} |
|
} |
|
|
|
// Second pass: recursively collect all events in thread order |
|
const result: NostrEvent[] = []; |
|
const processed = new Set<string>(); |
|
|
|
function addThread(event: NostrEvent) { |
|
if (processed.has(event.id)) return; |
|
processed.add(event.id); |
|
|
|
result.push(event); |
|
|
|
// Add all replies to this event |
|
const replies = replyMap.get(event.id) || []; |
|
const replyEvents = replies |
|
.map(id => eventMap.get(id)) |
|
.filter((e): e is NostrEvent => e !== undefined) |
|
.sort((a, b) => a.created_at - b.created_at); // Sort replies chronologically |
|
|
|
for (const reply of replyEvents) { |
|
addThread(reply); |
|
} |
|
} |
|
|
|
// Add all root events sorted by newest first |
|
rootEvents.sort((a, b) => b.created_at - a.created_at); |
|
for (const root of rootEvents) { |
|
addThread(root); |
|
} |
|
|
|
return result; |
|
} |
|
|
|
function handleShowNewPosts() { |
|
// Don't update feed while drawer is open - freeze in place |
|
if (drawerOpen) return; |
|
|
|
// Merge pending new posts into the feed |
|
if (pendingNewPosts.length > 0) { |
|
// Sort the merged array to maintain proper order |
|
const merged = [...pendingNewPosts, ...posts]; |
|
posts = sortPosts(merged); |
|
pendingNewPosts = []; |
|
} |
|
|
|
if (pendingNewReplaceable.length > 0) { |
|
replaceableEvents = [...pendingNewReplaceable, ...replaceableEvents].sort((a, b) => b.created_at - a.created_at); |
|
pendingNewReplaceable = []; |
|
} |
|
|
|
// Scroll to top and reset new posts count |
|
window.scrollTo({ top: 0, behavior: 'smooth' }); |
|
newPostsCount = 0; |
|
lastPostId = posts.length > 0 ? posts[0].id : null; |
|
} |
|
|
|
function isReply(post: NostrEvent): boolean { |
|
// Check if this event references another event (reply, quote, or replaceable event reference) |
|
// Filter out anything with "e", "a", or "q" tags |
|
|
|
// Check for "e" tag (references another event - reply) |
|
if (post.tags.some((t) => t[0] === 'e' && t[1] !== undefined && t[1] !== post.id)) { |
|
return true; |
|
} |
|
|
|
// Check for "a" tag (references a replaceable event) |
|
if (post.tags.some((t) => t[0] === 'a' && t[1] !== undefined)) { |
|
return true; |
|
} |
|
|
|
// Check for "q" tag (quotes another event) |
|
if (post.tags.some((t) => t[0] === 'q' && t[1] !== undefined)) { |
|
return true; |
|
} |
|
|
|
return false; |
|
} |
|
|
|
/** |
|
* Filter posts client-side based on showOPsOnly and showResponsesToMe checkboxes. |
|
* This is purely reactive - no queries are triggered. |
|
* All events are already loaded into the posts array from cache. |
|
*/ |
|
function getFilteredPosts(): NostrEvent[] { |
|
let filtered = posts; |
|
|
|
// Filter for responses to me (events where current user is in "p" tag) |
|
if (showResponsesToMe && currentPubkey) { |
|
filtered = filtered.filter(post => { |
|
// Check if current user's pubkey is in any "p" tag |
|
return post.tags.some((t) => t[0] === 'p' && t[1] === currentPubkey); |
|
}); |
|
} |
|
|
|
// Filter for OPs only (original posts, no replies, quotes, or references) |
|
if (showOPsOnly) { |
|
filtered = filtered.filter(post => { |
|
// Filter out all kind 1111 events (comments) |
|
if (post.kind === 1111) return false; |
|
// Filter out any event that is a reply, quote, or reference |
|
if (isReply(post)) return false; |
|
// Keep everything else (original posts) |
|
return true; |
|
}); |
|
} |
|
|
|
return filtered; |
|
} |
|
|
|
function openThreadDrawer(event: NostrEvent, e?: MouseEvent) { |
|
// Don't open drawer if clicking on interactive elements (but allow the wrapper itself) |
|
if (e) { |
|
const target = e.target as HTMLElement; |
|
const wrapper = target.closest('.post-wrapper'); |
|
|
|
// If the target itself is a button, link, or has role="button" (but not the wrapper) |
|
if (target.tagName === 'BUTTON' || target.tagName === 'A' || |
|
(target.getAttribute('role') === 'button' && target !== wrapper)) { |
|
return; |
|
} |
|
|
|
// Check if clicking inside a button or link (but not the wrapper) |
|
const button = target.closest('button'); |
|
const link = target.closest('a'); |
|
if ((button && button !== wrapper) || (link && link !== wrapper)) { |
|
return; |
|
} |
|
} |
|
selectedEvent = event; |
|
drawerOpen = true; |
|
} |
|
|
|
function closeThreadDrawer() { |
|
drawerOpen = false; |
|
selectedEvent = null; |
|
|
|
// Events that arrived while drawer was open are already in pendingNewPosts/pendingNewReplaceable |
|
// Update the counter now that drawer is closed so user sees the notification |
|
if (pendingNewPosts.length > 0 && lastPostId) { |
|
const newCount = pendingNewPosts.filter(e => e.id !== lastPostId).length; |
|
if (newCount > 0) { |
|
newPostsCount = newCount; |
|
} |
|
} else if (pendingNewPosts.length > 0) { |
|
newPostsCount = pendingNewPosts.length; |
|
} |
|
} |
|
|
|
// Use $derived to make this reactive and prevent infinite loops |
|
let allFeedItems = $derived.by(() => { |
|
const items: Array<{ id: string; event: NostrEvent; type: 'post' | 'replaceable'; created_at: number }> = []; |
|
|
|
// Add filtered posts |
|
const filteredPosts = getFilteredPosts(); |
|
for (const post of filteredPosts) { |
|
items.push({ |
|
id: post.id, |
|
event: post, |
|
type: 'post', |
|
created_at: post.created_at |
|
}); |
|
} |
|
|
|
// Add replaceable events |
|
for (const event of replaceableEvents) { |
|
items.push({ |
|
id: event.id, |
|
event: event, |
|
type: 'replaceable', |
|
created_at: event.created_at |
|
}); |
|
} |
|
|
|
// Sort by created_at, newest first |
|
return items.sort((a, b) => b.created_at - a.created_at); |
|
}); |
|
</script> |
|
|
|
<div class="Feed-feed"> |
|
<div class="feed-header mb-4"> |
|
<h1 class="text-2xl font-bold mb-4">Feed</h1> |
|
<div class="feed-controls flex flex-wrap items-center gap-2 sm:gap-4"> |
|
<label class="flex items-center gap-2 cursor-pointer"> |
|
<input |
|
type="checkbox" |
|
bind:checked={showOPsOnly} |
|
class="checkbox" |
|
/> |
|
<span class="text-sm text-fog-text dark:text-fog-dark-text">Show OPs only</span> |
|
</label> |
|
{#if currentPubkey} |
|
<label class="flex items-center gap-2 cursor-pointer"> |
|
<input |
|
type="checkbox" |
|
bind:checked={showResponsesToMe} |
|
class="checkbox" |
|
/> |
|
<span class="text-sm text-fog-text dark:text-fog-dark-text">Show responses to me</span> |
|
</label> |
|
{/if} |
|
</div> |
|
</div> |
|
|
|
{#if loading} |
|
<p class="text-fog-text-light dark:text-fog-dark-text-light">Loading feed...</p> |
|
{:else if posts.length === 0 && replaceableEvents.length === 0} |
|
<p class="text-fog-text-light dark:text-fog-dark-text-light">No posts yet. Be the first to post!</p> |
|
{:else} |
|
{#if newPostsCount > 0} |
|
<div class="new-posts-indicator mb-4"> |
|
<button |
|
onclick={handleShowNewPosts} |
|
class="px-4 py-2 bg-fog-accent dark:bg-fog-dark-accent text-white rounded hover:opacity-90 text-sm" |
|
> |
|
{newPostsCount} new {newPostsCount === 1 ? 'post' : 'posts'} - Click to view |
|
</button> |
|
</div> |
|
{/if} |
|
<div class="posts-list"> |
|
{#each allFeedItems as item (item.id)} |
|
{#if item.type === 'post'} |
|
{@const parentId = item.event.tags.find((t) => t[0] === 'e' && t[3] === 'reply')?.[1]} |
|
{@const parentEvent = parentId ? (posts.find(p => p.id === parentId) || loadedParentQuotedEvents.get(parentId)) : undefined} |
|
{@const quotedId = item.event.tags.find((t) => t[0] === 'q')?.[1]} |
|
{@const quotedEvent = quotedId ? (posts.find(p => p.id === quotedId) || loadedParentQuotedEvents.get(quotedId)) : undefined} |
|
<div |
|
data-post-id={item.event.id} |
|
class="post-wrapper" |
|
class:keyboard-selected={false} |
|
onclick={(e) => openThreadDrawer(item.event, e)} |
|
role="button" |
|
tabindex="0" |
|
onkeydown={(e) => { |
|
if (e.key === 'Enter' || e.key === ' ') { |
|
e.preventDefault(); |
|
openThreadDrawer(item.event); |
|
} |
|
}} |
|
> |
|
<FeedPost |
|
post={item.event} |
|
parentEvent={parentEvent} |
|
quotedEvent={quotedEvent} |
|
onParentLoaded={(event) => { |
|
// Store loaded parent/quoted events in separate map to prevent feed re-rendering |
|
// NEVER add to main posts array - this causes feed jumping |
|
if (!loadedParentQuotedEvents.has(event.id)) { |
|
loadedParentQuotedEvents.set(event.id, event); |
|
} |
|
}} |
|
onQuotedLoaded={(event) => { |
|
// Store loaded parent/quoted events in separate map to prevent feed re-rendering |
|
// NEVER add to main posts array - this causes feed jumping |
|
if (!loadedParentQuotedEvents.has(event.id)) { |
|
loadedParentQuotedEvents.set(event.id, event); |
|
} |
|
}} |
|
/> |
|
</div> |
|
{:else if item.type === 'replaceable'} |
|
<div data-event-id={item.event.id} class="post-wrapper" class:keyboard-selected={false}> |
|
<ReplaceableEventCard event={item.event} /> |
|
</div> |
|
{/if} |
|
{/each} |
|
</div> |
|
{#if loadingMore} |
|
<p class="text-center text-fog-text-light dark:text-fog-dark-text-light mt-4">Loading more...</p> |
|
{/if} |
|
{#if !hasMore && allFeedItems.length > 0} |
|
<p class="text-center text-fog-text-light dark:text-fog-dark-text-light mt-4">No more posts</p> |
|
{/if} |
|
{#if (showOPsOnly || showResponsesToMe) && getFilteredPosts().length === 0 && posts.length > 0 && replaceableEvents.length === 0} |
|
<p class="text-center text-fog-text-light dark:text-fog-dark-text-light mt-4"> |
|
{#if showResponsesToMe && showOPsOnly} |
|
No original posts responding to you found. Try unchecking the filters. |
|
{:else if showResponsesToMe} |
|
No responses to you found. Try unchecking "Show responses to me". |
|
{:else} |
|
No original posts found. Try unchecking "Show OPs only". |
|
{/if} |
|
</p> |
|
{/if} |
|
{/if} |
|
</div> |
|
|
|
<ThreadDrawer |
|
opEvent={selectedEvent} |
|
isOpen={drawerOpen} |
|
onClose={closeThreadDrawer} |
|
/> |
|
|
|
<style> |
|
.Feed-feed { |
|
max-width: var(--content-width); |
|
margin: 0 auto; |
|
padding: 1rem; |
|
} |
|
|
|
.feed-header { |
|
display: flex; |
|
justify-content: space-between; |
|
align-items: center; |
|
flex-wrap: wrap; |
|
gap: 1rem; |
|
} |
|
|
|
.feed-controls { |
|
display: flex; |
|
flex-wrap: wrap; |
|
align-items: center; |
|
gap: 0.75rem 1rem; /* row-gap column-gap for better wrapping */ |
|
} |
|
|
|
@media (max-width: 768px) { |
|
.feed-controls { |
|
gap: 0.5rem 0.75rem; /* Smaller gaps on mobile */ |
|
} |
|
} |
|
|
|
.checkbox { |
|
width: 1rem; |
|
height: 1rem; |
|
cursor: pointer; |
|
} |
|
|
|
.post-wrapper { |
|
transition: background 0.2s; |
|
cursor: pointer; |
|
} |
|
|
|
.post-wrapper:hover { |
|
background: var(--fog-highlight, #f3f4f6); |
|
border-radius: 0.25rem; |
|
} |
|
|
|
:global(.dark) .post-wrapper:hover { |
|
background: var(--fog-dark-highlight, #374151); |
|
} |
|
|
|
.post-wrapper.keyboard-selected { |
|
background: var(--fog-highlight, #f3f4f6); |
|
border-radius: 0.25rem; |
|
padding: 0.25rem; |
|
margin: -0.25rem; |
|
} |
|
|
|
:global(.dark) .post-wrapper.keyboard-selected { |
|
background: var(--fog-dark-highlight, #374151); |
|
} |
|
</style>
|
|
|