@ -1,17 +1,19 @@
< script lang = "ts" >
< script lang = "ts" >
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 FeedPost from '../feed/FeedPost .svelte';
import DiscussionCard from './DiscussionCard .svelte';
import ThreadDrawer from '../feed/ThreadDrawer.svelte';
import ThreadDrawer from '../feed/ThreadDrawer.svelte';
import type { NostrEvent } from '../../types/nostr.js';
import type { NostrEvent } from '../../types/nostr.js';
import { onMount } from 'svelte';
import { onMount } from 'svelte';
import { KIND } from '../../types/kind-lookup.js';
import { KIND } from '../../types/kind-lookup.js';
// Data maps - all data loaded upfront
// Data maps - threads and stats for sorting only (DiscussionCard loads its own stats for display)
let threadsMap = $state< Map < string , NostrEvent > >(new Map()); // threadId -> thread
let threadsMap = $state< Map < string , NostrEvent > >(new Map()); // threadId -> thread
let reactionsMap = $state< Map < string , NostrEvent [ ] > >(new Map()); // threadId -> reactions[]
let reactionsMap = $state< Map < string , NostrEvent [ ] > >(new Map()); // threadId -> reactions[] (for sorting only)
let zapReceiptsMap = $state< Map < string , NostrEvent [ ] > >(new Map()); // threadId -> zapReceipts[]
let zapReceiptsMap = $state< Map < string , NostrEvent [ ] > >(new Map()); // threadId -> zapReceipts[] (for sorting only)
let commentsMap = $state< Map < string , NostrEvent [ ] > >(new Map()); // threadId -> comments[]
let commentsMap = $state< Map < string , number > >(new Map()); // threadId -> commentCount (batch-loaded for display)
let voteCountsMap = $state< Map < string , { upvotes : number ; downvotes : number } > >(new Map()); // threadId -> { upvotes , downvotes } (calculated from reactionsMap)
let voteCountsReady = $state(false); // Track when vote counts are fully calculated
let loading = $state(true);
let loading = $state(true);
let sortBy = $state< 'newest' | 'active' | 'upvoted'>('newest');
let sortBy = $state< 'newest' | 'active' | 'upvoted'>('newest');
@ -84,6 +86,7 @@
if (!isMounted || isLoading) return; // Don't load if unmounted or already loading
if (!isMounted || isLoading) return; // Don't load if unmounted or already loading
loading = true;
loading = true;
isLoading = true;
isLoading = true;
voteCountsReady = false; // Reset vote counts ready state
try {
try {
const config = nostrClient.getConfig();
const config = nostrClient.getConfig();
const since = showOlder
const since = showOlder
@ -92,9 +95,9 @@
const threadRelays = relayManager.getThreadReadRelays();
const threadRelays = relayManager.getThreadReadRelays();
// Use getProfileReadRelays() for reactions to include defaultRelays + profileRelays + user inbox + localRelays
// Use getProfileReadRelays() for reactions to include defaultRelays + profileRelays + user inbox + localRelays
// This ensures we get all reactions from the complete relay set
const reactionRelays = relayManager.getProfileReadRelays();
const reactionRelays = relayManager.getProfileReadRelays();
const zapRelays = relayManager.getZapReceiptReadRelays();
const zapRelays = relayManager.getZapReceiptReadRelays();
const commentRelays = relayManager.getCommentReadRelays();
// Query relays first with 3-second timeout, then fill from cache if needed
// Query relays first with 3-second timeout, then fill from cache if needed
const fetchPromise = nostrClient.fetchEvents(
const fetchPromise = nostrClient.fetchEvents(
@ -141,30 +144,25 @@
threadsMap = newThreadsMap;
threadsMap = newThreadsMap;
loading = false; // Show data immediately
loading = false; // Show data immediately
// Get all thread IDs (use current threadsMap, not newThreadsMap, since it may have been updated )
// Get all thread IDs for loading stats (for sorting only - DiscussionCard loads its own for display )
const threadIds = Array.from(threadsMap.keys());
const threadIds = Array.from(threadsMap.keys());
if (threadIds.length > 0) {
if (threadIds.length > 0) {
// Don't fetch comments - they're not displayed on the list page
// Load reactions and zaps for sorting purposes only
// Only fetch reactions and zaps for sorting and display
// DiscussionCard components will load their own stats for display
// Fetch all reactions in parallel
// Fetch all reactions in parallel (for sorting)
// Note: Some relays reject '#E' filter, so we only use '#e' and handle both cases in grouping
// Use onUpdate to handle real-time reaction updates
const allReactionsMap = new Map< string , NostrEvent > ();
const allReactionsMap = new Map< string , NostrEvent > ();
// Function to process and group reactions (called initially and on updates)
const processReactionUpdates = async () => {
const processReactionUpdates = async () => {
const allReactions = Array.from(allReactionsMap.values());
const allReactions = Array.from(allReactionsMap.values());
// Processing reaction updates
if (allReactions.length === 0) return;
if (allReactions.length === 0) return;
if (!isMounted) return;
if (!isMounted) return; // Don't process if unmounted
// Fetch deletion events for specific reaction IDs only
const reactionIds = allReactions.map(r => r.id);
// Fetch deletion events for current reactions
const deletionFetchPromise = nostrClient.fetchEvents(
const deletionFetchPromise = nostrClient.fetchEvents(
[{ kinds : [ KIND . EVENT_DELETION ], authors : Array.from ( new Set ( allReactions . map ( r => r . pubkey ))) } ],
[{ kinds : [ KIND . EVENT_DELETION ], '#e' : reactionIds , limit : 100 } ],
reactionRelays,
reactionRelays,
{ relayFirst : true , useCache : true , cacheResults : true , timeout : 3000 }
{ relayFirst : true , useCache : true , cacheResults : true , timeout : 3000 }
);
);
@ -172,36 +170,29 @@
const deletionEvents = await deletionFetchPromise;
const deletionEvents = await deletionFetchPromise;
activeFetchPromises.delete(deletionFetchPromise);
activeFetchPromises.delete(deletionFetchPromise);
if (!isMounted) return; // Don't process if unmounted
if (!isMounted) return;
// Build deleted reaction IDs map
// Build deleted reaction IDs set
const deletedReactionIdsByPubkey = new Map< string , Set < string > > ();
const deletedReactionIds = new Set< string > ();
for (const deletionEvent of deletionEvents) {
for (const deletionEvent of deletionEvents) {
const pubkey = deletionEvent.pubkey;
if (!deletedReactionIdsByPubkey.has(pubkey)) {
deletedReactionIdsByPubkey.set(pubkey, new Set());
}
for (const tag of deletionEvent.tags) {
for (const tag of deletionEvent.tags) {
if (tag[0] === 'e' && tag[1]) {
if (tag[0] === 'e' && tag[1]) {
deletedReactionIdsByPubkey.get(pubkey)! .add(tag[1]);
deletedReactionIds.add(tag[1]);
}
}
}
}
}
}
// Rebuild reactions map
// Rebuild reactions map (for sorting only)
const updatedReactionsMap = new Map< string , NostrEvent [ ] > ();
const updatedReactionsMap = new Map< string , NostrEvent [ ] > ();
for (const reaction of allReactions) {
for (const reaction of allReactions) {
const deletedIds = deletedReactionIdsByPubkey.get(reaction.pubkey);
if (deletedReactionIds.has(reaction.id)) continue;
if (deletedIds && deletedIds.has(reaction.id)) {
continue;
}
const threadId = reaction.tags.find(t => {
const threadId = reaction.tags.find(t => {
const tagName = t[0];
const tagName = t[0];
return (tagName === 'e' || tagName === 'E') & & t[1];
return (tagName === 'e' || tagName === 'E') & & t[1];
})?.[1];
})?.[1];
if (threadId && newT hreadsMap.has(threadId)) {
if (threadId && t hreadsMap.has(threadId)) {
if (!updatedReactionsMap.has(threadId)) {
if (!updatedReactionsMap.has(threadId)) {
updatedReactionsMap.set(threadId, []);
updatedReactionsMap.set(threadId, []);
}
}
@ -211,21 +202,21 @@
if (isMounted) {
if (isMounted) {
reactionsMap = updatedReactionsMap;
reactionsMap = updatedReactionsMap;
// Updated reactions map
// Don't update vote counts during real-time updates - only show after initial load
}
}
};
};
const handleReactionUpdate = async (updated: NostrEvent[]) => {
const handleReactionUpdate = async (updated: NostrEvent[]) => {
if (!isMounted) return; // Don't update if unmounted
if (!isMounted) return;
for (const r of updated) {
for (const r of updated) {
allReactionsMap.set(r.id, r);
allReactionsMap.set(r.id, r);
}
}
// Reprocess reactions when updates arrive
await processReactionUpdates();
await processReactionUpdates();
};
};
if (!isMounted) return; // Don't process if unmounted
if (!isMounted) return;
// Fetch reactions with lowercase e
const reactionsFetchPromise1 = nostrClient.fetchEvents(
const reactionsFetchPromise1 = nostrClient.fetchEvents(
[{ kinds : [ KIND . REACTION ], '#e' : threadIds , limit : 100 } ],
[{ kinds : [ KIND . REACTION ], '#e' : threadIds , limit : 100 } ],
reactionRelays,
reactionRelays,
@ -241,9 +232,9 @@
const reactionsWithLowerE = await reactionsFetchPromise1;
const reactionsWithLowerE = await reactionsFetchPromise1;
activeFetchPromises.delete(reactionsFetchPromise1);
activeFetchPromises.delete(reactionsFetchPromise1);
if (!isMounted) return; // Don't process if unmounted
if (!isMounted) return;
// Try uppercase filter, but some relays reject it - that's okay
// Try uppercase filter
let reactionsWithUpperE: NostrEvent[] = [];
let reactionsWithUpperE: NostrEvent[] = [];
try {
try {
const reactionsFetchPromise2 = nostrClient.fetchEvents(
const reactionsFetchPromise2 = nostrClient.fetchEvents(
@ -260,17 +251,14 @@
activeFetchPromises.add(reactionsFetchPromise2);
activeFetchPromises.add(reactionsFetchPromise2);
reactionsWithUpperE = await reactionsFetchPromise2;
reactionsWithUpperE = await reactionsFetchPromise2;
activeFetchPromises.delete(reactionsFetchPromise2);
activeFetchPromises.delete(reactionsFetchPromise2);
if (!isMounted) return;
if (!isMounted) return; // Don't process if unmounted
} catch (error) {
} catch (error) {
if (isMounted) { // Only log if still mounted
if (isMounted) {
console.log('[Thread List] Upper case #E filter rejected by relay (this is normal):', error);
console.log('[Discussion List] Upper case #E filter rejected by relay (this is normal):', error);
}
}
}
}
// Reactions fetched
// Combine reactions
// Combine and deduplicate by reaction ID
for (const r of reactionsWithLowerE) {
for (const r of reactionsWithLowerE) {
allReactionsMap.set(r.id, r);
allReactionsMap.set(r.id, r);
}
}
@ -278,11 +266,15 @@
allReactionsMap.set(r.id, r);
allReactionsMap.set(r.id, r);
}
}
const allReactions = Array.from(allReactionsMap.values());
// Process reactions
await processReactionUpdates();
// Fetch all zap receipts in parallel (relay-first for first-time users)
if (!isMounted) return; // Don't process if unmounted
// Calculate vote counts from reactions for preview cards (only after initial load is complete)
updateVoteCountsMap();
voteCountsReady = true;
// Fetch zap receipts (for sorting)
if (!isMounted) return;
const zapFetchPromise = nostrClient.fetchEvents(
const zapFetchPromise = nostrClient.fetchEvents(
[{ kinds : [ KIND . ZAP_RECEIPT ], '#e' : threadIds , limit : 100 } ],
[{ kinds : [ KIND . ZAP_RECEIPT ], '#e' : threadIds , limit : 100 } ],
zapRelays,
zapRelays,
@ -292,55 +284,91 @@
const allZapReceipts = await zapFetchPromise;
const allZapReceipts = await zapFetchPromise;
activeFetchPromises.delete(zapFetchPromise);
activeFetchPromises.delete(zapFetchPromise);
if (!isMounted) return; // Don't process if unmounted
if (!isMounted) return;
// Build maps
let newReactionsMap = new Map< string , NostrEvent [ ] > ();
const newZapReceiptsMap = new Map< string , NostrEvent [ ] > ();
// Process initial reactions (this will set newReactionsMap)
// Group zap receipts by thread ID (for sorting)
await processReactionUpdates();
const newZapReceiptsMap = new Map< string , NostrEvent [ ] > ();
newReactionsMap = reactionsMap; // Use the processed reactions map
// Group zap receipts by thread ID
for (const zapReceipt of allZapReceipts) {
for (const zapReceipt of allZapReceipts) {
const threadId = zapReceipt.tags.find((t: string[]) => t[0] === 'e')?.[1];
const threadId = zapReceipt.tags.find((t: string[]) => t[0] === 'e')?.[1];
if (threadId && newT hreadsMap.has(threadId)) {
if (threadId && threadsMap.has(threadId)) {
if (!newZapReceiptsMap.has(threadId)) {
if (!newZapReceiptsMap.has(threadId)) {
newZapReceiptsMap.set(threadId, []);
newZapReceiptsMap.set(threadId, []);
}
}
newZapReceiptsMap.get(threadId)!.push(zapReceipt);
newZapReceiptsMap.get(threadId)!.push(zapReceipt);
}
}
}
}
reactionsMap = newReactionsMap;
zapReceiptsMap = newZapReceiptsMap;
zapReceiptsMap = newZapReceiptsMap;
// Clear comments map - we don't fetch comments for the list page
commentsMap = new Map();
// Batch-load comment counts for all threads
} else {
if (!isMounted) return;
// Clear maps if no threads
const commentsFetchPromise = nostrClient.fetchEvents(
commentsMap = new Map();
[{ kinds : [ KIND . COMMENT ], '#E' : threadIds , '#K' : [ '11' ], limit : 100 } ],
reactionsMap = new Map();
commentRelays,
zapReceiptsMap = new Map();
{ relayFirst : true , useCache : true , cacheResults : true , timeout : 3000 , priority : 'low' }
);
activeFetchPromises.add(commentsFetchPromise);
const allComments = await commentsFetchPromise;
activeFetchPromises.delete(commentsFetchPromise);
if (!isMounted) return;
// Count comments per thread
const newCommentsMap = new Map< string , number > ();
for (const comment of allComments) {
const threadId = comment.tags.find((t: string[]) => {
const tagName = t[0];
return (tagName === 'e' || tagName === 'E') & & t[1];
})?.[1];
if (threadId && threadsMap.has(threadId)) {
newCommentsMap.set(threadId, (newCommentsMap.get(threadId) || 0) + 1);
}
}
// Set count to 0 for threads with no comments
for (const threadId of threadIds) {
if (!newCommentsMap.has(threadId)) {
newCommentsMap.set(threadId, 0);
}
}
commentsMap = newCommentsMap;
}
}
} catch (error) {
} catch (error) {
console.error('Error loading thread data:', error);
console.error('Error loading thread data:', error);
threadsMap = new Map();
threadsMap = new Map();
commentsMap = new Map();
voteCountsReady = false;
reactionsMap = new Map();
zapReceiptsMap = new Map();
} finally {
} finally {
loading = false;
loading = false;
}
}
}
}
// Sort threads from the maps (synchronous, no fetching)
// Calculate vote counts from reactionsMap for preview cards
function updateVoteCountsMap() {
const newVoteCountsMap = new Map< string , { upvotes : number ; downvotes : number } > ();
for (const threadId of threadsMap.keys()) {
const threadReactions = reactionsMap.get(threadId) || [];
let upvotes = 0;
let downvotes = 0;
for (const reaction of threadReactions) {
const content = reaction.content.trim();
if (content === '+' || content === '⬆️ ' || content === '↑') {
upvotes++;
} else if (content === '-' || content === '⬇️ ' || content === '↓') {
downvotes++;
}
}
newVoteCountsMap.set(threadId, { upvotes , downvotes } );
}
voteCountsMap = newVoteCountsMap;
}
// Sort threads using stats loaded for sorting (DiscussionCard loads its own stats for display)
function sortThreadsFromMaps(events: NostrEvent[], sortType: 'newest' | 'active' | 'upvoted'): NostrEvent[] {
function sortThreadsFromMaps(events: NostrEvent[], sortType: 'newest' | 'active' | 'upvoted'): NostrEvent[] {
switch (sortType) {
switch (sortType) {
case 'newest':
case 'newest':
return [...events].sort((a, b) => b.created_at - a.created_at);
return [...events].sort((a, b) => b.created_at - a.created_at);
case 'active':
case 'active':
// Sort by most recent activity (reactions or zaps - comments not fetched for list page)
// Sort by most recent activity (reactions or zaps)
const activeSorted = events.map((event) => {
const activeSorted = events.map((event) => {
const reactions = reactionsMap.get(event.id) || [];
const reactions = reactionsMap.get(event.id) || [];
const zapReceipts = zapReceiptsMap.get(event.id) || [];
const zapReceipts = zapReceiptsMap.get(event.id) || [];
@ -541,6 +569,7 @@
{ : else }
{ : else }
< div >
< div >
{ #each filteredThreads as thread }
{ #each filteredThreads as thread }
{ @const voteCounts = voteCountsMap . get ( thread . id ) ?? { upvotes : 0 , downvotes : 0 }}
< div
< div
data-thread-id={ thread . id }
data-thread-id={ thread . id }
class="thread-wrapper"
class="thread-wrapper"
@ -554,10 +583,12 @@
}
}
}}
}}
>
>
< FeedPost
< DiscussionCard
post={ thread }
thread={ thread }
previewMode={ true }
commentCount={ commentsMap . get ( thread . id ) ?? 0 }
reactions={ reactionsMap . get ( thread . id ) || []}
upvotes={ voteCounts . upvotes }
downvotes={ voteCounts . downvotes }
votesCalculated={ voteCountsReady }
/>
/>
< / div >
< / div >
{ /each }
{ /each }