23 changed files with 743 additions and 1598 deletions
@ -1,707 +1,209 @@ |
|||||||
<script lang="ts"> |
<script lang="ts"> |
||||||
import FeedPost from './FeedPost.svelte'; |
|
||||||
import ThreadDrawer from './ThreadDrawer.svelte'; |
|
||||||
import { nostrClient } from '../../services/nostr/nostr-client.js'; |
import { nostrClient } from '../../services/nostr/nostr-client.js'; |
||||||
import { relayManager } from '../../services/nostr/relay-manager.js'; |
import { relayManager } from '../../services/nostr/relay-manager.js'; |
||||||
import { sessionManager } from '../../services/auth/session-manager.js'; |
import FeedPost from './FeedPost.svelte'; |
||||||
import { onMount } from 'svelte'; |
|
||||||
import type { NostrEvent } from '../../types/nostr.js'; |
import type { NostrEvent } from '../../types/nostr.js'; |
||||||
import { getFeedKinds } from '../../types/kind-lookup.js'; |
import { onMount } from 'svelte'; |
||||||
|
|
||||||
let posts = $state<NostrEvent[]>([]); |
let posts = $state<NostrEvent[]>([]); |
||||||
let loading = $state(true); |
let loading = $state(true); |
||||||
let hasMore = $state(true); |
|
||||||
let loadingMore = $state(false); |
let loadingMore = $state(false); |
||||||
let newPostsCount = $state(0); |
let hasMore = $state(true); |
||||||
let lastPostId = $state<string | null>(null); |
let oldestTimestamp = $state<number | 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 |
// Debounce updates to prevent rapid re-renders |
||||||
$effect(() => { |
let updateTimeout: ReturnType<typeof setTimeout> | null = null; |
||||||
const unsubscribe = sessionManager.session.subscribe(() => { |
let pendingUpdates: NostrEvent[] = []; |
||||||
currentPubkey = sessionManager.getCurrentPubkey(); |
|
||||||
}); |
|
||||||
return unsubscribe; |
|
||||||
}); |
|
||||||
|
|
||||||
onMount(() => { |
onMount(() => { |
||||||
nostrClient.initialize().then(() => { |
(async () => { |
||||||
loadFeed(); |
await nostrClient.initialize(); |
||||||
}); |
await loadFeed(); |
||||||
|
})(); |
||||||
// Set up infinite scroll |
|
||||||
window.addEventListener('scroll', handleScroll); |
// Set up intersection observer for infinite scroll |
||||||
|
const observer = new IntersectionObserver((entries) => { |
||||||
return () => { |
if (entries[0].isIntersecting && hasMore && !loadingMore) { |
||||||
window.removeEventListener('scroll', handleScroll); |
loadMore(); |
||||||
if (scrollTimeout) { |
|
||||||
clearTimeout(scrollTimeout); |
|
||||||
scrollTimeout = null; |
|
||||||
} |
} |
||||||
}; |
}, { threshold: 0.1 }); |
||||||
}); |
|
||||||
|
|
||||||
async function loadFeed(reset = true) { |
const sentinel = document.getElementById('feed-sentinel'); |
||||||
// Prevent concurrent loads |
if (sentinel) { |
||||||
if (isLoadingFeed) { |
observer.observe(sentinel); |
||||||
return; |
|
||||||
} |
|
||||||
|
|
||||||
isLoadingFeed = true; |
|
||||||
|
|
||||||
if (reset) { |
|
||||||
loading = true; |
|
||||||
posts = []; |
|
||||||
hasMore = true; |
|
||||||
} else { |
|
||||||
loadingMore = true; |
|
||||||
} |
} |
||||||
|
|
||||||
try { |
return () => { |
||||||
const oldestTimestamp = posts.length > 0 |
if (sentinel) { |
||||||
? Math.min(...posts.map(p => p.created_at)) |
observer.unobserve(sentinel); |
||||||
: 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 |
|
||||||
? { |
|
||||||
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 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)); |
|
||||||
|
|
||||||
if (newPosts.length > 0) { |
|
||||||
// Sort only the new posts, then append to existing (preserve existing order) |
|
||||||
const sortedNewPosts = sortPosts(newPosts); |
|
||||||
posts = [...posts, ...sortedNewPosts]; |
|
||||||
} |
|
||||||
} |
|
||||||
} |
|
||||||
|
|
||||||
} |
} |
||||||
|
if (updateTimeout) { |
||||||
// For initial load, wait a moment to ensure all data is processed before showing feed |
clearTimeout(updateTimeout); |
||||||
// 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 |
async function loadFeed() { |
||||||
// For initial load, we don't use onUpdate, so background refresh won't cause scrolling |
loading = true; |
||||||
|
try { |
||||||
// Phase 2: Fetch secondary kinds (reactions, zaps) for displayed events |
const config = nostrClient.getConfig(); |
||||||
// One request per relay with all filters, sent in parallel, update cache in background (10s timeout) |
const relays = relayManager.getFeedReadRelays(); |
||||||
// Only fetch if we're not in a loading state to prevent excessive requests |
|
||||||
// Don't fetch during initial load to prevent scrolling |
// Load initial feed - cache first, then background refresh |
||||||
if (!isLoadingFeed && !loading && !loadingMore && !reset) { |
const filters = [{ kinds: [1], limit: 20 }]; |
||||||
const displayedEventIds = posts.map(e => e.id); |
const events = await nostrClient.fetchEvents( |
||||||
if (displayedEventIds.length > 0) { |
filters, |
||||||
// Fetch reactions (kind 7) and zap receipts (kind 9735) for displayed events |
relays, |
||||||
const secondaryFilter = [{ |
{ |
||||||
kinds: [7, 9735], |
useCache: true, |
||||||
'#e': displayedEventIds.slice(0, 100) // Limit to avoid huge requests |
cacheResults: true, |
||||||
}]; |
onUpdate: handleUpdate, |
||||||
|
timeout: 10000 |
||||||
// 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 |
// Sort by created_at descending |
||||||
// Batch fetch all at once to prevent individual requests from ProfileBadge components |
const sorted = events.sort((a, b) => b.created_at - a.created_at); |
||||||
// This runs on both initial load and background refresh |
posts = sorted; |
||||||
const uniquePubkeys = new Set<string>(); |
|
||||||
for (const event of posts) { |
if (sorted.length > 0) { |
||||||
uniquePubkeys.add(event.pubkey); |
oldestTimestamp = Math.min(...sorted.map(e => e.created_at)); |
||||||
} |
|
||||||
|
|
||||||
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; |
hasMore = events.length >= 20; |
||||||
} catch (error) { |
} catch (error) { |
||||||
console.error('Error loading feed:', error); |
console.error('Error loading feed:', error); |
||||||
} finally { |
} finally { |
||||||
loading = false; |
loading = false; |
||||||
loadingMore = false; |
|
||||||
isLoadingFeed = false; |
|
||||||
} |
} |
||||||
} |
} |
||||||
|
|
||||||
function handleScroll() { |
async function loadMore() { |
||||||
// Throttle scroll handler to prevent doom loops |
if (loadingMore || !hasMore) return; |
||||||
if (scrollTimeout) { |
|
||||||
return; |
|
||||||
} |
|
||||||
|
|
||||||
scrollTimeout = setTimeout(() => { |
|
||||||
scrollTimeout = null; |
|
||||||
}, 500); // Increase throttle to 500ms to reduce frequency |
|
||||||
|
|
||||||
if (loadingMore || !hasMore || isLoadingFeed) return; |
loadingMore = true; |
||||||
|
try { |
||||||
const scrollTop = window.scrollY || document.documentElement.scrollTop; |
const config = nostrClient.getConfig(); |
||||||
const windowHeight = window.innerHeight; |
const relays = relayManager.getFeedReadRelays(); |
||||||
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 filters = [{ |
||||||
const replyTag = event.tags.find((t) => t[0] === 'e' && t[3] === 'reply'); |
kinds: [1], |
||||||
const parentId = replyTag?.[1]; |
limit: 20, |
||||||
|
until: oldestTimestamp || undefined |
||||||
|
}]; |
||||||
|
|
||||||
if (parentId && eventMap.has(parentId)) { |
const events = await nostrClient.fetchEvents( |
||||||
// This is a reply to an event we have |
filters, |
||||||
if (!replyMap.has(parentId)) { |
relays, |
||||||
replyMap.set(parentId, []); |
{ |
||||||
|
useCache: true, |
||||||
|
cacheResults: true, |
||||||
|
timeout: 10000 |
||||||
} |
} |
||||||
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 |
if (events.length === 0) { |
||||||
const result: NostrEvent[] = []; |
hasMore = false; |
||||||
const processed = new Set<string>(); |
return; |
||||||
|
} |
||||||
|
|
||||||
function addThread(event: NostrEvent) { |
// Filter out duplicates |
||||||
if (processed.has(event.id)) return; |
const existingIds = new Set(posts.map(p => p.id)); |
||||||
processed.add(event.id); |
const newEvents = events.filter(e => !existingIds.has(e.id)); |
||||||
|
|
||||||
result.push(event); |
if (newEvents.length > 0) { |
||||||
|
const sorted = newEvents.sort((a, b) => b.created_at - a.created_at); |
||||||
// Add all replies to this event |
posts = [...posts, ...sorted]; |
||||||
const replies = replyMap.get(event.id) || []; |
|
||||||
const replyEvents = replies |
const oldest = Math.min(...newEvents.map(e => e.created_at)); |
||||||
.map(id => eventMap.get(id)) |
if (oldest < (oldestTimestamp || Infinity)) { |
||||||
.filter((e): e is NostrEvent => e !== undefined) |
oldestTimestamp = oldest; |
||||||
.sort((a, b) => a.created_at - b.created_at); // Sort replies chronologically |
} |
||||||
|
|
||||||
for (const reply of replyEvents) { |
|
||||||
addThread(reply); |
|
||||||
} |
} |
||||||
|
|
||||||
|
hasMore = events.length >= 20; |
||||||
|
} catch (error) { |
||||||
|
console.error('Error loading more:', error); |
||||||
|
} finally { |
||||||
|
loadingMore = false; |
||||||
} |
} |
||||||
|
|
||||||
// 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 = []; |
|
||||||
} |
|
||||||
|
|
||||||
// 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; |
|
||||||
} |
} |
||||||
|
|
||||||
/** |
// Debounced update handler to prevent rapid re-renders |
||||||
* Filter posts client-side based on showOPsOnly and showResponsesToMe checkboxes. |
function handleUpdate(updated: NostrEvent[]) { |
||||||
* This is purely reactive - no queries are triggered. |
pendingUpdates.push(...updated); |
||||||
* 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 (updateTimeout) { |
||||||
if (showOPsOnly) { |
clearTimeout(updateTimeout); |
||||||
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; |
// Batch updates every 500ms |
||||||
} |
updateTimeout = setTimeout(() => { |
||||||
|
if (pendingUpdates.length === 0) return; |
||||||
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) |
const existingIds = new Set(posts.map(p => p.id)); |
||||||
if (target.tagName === 'BUTTON' || target.tagName === 'A' || |
const newEvents = pendingUpdates.filter(e => !existingIds.has(e.id)); |
||||||
(target.getAttribute('role') === 'button' && target !== wrapper)) { |
|
||||||
return; |
|
||||||
} |
|
||||||
|
|
||||||
// Check if clicking inside a button or link (but not the wrapper) |
if (newEvents.length > 0) { |
||||||
const button = target.closest('button'); |
// Merge and sort |
||||||
const link = target.closest('a'); |
const merged = [...posts, ...newEvents]; |
||||||
if ((button && button !== wrapper) || (link && link !== wrapper)) { |
const sorted = merged.sort((a, b) => b.created_at - a.created_at); |
||||||
return; |
posts = sorted; |
||||||
} |
} |
||||||
} |
|
||||||
selectedEvent = event; |
pendingUpdates = []; |
||||||
drawerOpen = true; |
}, 500); |
||||||
} |
|
||||||
|
|
||||||
function closeThreadDrawer() { |
|
||||||
drawerOpen = false; |
|
||||||
selectedEvent = null; |
|
||||||
|
|
||||||
// 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; |
|
||||||
} |
|
||||||
} |
} |
||||||
|
|
||||||
// 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 |
|
||||||
}); |
|
||||||
} |
|
||||||
|
|
||||||
// Sort by created_at, newest first |
|
||||||
return items.sort((a, b) => b.created_at - a.created_at); |
|
||||||
}); |
|
||||||
</script> |
</script> |
||||||
|
|
||||||
<div class="Feed-feed"> |
<div class="feed-page"> |
||||||
<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} |
{#if loading} |
||||||
<p class="text-fog-text-light dark:text-fog-dark-text-light">Loading feed...</p> |
<div class="loading-state"> |
||||||
|
<p class="text-fog-text dark:text-fog-dark-text">Loading feed...</p> |
||||||
|
</div> |
||||||
{:else if posts.length === 0} |
{: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> |
<div class="empty-state"> |
||||||
|
<p class="text-fog-text dark:text-fog-dark-text">No posts found. Check back later!</p> |
||||||
|
</div> |
||||||
{:else} |
{:else} |
||||||
{#if newPostsCount > 0} |
<div class="feed-posts"> |
||||||
<div class="new-posts-indicator mb-4"> |
{#each posts as post (post.id)} |
||||||
<button |
<FeedPost post={post} /> |
||||||
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)} |
|
||||||
{@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> |
|
||||||
{/each} |
{/each} |
||||||
</div> |
</div> |
||||||
{#if loadingMore} |
|
||||||
<p class="text-center text-fog-text-light dark:text-fog-dark-text-light mt-4">Loading more...</p> |
<div id="feed-sentinel" class="feed-sentinel"> |
||||||
{/if} |
{#if loadingMore} |
||||||
{#if !hasMore && allFeedItems.length > 0} |
<p class="text-fog-text-light dark:text-fog-dark-text-light">Loading more...</p> |
||||||
<p class="text-center text-fog-text-light dark:text-fog-dark-text-light mt-4">No more posts</p> |
{:else if hasMore} |
||||||
{/if} |
<p class="text-fog-text-light dark:text-fog-dark-text-light">Scroll for more</p> |
||||||
{#if (showOPsOnly || showResponsesToMe) && getFilteredPosts().length === 0 && posts.length > 0} |
{:else} |
||||||
<p class="text-center text-fog-text-light dark:text-fog-dark-text-light mt-4"> |
<p class="text-fog-text-light dark:text-fog-dark-text-light">No more posts</p> |
||||||
{#if showResponsesToMe && showOPsOnly} |
{/if} |
||||||
No original posts responding to you found. Try unchecking the filters. |
</div> |
||||||
{: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} |
{/if} |
||||||
</div> |
</div> |
||||||
|
|
||||||
<ThreadDrawer |
|
||||||
opEvent={selectedEvent} |
|
||||||
isOpen={drawerOpen} |
|
||||||
onClose={closeThreadDrawer} |
|
||||||
/> |
|
||||||
|
|
||||||
<style> |
<style> |
||||||
.Feed-feed { |
.feed-page { |
||||||
max-width: var(--content-width); |
max-width: 100%; |
||||||
margin: 0 auto; |
|
||||||
padding: 1rem; |
|
||||||
} |
} |
||||||
|
|
||||||
.feed-header { |
.loading-state, |
||||||
display: flex; |
.empty-state { |
||||||
justify-content: space-between; |
padding: 2rem; |
||||||
align-items: center; |
text-align: center; |
||||||
flex-wrap: wrap; |
|
||||||
gap: 1rem; |
|
||||||
} |
} |
||||||
|
|
||||||
.feed-controls { |
.feed-posts { |
||||||
display: flex; |
display: flex; |
||||||
flex-wrap: wrap; |
flex-direction: column; |
||||||
align-items: center; |
gap: 1rem; |
||||||
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 { |
.feed-sentinel { |
||||||
background: var(--fog-dark-highlight, #374151); |
padding: 2rem; |
||||||
|
text-align: center; |
||||||
|
min-height: 100px; |
||||||
} |
} |
||||||
</style> |
</style> |
||||||
|
|||||||
@ -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