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.
 
 
 
 
 

605 lines
22 KiB

<script lang="ts">
import { nostrClient } from '../../services/nostr/nostr-client.js';
import { relayManager } from '../../services/nostr/relay-manager.js';
import FeedPost from '../feed/FeedPost.svelte';
import ThreadDrawer from '../feed/ThreadDrawer.svelte';
import type { NostrEvent } from '../../types/nostr.js';
import { onMount } from 'svelte';
import { KIND } from '../../types/kind-lookup.js';
// Data maps - all data loaded upfront
let threadsMap = $state<Map<string, NostrEvent>>(new Map()); // threadId -> thread
let reactionsMap = $state<Map<string, NostrEvent[]>>(new Map()); // threadId -> reactions[]
let zapReceiptsMap = $state<Map<string, NostrEvent[]>>(new Map()); // threadId -> zapReceipts[]
let commentsMap = $state<Map<string, NostrEvent[]>>(new Map()); // threadId -> comments[]
let loading = $state(true);
let sortBy = $state<'newest' | 'active' | 'upvoted'>('newest');
let showOlder = $state(false);
let selectedTopic = $state<string | null | undefined>(null); // null = All, undefined = General, string = specific topic
// Thread drawer state
let drawerOpen = $state(false);
let selectedEvent = $state<NostrEvent | null>(null);
// Computed: get sorted and filtered threads from maps
let threads = $derived.by(() => {
const allThreads = Array.from(threadsMap.values());
const sorted = sortThreadsFromMaps(allThreads, sortBy);
return sorted;
});
$effect(() => {
loadAllData();
});
async function loadAllData() {
loading = true;
try {
const config = nostrClient.getConfig();
const since = showOlder
? undefined
: Math.floor(Date.now() / 1000) - config.threadTimeoutDays * 86400;
const threadRelays = relayManager.getThreadReadRelays();
const commentRelays = relayManager.getCommentReadRelays();
// 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 zapRelays = relayManager.getZapReceiptReadRelays();
// Step 1: Load from cache first (immediate display)
const cachedThreads = await nostrClient.fetchEvents(
[{ kinds: [KIND.DISCUSSION_THREAD], since, limit: 50 }],
threadRelays,
{
useCache: true,
cacheResults: false, // Don't cache again, we already have it
timeout: 100 // Quick timeout for cache-only fetch
}
);
// Build threads map from cache immediately
const newThreadsMap = new Map<string, NostrEvent>();
for (const event of cachedThreads) {
newThreadsMap.set(event.id, event);
}
threadsMap = newThreadsMap;
loading = false; // Show cached data immediately
// Step 2: Fetch from relays in background and update incrementally
// Use a Set to track which events we've already processed to avoid loops
const processedEventIds = new Set<string>(Array.from(newThreadsMap.keys()));
nostrClient.fetchEvents(
[{ kinds: [KIND.DISCUSSION_THREAD], since, limit: 50 }],
threadRelays,
{
useCache: false, // Force query relays
cacheResults: true,
onUpdate: async (updatedEvents) => {
// Only add new events that aren't already in the map
let hasNewEvents = false;
for (const event of updatedEvents) {
if (!processedEventIds.has(event.id)) {
newThreadsMap.set(event.id, event);
processedEventIds.add(event.id);
hasNewEvents = true;
} else {
// Update existing event if this one is newer
const existing = newThreadsMap.get(event.id);
if (existing && event.created_at > existing.created_at) {
newThreadsMap.set(event.id, event);
}
}
}
if (hasNewEvents) {
threadsMap = new Map(newThreadsMap); // Trigger reactivity
}
}
}
).then((relayThreads) => {
// Final update after relay fetch completes
let hasNewEvents = false;
for (const event of relayThreads) {
if (!processedEventIds.has(event.id)) {
newThreadsMap.set(event.id, event);
processedEventIds.add(event.id);
hasNewEvents = true;
} else {
// Update existing event if this one is newer
const existing = newThreadsMap.get(event.id);
if (existing && event.created_at > existing.created_at) {
newThreadsMap.set(event.id, event);
}
}
}
if (hasNewEvents) {
threadsMap = new Map(newThreadsMap); // Trigger reactivity
}
}).catch((error) => {
console.debug('Background relay fetch error (non-critical):', error);
});
// Get all thread IDs (use current threadsMap, not newThreadsMap, since it may have been updated)
const threadIds = Array.from(threadsMap.keys());
if (threadIds.length > 0) {
// Fetch all comments in parallel
const allComments = await nostrClient.fetchEvents(
[{ kinds: [KIND.COMMENT], '#E': threadIds, '#K': ['11'] }],
commentRelays,
{ useCache: true }
);
// Fetch all reactions in parallel
// 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>();
// Function to process and group reactions (called initially and on updates)
const processReactionUpdates = async () => {
const allReactions = Array.from(allReactionsMap.values());
console.log('[ThreadList] Processing reaction updates, total reactions:', allReactions.length);
if (allReactions.length === 0) return;
// Fetch deletion events for current reactions
const deletionEvents = await nostrClient.fetchEvents(
[{ kinds: [KIND.EVENT_DELETION], authors: Array.from(new Set(allReactions.map(r => r.pubkey))) }],
reactionRelays,
{ useCache: true }
);
// Build deleted reaction IDs map
const deletedReactionIdsByPubkey = new Map<string, Set<string>>();
for (const deletionEvent of deletionEvents) {
const pubkey = deletionEvent.pubkey;
if (!deletedReactionIdsByPubkey.has(pubkey)) {
deletedReactionIdsByPubkey.set(pubkey, new Set());
}
for (const tag of deletionEvent.tags) {
if (tag[0] === 'e' && tag[1]) {
deletedReactionIdsByPubkey.get(pubkey)!.add(tag[1]);
}
}
}
// Rebuild reactions map
const updatedReactionsMap = new Map<string, NostrEvent[]>();
for (const reaction of allReactions) {
const deletedIds = deletedReactionIdsByPubkey.get(reaction.pubkey);
if (deletedIds && deletedIds.has(reaction.id)) {
continue;
}
const threadId = reaction.tags.find(t => {
const tagName = t[0];
return (tagName === 'e' || tagName === 'E') && t[1];
})?.[1];
if (threadId && newThreadsMap.has(threadId)) {
if (!updatedReactionsMap.has(threadId)) {
updatedReactionsMap.set(threadId, []);
}
updatedReactionsMap.get(threadId)!.push(reaction);
}
}
reactionsMap = updatedReactionsMap;
console.log('[ThreadList] Updated reactions map:', {
threadCounts: Array.from(updatedReactionsMap.entries()).map(([threadId, reactions]) => ({
threadId: threadId.substring(0, 16) + '...',
count: reactions.length,
upvotes: reactions.filter(r => {
const content = r.content.trim();
return content === '+' || content === '⬆' || content === '↑';
}).length,
reactionEvents: reactions.map(r => ({
id: r.id.substring(0, 16) + '...',
pubkey: r.pubkey.substring(0, 16) + '...',
content: r.content,
fullEvent: r
}))
}))
});
};
const handleReactionUpdate = async (updated: NostrEvent[]) => {
console.log('[ThreadList] Received reaction update:', {
count: updated.length,
events: updated.map(r => ({
id: r.id.substring(0, 16) + '...',
pubkey: r.pubkey.substring(0, 16) + '...',
content: r.content,
tags: r.tags.filter(t => t[0] === 'e' || t[0] === 'E'),
fullEvent: r
}))
});
for (const r of updated) {
allReactionsMap.set(r.id, r);
}
// Reprocess reactions when updates arrive
await processReactionUpdates();
};
const reactionsWithLowerE = await nostrClient.fetchEvents(
[{ kinds: [KIND.REACTION], '#e': threadIds }],
reactionRelays,
{
useCache: true,
onUpdate: handleReactionUpdate
}
);
// Try uppercase filter, but some relays reject it - that's okay
let reactionsWithUpperE: NostrEvent[] = [];
try {
reactionsWithUpperE = await nostrClient.fetchEvents(
[{ kinds: [KIND.REACTION], '#E': threadIds }],
reactionRelays,
{
useCache: true,
onUpdate: handleReactionUpdate
}
);
} catch (error) {
console.log('[ThreadList] Upper case #E filter rejected by relay (this is normal):', error);
}
console.log('[ThreadList] Reactions fetched:', {
withLowerE: reactionsWithLowerE.length,
withUpperE: reactionsWithUpperE.length,
lowerE_events: reactionsWithLowerE,
upperE_events: reactionsWithUpperE
});
// Combine and deduplicate by reaction ID
for (const r of reactionsWithLowerE) {
allReactionsMap.set(r.id, r);
}
for (const r of reactionsWithUpperE) {
allReactionsMap.set(r.id, r);
}
const allReactions = Array.from(allReactionsMap.values());
console.log('[ThreadList] All reactions (deduplicated):', {
total: allReactions.length,
events: allReactions.map(r => ({
id: r.id.substring(0, 16) + '...',
pubkey: r.pubkey.substring(0, 16) + '...',
content: r.content,
tags: r.tags.filter(t => t[0] === 'e' || t[0] === 'E'),
created_at: new Date(r.created_at * 1000).toISOString()
}))
});
// Fetch all zap receipts in parallel
const allZapReceipts = await nostrClient.fetchEvents(
[{ kinds: [KIND.ZAP_RECEIPT], '#e': threadIds }],
zapRelays,
{ useCache: true }
);
// Build maps
const newCommentsMap = new Map<string, NostrEvent[]>();
let newReactionsMap = new Map<string, NostrEvent[]>();
const newZapReceiptsMap = new Map<string, NostrEvent[]>();
// Process initial reactions (this will set newReactionsMap)
await processReactionUpdates();
newReactionsMap = reactionsMap; // Use the processed reactions map
// Group comments by thread ID
for (const comment of allComments) {
const threadId = comment.tags.find(t => t[0] === 'E' || t[0] === 'e')?.[1];
if (threadId && newThreadsMap.has(threadId)) {
if (!newCommentsMap.has(threadId)) {
newCommentsMap.set(threadId, []);
}
newCommentsMap.get(threadId)!.push(comment);
}
}
// Reactions are already processed by processReactionUpdates() above
// newReactionsMap is now set from reactionsMap
// Group zap receipts by thread ID
for (const zapReceipt of allZapReceipts) {
const threadId = zapReceipt.tags.find(t => t[0] === 'e')?.[1];
if (threadId && newThreadsMap.has(threadId)) {
if (!newZapReceiptsMap.has(threadId)) {
newZapReceiptsMap.set(threadId, []);
}
newZapReceiptsMap.get(threadId)!.push(zapReceipt);
}
}
commentsMap = newCommentsMap;
reactionsMap = newReactionsMap;
zapReceiptsMap = newZapReceiptsMap;
} else {
// Clear maps if no threads
commentsMap = new Map();
reactionsMap = new Map();
zapReceiptsMap = new Map();
}
} catch (error) {
console.error('Error loading thread data:', error);
threadsMap = new Map();
commentsMap = new Map();
reactionsMap = new Map();
zapReceiptsMap = new Map();
} finally {
loading = false;
}
}
// Sort threads from the maps (synchronous, no fetching)
function sortThreadsFromMaps(events: NostrEvent[], sortType: 'newest' | 'active' | 'upvoted'): NostrEvent[] {
switch (sortType) {
case 'newest':
return [...events].sort((a, b) => b.created_at - a.created_at);
case 'active':
// Sort by most recent activity (comments, reactions, or zaps)
const activeSorted = events.map((event) => {
const comments = commentsMap.get(event.id) || [];
const reactions = reactionsMap.get(event.id) || [];
const zapReceipts = zapReceiptsMap.get(event.id) || [];
const lastCommentTime = comments.length > 0
? Math.max(...comments.map(c => c.created_at))
: 0;
const lastReactionTime = reactions.length > 0
? Math.max(...reactions.map(r => r.created_at))
: 0;
const lastZapTime = zapReceipts.length > 0
? Math.max(...zapReceipts.map(z => z.created_at))
: 0;
const lastActivity = Math.max(
event.created_at,
lastCommentTime,
lastReactionTime,
lastZapTime
);
return { event, lastActivity };
});
return activeSorted
.sort((a, b) => b.lastActivity - a.lastActivity)
.map(({ event }) => event);
case 'upvoted':
// Sort by upvote count
const upvotedSorted = events.map((event) => {
const reactions = reactionsMap.get(event.id) || [];
const upvoteCount = reactions.filter(
(r) => r.content.trim() === '+' || r.content.trim() === '⬆' || r.content.trim() === '↑'
).length;
return { event, upvotes: upvoteCount };
});
return upvotedSorted
.sort((a, b) => b.upvotes - a.upvotes)
.map(({ event }) => event);
default:
return events;
}
}
/**
* Filter threads by age (30 days)
*/
function filterByAge(events: NostrEvent[]): NostrEvent[] {
if (showOlder) {
return events; // Show all threads if "show older" is checked
}
const config = nostrClient.getConfig();
const cutoffTime = Math.floor(Date.now() / 1000) - config.threadTimeoutDays * 86400;
return events.filter((t) => t.created_at >= cutoffTime);
}
// Get filtered threads (by age and topic) - reactive derived value
let filteredThreads = $derived.by(() => {
let filtered = threads;
// Filter by age first
filtered = filterByAge(filtered);
// Then filter by topic
// selectedTopic === null means "All" - show all threads
if (selectedTopic === null) {
return filtered;
}
// selectedTopic === undefined means "General" - show threads without topics
if (selectedTopic === undefined) {
return filtered.filter((t) => !t.tags.some((tag) => tag[0] === 't'));
}
// selectedTopic is a string - show threads with that topic
return filtered.filter((t) => t.tags.some((tag) => tag[0] === 't' && tag[1] === selectedTopic));
});
function getTopics(): string[] {
const topicSet = new Set<string>();
// Use age-filtered threads for topic extraction
const ageFiltered = filterByAge(threads);
for (const thread of ageFiltered) {
const topics = thread.tags.filter((t) => t[0] === 't').map((t) => t[1]);
topics.forEach((t) => topicSet.add(t));
}
return Array.from(topicSet).sort();
}
function getTopicsWithCounts(): Array<{ topic: string | null; count: number }> {
// Use age-filtered threads for counts
const ageFiltered = filterByAge(threads);
const topics = getTopics();
const result: Array<{ topic: string | null; count: number }> = [];
// Add "General" (threads without topics)
const generalCount = ageFiltered.filter((t) => !t.tags.some((tag) => tag[0] === 't')).length;
if (generalCount > 0) {
result.push({ topic: null, count: generalCount });
}
// Add topics with counts
for (const topic of topics) {
const count = ageFiltered.filter((t) => t.tags.some((tag) => tag[0] === 't' && tag[1] === topic)).length;
if (count > 0) {
result.push({ topic, count });
}
}
return result;
}
function openThreadDrawer(event: NostrEvent, e?: MouseEvent) {
// Don't open drawer if clicking on interactive elements
if (e) {
const target = e.target as HTMLElement;
if (target.closest('button') || target.closest('a') || target.closest('[role="button"]')) {
return;
}
}
selectedEvent = event;
drawerOpen = true;
}
function closeThreadDrawer() {
drawerOpen = false;
selectedEvent = null;
}
onMount(() => {
// Listen for custom event from EmbeddedEvent components
const handleOpenEvent = (e: CustomEvent) => {
if (e.detail?.event) {
openThreadDrawer(e.detail.event);
}
};
window.addEventListener('openEventInDrawer', handleOpenEvent as EventListener);
return () => {
window.removeEventListener('openEventInDrawer', handleOpenEvent as EventListener);
};
});
</script>
<div class="thread-list">
<!-- Top row: Sorting and Show Older checkbox -->
<div class="controls mb-4 flex gap-4 items-center flex-wrap">
<select
bind:value={sortBy}
class="border border-fog-border dark:border-fog-dark-border bg-fog-post dark:bg-fog-dark-post text-fog-text dark:text-fog-dark-text px-2 py-1 rounded"
>
<option value="newest">Newest</option>
<option value="active">Most Active</option>
<option value="upvoted">Most Upvoted</option>
</select>
<label class="text-fog-text dark:text-fog-dark-text flex items-center gap-2">
<input
type="checkbox"
bind:checked={showOlder}
onchange={() => {
// If showing older threads, reload to fetch them
if (showOlder) {
loadAllData();
}
}}
/>
Show older posts (than 30 days)
</label>
</div>
<!-- Filter by topic buttons -->
<div class="mb-6">
<div class="flex flex-wrap gap-2 items-center">
<span class="text-sm font-semibold text-fog-text dark:text-fog-dark-text mr-2">Filter by topic:</span>
<button
onclick={() => (selectedTopic = null)}
class="px-3 py-1 rounded border transition-colors {selectedTopic === null
? 'bg-fog-accent dark:bg-fog-dark-accent text-white border-fog-accent dark:border-fog-dark-accent'
: 'bg-fog-post dark:bg-fog-dark-post text-fog-text dark:text-fog-dark-text border-fog-border dark:border-fog-dark-border hover:bg-fog-highlight dark:hover:bg-fog-dark-highlight'}"
>
All ({filterByAge(threads).length})
</button>
{#each getTopicsWithCounts() as { topic, count }}
<button
onclick={() => (selectedTopic = topic === null ? undefined : topic)}
class="px-3 py-1 rounded border transition-colors {selectedTopic === (topic === null ? undefined : topic)
? 'bg-fog-accent dark:bg-fog-dark-accent text-white border-fog-accent dark:border-fog-dark-accent'
: 'bg-fog-post dark:bg-fog-dark-post text-fog-text dark:text-fog-dark-text border-fog-border dark:border-fog-dark-border hover:bg-fog-highlight dark:hover:bg-fog-dark-highlight'}"
>
{topic === null ? 'General' : topic} ({count})
</button>
{/each}
</div>
</div>
<!-- Thread list -->
{#if loading}
<p class="text-fog-text dark:text-fog-dark-text">Loading threads...</p>
{:else}
<div>
{#each filteredThreads as thread}
<div
data-thread-id={thread.id}
class="thread-wrapper"
onclick={(e) => openThreadDrawer(thread, e)}
role="button"
tabindex="0"
onkeydown={(e) => {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault();
openThreadDrawer(thread);
}
}}
>
<FeedPost
post={thread}
previewMode={true}
reactions={reactionsMap.get(thread.id) || []}
/>
</div>
{/each}
{#if filteredThreads.length === 0}
<p class="text-fog-text-light dark:text-fog-dark-text-light">No threads found.</p>
{/if}
</div>
{/if}
</div>
<ThreadDrawer
opEvent={selectedEvent}
isOpen={drawerOpen}
onClose={closeThreadDrawer}
/>
<style>
.thread-list {
max-width: var(--content-width);
margin: 0 auto;
padding: 1rem;
}
.thread-wrapper {
cursor: pointer;
transition: background 0.2s;
margin-bottom: 1rem;
}
.thread-wrapper:hover {
background: var(--fog-highlight, #f3f4f6);
border-radius: 0.25rem;
}
:global(.dark) .thread-wrapper:hover {
background: var(--fog-dark-highlight, #374151);
}
</style>