|
|
<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> |