23 changed files with 743 additions and 1598 deletions
@ -1,707 +1,209 @@
@@ -1,707 +1,209 @@
|
||||
<script lang="ts"> |
||||
import FeedPost from './FeedPost.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 FeedPost from './FeedPost.svelte'; |
||||
import type { NostrEvent } from '../../types/nostr.js'; |
||||
import { getFeedKinds } from '../../types/kind-lookup.js'; |
||||
import { onMount } from 'svelte'; |
||||
|
||||
let posts = $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 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; |
||||
}); |
||||
let hasMore = $state(true); |
||||
let oldestTimestamp = $state<number | null>(null); |
||||
|
||||
onMount(() => { |
||||
nostrClient.initialize().then(() => { |
||||
loadFeed(); |
||||
}); |
||||
// Debounce updates to prevent rapid re-renders |
||||
let updateTimeout: ReturnType<typeof setTimeout> | null = null; |
||||
let pendingUpdates: NostrEvent[] = []; |
||||
|
||||
// Set up infinite scroll |
||||
window.addEventListener('scroll', handleScroll); |
||||
onMount(() => { |
||||
(async () => { |
||||
await nostrClient.initialize(); |
||||
await loadFeed(); |
||||
})(); |
||||
|
||||
return () => { |
||||
window.removeEventListener('scroll', handleScroll); |
||||
if (scrollTimeout) { |
||||
clearTimeout(scrollTimeout); |
||||
scrollTimeout = null; |
||||
// Set up intersection observer for infinite scroll |
||||
const observer = new IntersectionObserver((entries) => { |
||||
if (entries[0].isIntersecting && hasMore && !loadingMore) { |
||||
loadMore(); |
||||
} |
||||
}; |
||||
}); |
||||
}, { threshold: 0.1 }); |
||||
|
||||
async function loadFeed(reset = true) { |
||||
// Prevent concurrent loads |
||||
if (isLoadingFeed) { |
||||
return; |
||||
const sentinel = document.getElementById('feed-sentinel'); |
||||
if (sentinel) { |
||||
observer.observe(sentinel); |
||||
} |
||||
|
||||
isLoadingFeed = true; |
||||
|
||||
if (reset) { |
||||
loading = true; |
||||
posts = []; |
||||
hasMore = true; |
||||
} else { |
||||
loadingMore = true; |
||||
return () => { |
||||
if (sentinel) { |
||||
observer.unobserve(sentinel); |
||||
} |
||||
|
||||
try { |
||||
const oldestTimestamp = posts.length > 0 |
||||
? Math.min(...posts.map(p => p.created_at)) |
||||
: undefined; |
||||
|
||||
const relays = relayManager.getFeedReadRelays(); |
||||
const feedKinds = getFeedKinds(); |
||||
|
||||
// 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 |
||||
|
||||
// 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 newPosts = updated.filter(e => !existingIds.has(e.id) && feedKinds.includes(e.kind)); |
||||
|
||||
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; |
||||
} |
||||
} |
||||
} |
||||
} |
||||
} |
||||
}, 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 |
||||
? { |
||||
async function loadFeed() { |
||||
loading = true; |
||||
try { |
||||
const config = nostrClient.getConfig(); |
||||
const relays = relayManager.getFeedReadRelays(); |
||||
|
||||
// Load initial feed - cache first, then background refresh |
||||
const filters = [{ kinds: [1], limit: 20 }]; |
||||
const events = await nostrClient.fetchEvents( |
||||
filters, |
||||
relays, |
||||
{ |
||||
useCache: true, |
||||
cacheResults: true, |
||||
onUpdate: handleUpdate, |
||||
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 allFeedEvents = cachedEvents.filter((e: NostrEvent) => 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 (allFeedEvents.length > 0) { |
||||
posts = sortPosts(allFeedEvents); |
||||
lastPostId = posts[0].id; |
||||
} |
||||
} 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 newPosts = allFeedEvents.filter(e => !existingIds.has(e.id)); |
||||
// Sort by created_at descending |
||||
const sorted = events.sort((a, b) => b.created_at - a.created_at); |
||||
posts = sorted; |
||||
|
||||
if (newPosts.length > 0) { |
||||
// Sort only the new posts, then append to existing (preserve existing order) |
||||
const sortedNewPosts = sortPosts(newPosts); |
||||
posts = [...posts, ...sortedNewPosts]; |
||||
if (sorted.length > 0) { |
||||
oldestTimestamp = Math.min(...sorted.map(e => e.created_at)); |
||||
} |
||||
|
||||
hasMore = events.length >= 20; |
||||
} catch (error) { |
||||
console.error('Error loading feed:', error); |
||||
} finally { |
||||
loading = false; |
||||
} |
||||
} |
||||
|
||||
} |
||||
async function loadMore() { |
||||
if (loadingMore || !hasMore) return; |
||||
|
||||
// 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)); |
||||
} |
||||
loadingMore = true; |
||||
try { |
||||
const config = nostrClient.getConfig(); |
||||
const relays = relayManager.getFeedReadRelays(); |
||||
|
||||
// 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.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 |
||||
const filters = [{ |
||||
kinds: [1], |
||||
limit: 20, |
||||
until: oldestTimestamp || undefined |
||||
}]; |
||||
|
||||
// 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, |
||||
const events = await nostrClient.fetchEvents( |
||||
filters, |
||||
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) { |
||||
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); |
||||
}); |
||||
if (events.length === 0) { |
||||
hasMore = false; |
||||
return; |
||||
} |
||||
|
||||
// Fetch missing parent events (batch, one request per relay) |
||||
const allEventIds = new Set(posts.map(e => e.id)); |
||||
const missingParentIds = new Set<string>(); |
||||
// Filter out duplicates |
||||
const existingIds = new Set(posts.map(p => p.id)); |
||||
const newEvents = events.filter(e => !existingIds.has(e.id)); |
||||
|
||||
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 (newEvents.length > 0) { |
||||
const sorted = newEvents.sort((a, b) => b.created_at - a.created_at); |
||||
posts = [...posts, ...sorted]; |
||||
|
||||
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); |
||||
}); |
||||
const oldest = Math.min(...newEvents.map(e => e.created_at)); |
||||
if (oldest < (oldestTimestamp || Infinity)) { |
||||
oldestTimestamp = oldest; |
||||
} |
||||
} |
||||
|
||||
hasMore = allFeedEvents.length >= 100; |
||||
hasMore = events.length >= 20; |
||||
} catch (error) { |
||||
console.error('Error loading feed:', error); |
||||
console.error('Error loading more:', 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; |
||||
// Debounced update handler to prevent rapid re-renders |
||||
function handleUpdate(updated: NostrEvent[]) { |
||||
pendingUpdates.push(...updated); |
||||
|
||||
// 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 = []; |
||||
} |
||||
|
||||
// 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; |
||||
if (updateTimeout) { |
||||
clearTimeout(updateTimeout); |
||||
} |
||||
|
||||
// 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; |
||||
} |
||||
// Batch updates every 500ms |
||||
updateTimeout = setTimeout(() => { |
||||
if (pendingUpdates.length === 0) return; |
||||
|
||||
function closeThreadDrawer() { |
||||
drawerOpen = false; |
||||
selectedEvent = null; |
||||
const existingIds = new Set(posts.map(p => p.id)); |
||||
const newEvents = pendingUpdates.filter(e => !existingIds.has(e.id)); |
||||
|
||||
// Events that arrived while drawer was open are already in pendingNewPosts |
||||
// 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; |
||||
} |
||||
if (newEvents.length > 0) { |
||||
// Merge and sort |
||||
const merged = [...posts, ...newEvents]; |
||||
const sorted = merged.sort((a, b) => b.created_at - a.created_at); |
||||
posts = sorted; |
||||
} |
||||
|
||||
// Use $derived to make this reactive and prevent infinite loops |
||||
let allFeedItems = $derived.by(() => { |
||||
const items: Array<{ id: string; event: NostrEvent; created_at: number }> = []; |
||||
|
||||
// Add filtered posts (all events are in posts array now) |
||||
const filteredPosts = getFilteredPosts(); |
||||
for (const post of filteredPosts) { |
||||
items.push({ |
||||
id: post.id, |
||||
event: post, |
||||
created_at: post.created_at |
||||
}); |
||||
pendingUpdates = []; |
||||
}, 500); |
||||
} |
||||
|
||||
// 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> |
||||
|
||||
<div class="feed-page"> |
||||
{#if loading} |
||||
<p class="text-fog-text-light dark:text-fog-dark-text-light">Loading feed...</p> |
||||
{:else if posts.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 class="loading-state"> |
||||
<p class="text-fog-text dark:text-fog-dark-text">Loading feed...</p> |
||||
</div> |
||||
{/if} |
||||
<div class="posts-list"> |
||||
{#each allFeedItems as item (item.id)} |
||||
{@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); |
||||
} |
||||
}} |
||||
/> |
||||
{:else if posts.length === 0} |
||||
<div class="empty-state"> |
||||
<p class="text-fog-text dark:text-fog-dark-text">No posts found. Check back later!</p> |
||||
</div> |
||||
{:else} |
||||
<div class="feed-posts"> |
||||
{#each posts as post (post.id)} |
||||
<FeedPost post={post} /> |
||||
{/each} |
||||
</div> |
||||
|
||||
<div id="feed-sentinel" class="feed-sentinel"> |
||||
{#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} |
||||
<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". |
||||
<p class="text-fog-text-light dark:text-fog-dark-text-light">Loading more...</p> |
||||
{:else if hasMore} |
||||
<p class="text-fog-text-light dark:text-fog-dark-text-light">Scroll for more</p> |
||||
{:else} |
||||
No original posts found. Try unchecking "Show OPs only". |
||||
{/if} |
||||
</p> |
||||
<p class="text-fog-text-light dark:text-fog-dark-text-light">No more posts</p> |
||||
{/if} |
||||
</div> |
||||
{/if} |
||||
</div> |
||||
|
||||
<ThreadDrawer |
||||
opEvent={selectedEvent} |
||||
isOpen={drawerOpen} |
||||
onClose={closeThreadDrawer} |
||||
/> |
||||
|
||||
<style> |
||||
.Feed-feed { |
||||
max-width: var(--content-width); |
||||
margin: 0 auto; |
||||
padding: 1rem; |
||||
.feed-page { |
||||
max-width: 100%; |
||||
} |
||||
|
||||
.feed-header { |
||||
display: flex; |
||||
justify-content: space-between; |
||||
align-items: center; |
||||
flex-wrap: wrap; |
||||
gap: 1rem; |
||||
.loading-state, |
||||
.empty-state { |
||||
padding: 2rem; |
||||
text-align: center; |
||||
} |
||||
|
||||
.feed-controls { |
||||
.feed-posts { |
||||
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; |
||||
flex-direction: column; |
||||
gap: 1rem; |
||||
} |
||||
|
||||
:global(.dark) .post-wrapper.keyboard-selected { |
||||
background: var(--fog-dark-highlight, #374151); |
||||
.feed-sentinel { |
||||
padding: 2rem; |
||||
text-align: center; |
||||
min-height: 100px; |
||||
} |
||||
</style> |
||||
|
||||
@ -1,158 +0,0 @@
@@ -1,158 +0,0 @@
|
||||
<script lang="ts"> |
||||
import ProfileBadge from '../../components/layout/ProfileBadge.svelte'; |
||||
import type { NostrEvent } from '../../types/nostr.js'; |
||||
import { getKindInfo } from '../../types/kind-lookup.js'; |
||||
|
||||
interface Props { |
||||
event: NostrEvent; |
||||
} |
||||
|
||||
let { event }: Props = $props(); |
||||
|
||||
function getDTag(): string | null { |
||||
const dTag = event.tags.find((t) => t[0] === 'd'); |
||||
return dTag?.[1] || null; |
||||
} |
||||
|
||||
function getWikistrUrl(): string | null { |
||||
const dTag = getDTag(); |
||||
if (!dTag) return null; |
||||
return `https://wikistr.imwald.eu/${dTag}*${event.pubkey}`; |
||||
} |
||||
|
||||
function getRelativeTime(): string { |
||||
const now = Math.floor(Date.now() / 1000); |
||||
const diff = now - event.created_at; |
||||
const hours = Math.floor(diff / 3600); |
||||
const days = Math.floor(diff / 86400); |
||||
const minutes = Math.floor(diff / 60); |
||||
|
||||
if (days > 0) return `${days}d ago`; |
||||
if (hours > 0) return `${hours}h ago`; |
||||
if (minutes > 0) return `${minutes}m ago`; |
||||
return 'just now'; |
||||
} |
||||
|
||||
function getClientName(): string | null { |
||||
const clientTag = event.tags.find((t) => t[0] === 'client'); |
||||
return clientTag?.[1] || null; |
||||
} |
||||
|
||||
</script> |
||||
|
||||
<article class="replaceable-event-card"> |
||||
<div class="card-header flex items-center justify-between mb-2"> |
||||
<div class="flex items-center gap-2"> |
||||
<ProfileBadge pubkey={event.pubkey} /> |
||||
<span class="text-xs text-fog-text-light dark:text-fog-dark-text-light">{getRelativeTime()}</span> |
||||
{#if getClientName()} |
||||
<span class="text-xs text-fog-text-light dark:text-fog-dark-text-light">via {getClientName()}</span> |
||||
{/if} |
||||
</div> |
||||
</div> |
||||
|
||||
<div class="card-content mb-2"> |
||||
{#if getDTag()} |
||||
<div class="d-tag-display mb-2"> |
||||
<span class="text-sm font-semibold text-fog-text dark:text-fog-dark-text">d-tag:</span> |
||||
<span class="text-sm text-fog-text-light dark:text-fog-dark-text-light ml-1">{getDTag()}</span> |
||||
</div> |
||||
{/if} |
||||
|
||||
{#if event.content} |
||||
<div class="content-preview text-sm text-fog-text dark:text-fog-dark-text mb-2"> |
||||
{event.content.slice(0, 200)}{event.content.length > 200 ? '...' : ''} |
||||
</div> |
||||
{/if} |
||||
</div> |
||||
|
||||
<div class="card-actions"> |
||||
{#if getWikistrUrl()} |
||||
<a |
||||
href={getWikistrUrl()} |
||||
target="_blank" |
||||
rel="noopener noreferrer" |
||||
class="wikistr-link inline-flex items-center gap-2 px-4 py-2 bg-fog-accent dark:bg-fog-dark-accent text-white rounded hover:opacity-90 text-sm" |
||||
> |
||||
<span>View on wikistr</span> |
||||
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"> |
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M10 6H6a2 2 0 00-2 2v10a2 2 0 002 2h10a2 2 0 002-2v-4M14 4h6m0 0v6m0-6L10 14" /> |
||||
</svg> |
||||
</a> |
||||
{/if} |
||||
</div> |
||||
|
||||
<div class="kind-badge"> |
||||
<span class="kind-number">{getKindInfo(event.kind).number}</span> |
||||
<span class="kind-description">{getKindInfo(event.kind).description}</span> |
||||
</div> |
||||
</article> |
||||
|
||||
<style> |
||||
.replaceable-event-card { |
||||
padding: 1rem; |
||||
margin-bottom: 1rem; |
||||
background: var(--fog-post, #ffffff); |
||||
border: 1px solid var(--fog-border, #e5e7eb); |
||||
border-radius: 0.25rem; |
||||
border-left: 3px solid var(--fog-accent, #64748b); |
||||
} |
||||
|
||||
:global(.dark) .replaceable-event-card { |
||||
background: var(--fog-dark-post, #1f2937); |
||||
border-color: var(--fog-dark-border, #374151); |
||||
border-left-color: var(--fog-dark-accent, #64748b); |
||||
} |
||||
|
||||
.card-header { |
||||
padding-bottom: 0.5rem; |
||||
border-bottom: 1px solid var(--fog-border, #e5e7eb); |
||||
} |
||||
|
||||
:global(.dark) .card-header { |
||||
border-bottom-color: var(--fog-dark-border, #374151); |
||||
} |
||||
|
||||
.content-preview { |
||||
line-height: 1.6; |
||||
} |
||||
|
||||
.wikistr-link { |
||||
text-decoration: none; |
||||
transition: opacity 0.2s; |
||||
} |
||||
|
||||
.wikistr-link:hover { |
||||
opacity: 0.9; |
||||
} |
||||
|
||||
.kind-badge { |
||||
position: absolute; |
||||
bottom: 0.5rem; |
||||
right: 0.5rem; |
||||
display: flex; |
||||
flex-direction: column; |
||||
align-items: flex-end; |
||||
gap: 0.125rem; |
||||
font-size: 0.625rem; |
||||
line-height: 1; |
||||
color: var(--fog-text-light, #9ca3af); |
||||
} |
||||
|
||||
:global(.dark) .kind-badge { |
||||
color: var(--fog-dark-text-light, #6b7280); |
||||
} |
||||
|
||||
.kind-number { |
||||
font-weight: 600; |
||||
} |
||||
|
||||
.kind-description { |
||||
font-size: 0.5rem; |
||||
opacity: 0.8; |
||||
} |
||||
|
||||
.replaceable-event-card { |
||||
position: relative; |
||||
} |
||||
</style> |
||||
Loading…
Reference in new issue