|
|
<script lang="ts"> |
|
|
import { nostrClient } from '../../services/nostr/nostr-client.js'; |
|
|
import { relayManager } from '../../services/nostr/relay-manager.js'; |
|
|
import { config } from '../../services/nostr/config.js'; |
|
|
import DiscussionCard from './DiscussionCard.svelte'; |
|
|
import type { NostrEvent } from '../../types/nostr.js'; |
|
|
import { onMount } from 'svelte'; |
|
|
import { KIND } from '../../types/kind-lookup.js'; |
|
|
import { getRecentCachedEvents, getCachedReactionsForEvents } from '../../services/cache/event-cache.js'; |
|
|
import { nip19 } from 'nostr-tools'; |
|
|
import { goto } from '$app/navigation'; |
|
|
|
|
|
interface Props { |
|
|
filterResult?: { type: 'event' | 'pubkey' | 'text' | null; value: string | null }; |
|
|
} |
|
|
|
|
|
let { filterResult = { type: null, value: null } }: Props = $props(); |
|
|
|
|
|
// Expose state for parent component |
|
|
export { sortBy, showOlder }; |
|
|
|
|
|
// Refresh function for parent component |
|
|
async function refresh() { |
|
|
if (!isMounted) return; |
|
|
threadsMap.clear(); |
|
|
reactionsMap.clear(); |
|
|
commentsMap.clear(); |
|
|
loading = true; |
|
|
await loadCachedThreads(); |
|
|
await loadAllData(); |
|
|
} |
|
|
export { refresh }; |
|
|
|
|
|
// Resolved pubkey from filter (handled by parent component's PubkeyFilter) |
|
|
// For now, we'll do basic normalization here since we don't have access to the filter component |
|
|
// The parent component should resolve NIP-05 before passing it here |
|
|
function normalizeToHex(input: string): string | null { |
|
|
const trimmed = input.trim(); |
|
|
if (!trimmed) return null; |
|
|
|
|
|
if (/^[a-fA-F0-9]{64}$/.test(trimmed)) { |
|
|
return trimmed.toLowerCase(); |
|
|
} |
|
|
|
|
|
try { |
|
|
const decoded = nip19.decode(trimmed); |
|
|
if (decoded.type === 'npub') { |
|
|
return decoded.data as string; |
|
|
} else if (decoded.type === 'nprofile') { |
|
|
return (decoded.data as any).pubkey; |
|
|
} |
|
|
} catch { |
|
|
// Not a valid bech32 |
|
|
} |
|
|
|
|
|
return null; |
|
|
} |
|
|
|
|
|
// 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 reactionsMap = $state<Map<string, NostrEvent[]>>(new Map()); // threadId -> reactions[] (for sorting and display) |
|
|
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 sortBy = $state<'newest' | 'active' | 'upvoted'>('newest'); |
|
|
let showOlder = $state(false); |
|
|
let selectedTopic = $state<string | null | undefined>(null); // null = All, undefined = General, string = specific topic |
|
|
|
|
|
|
|
|
// 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; |
|
|
}); |
|
|
|
|
|
// Track if we're currently loading to prevent loops |
|
|
let isLoading = $state(false); |
|
|
let prevSortBy = $state<'newest' | 'active' | 'upvoted' | null>(null); |
|
|
let prevShowOlder = $state<boolean | null>(null); |
|
|
let prevSelectedTopic = $state<string | null | undefined | null>(null); |
|
|
|
|
|
// Cleanup tracking |
|
|
let isMounted = $state(true); |
|
|
let activeFetchPromises = $state<Set<Promise<any>>>(new Set()); |
|
|
|
|
|
// Initial load on mount |
|
|
onMount(() => { |
|
|
isMounted = true; |
|
|
prevSortBy = sortBy; |
|
|
prevShowOlder = showOlder; |
|
|
prevSelectedTopic = selectedTopic; |
|
|
// Load cached threads first, then refresh |
|
|
loadCachedThreads(); |
|
|
loadAllData(); |
|
|
}); |
|
|
|
|
|
// Cleanup on unmount |
|
|
$effect(() => { |
|
|
return () => { |
|
|
isMounted = false; |
|
|
|
|
|
// Cancel all active fetch promises |
|
|
activeFetchPromises.clear(); |
|
|
|
|
|
// Clear any pending operations |
|
|
isLoading = false; |
|
|
loading = false; |
|
|
}; |
|
|
}); |
|
|
|
|
|
// Only reload when sortBy or selectedTopic changes (after initial values are set) |
|
|
// Note: showOlder only affects view filtering, not data fetching |
|
|
$effect(() => { |
|
|
// Skip if we haven't set initial values yet (onMount hasn't run) |
|
|
if (prevSortBy === null) return; |
|
|
|
|
|
// Read showOlder to ensure it's tracked by the effect (for view filtering) |
|
|
const currentShowOlder = showOlder; |
|
|
const currentSortBy = sortBy; |
|
|
const currentSelectedTopic = selectedTopic; |
|
|
|
|
|
// Check if sortBy or topic changed (showOlder only affects filtering, not fetching) |
|
|
const sortByChanged = currentSortBy !== prevSortBy; |
|
|
const topicChanged = currentSelectedTopic !== prevSelectedTopic; |
|
|
|
|
|
if (sortByChanged || topicChanged) { |
|
|
// Only reload if not already loading |
|
|
if (!isLoading) { |
|
|
loadAllData(); |
|
|
} |
|
|
|
|
|
// Update previous values AFTER checking for changes |
|
|
prevSortBy = currentSortBy; |
|
|
prevShowOlder = currentShowOlder; // Track but don't reload on change |
|
|
prevSelectedTopic = currentSelectedTopic; |
|
|
} else { |
|
|
// Update showOlder tracking even if nothing else changed |
|
|
prevShowOlder = currentShowOlder; |
|
|
} |
|
|
}); |
|
|
|
|
|
async function loadCachedThreads() { |
|
|
if (!isMounted || isLoading) return; |
|
|
|
|
|
try { |
|
|
// Always load all cached threads (limit 100), filtering happens in the view |
|
|
const cachedThreads = await getRecentCachedEvents([KIND.DISCUSSION_THREAD], 60 * 60 * 1000, 100); // 1 hour cache (optimized for slow connections) |
|
|
|
|
|
if (cachedThreads.length > 0 && isMounted) { |
|
|
// Build threads map from cached results (no time filtering here) |
|
|
const newThreadsMap = new Map<string, NostrEvent>(); |
|
|
for (const event of cachedThreads) { |
|
|
newThreadsMap.set(event.id, event); |
|
|
} |
|
|
|
|
|
if (newThreadsMap.size > 0) { |
|
|
threadsMap = newThreadsMap; |
|
|
loading = false; // Show cached data immediately |
|
|
} |
|
|
} |
|
|
} catch (error) { |
|
|
// Cache error (non-critical) |
|
|
} |
|
|
} |
|
|
|
|
|
async function loadAllData() { |
|
|
if (!isMounted || isLoading) return; // Don't load if unmounted or already loading |
|
|
|
|
|
// Only show loading spinner if we don't have cached threads |
|
|
const hasCachedThreads = threadsMap.size > 0; |
|
|
if (!hasCachedThreads) { |
|
|
loading = true; |
|
|
} |
|
|
isLoading = true; |
|
|
voteCountsReady = false; // Reset vote counts ready state |
|
|
try { |
|
|
const config = nostrClient.getConfig(); |
|
|
// Always fetch limit 100 without time filter - filtering happens in the view |
|
|
// No 'since' parameter - fetch all threads |
|
|
|
|
|
const threadRelays = relayManager.getThreadReadRelays(); |
|
|
// Use getProfileReadRelays() for reactions to include defaultRelays + profileRelays + user inbox + localRelays |
|
|
const reactionRelays = relayManager.getProfileReadRelays(); |
|
|
const commentRelays = relayManager.getCommentReadRelays(); |
|
|
|
|
|
// Load from cache first, then stream updates from relays (optimized for slow connections) |
|
|
// Always fetch limit 100, no time filtering |
|
|
const fetchPromise = nostrClient.fetchEvents( |
|
|
[{ kinds: [KIND.DISCUSSION_THREAD], limit: 100 }], |
|
|
threadRelays, |
|
|
{ |
|
|
useCache: 'cache-first', // Load from cache first, then stream updates from relays |
|
|
cacheResults: true, // Cache the results |
|
|
timeout: config.standardTimeout, |
|
|
onUpdate: async (updatedEvents) => { |
|
|
if (!isMounted) return; // Don't update if unmounted |
|
|
// Update incrementally as events arrive |
|
|
const newThreadsMap = new Map(threadsMap); |
|
|
let hasNewEvents = false; |
|
|
for (const event of updatedEvents) { |
|
|
const existing = newThreadsMap.get(event.id); |
|
|
if (!existing) { |
|
|
newThreadsMap.set(event.id, event); |
|
|
hasNewEvents = true; |
|
|
} else if (event.created_at > existing.created_at) { |
|
|
// Update existing event if this one is newer |
|
|
newThreadsMap.set(event.id, event); |
|
|
hasNewEvents = true; |
|
|
} |
|
|
} |
|
|
if (hasNewEvents && isMounted) { |
|
|
threadsMap = newThreadsMap; // Trigger reactivity |
|
|
} |
|
|
} |
|
|
} |
|
|
); |
|
|
activeFetchPromises.add(fetchPromise); |
|
|
const relayThreads = await fetchPromise; |
|
|
activeFetchPromises.delete(fetchPromise); |
|
|
|
|
|
if (!isMounted) return; // Don't process if unmounted |
|
|
|
|
|
// Merge with existing cached threads |
|
|
const newThreadsMap = new Map(threadsMap); |
|
|
for (const event of relayThreads) { |
|
|
// Update or add new threads |
|
|
const existing = newThreadsMap.get(event.id); |
|
|
if (!existing || event.created_at > existing.created_at) { |
|
|
newThreadsMap.set(event.id, event); |
|
|
} |
|
|
} |
|
|
threadsMap = newThreadsMap; |
|
|
loading = false; // Show data immediately |
|
|
|
|
|
// Get all thread IDs for loading stats (for sorting only - DiscussionCard loads its own for display) |
|
|
const threadIds = Array.from(threadsMap.keys()); |
|
|
|
|
|
if (threadIds.length > 0) { |
|
|
// Load reactions for sorting and display - optimized batch loading |
|
|
const allReactionsMap = new Map<string, NostrEvent>(); |
|
|
|
|
|
const processReactionUpdates = async () => { |
|
|
const allReactions = Array.from(allReactionsMap.values()); |
|
|
if (allReactions.length === 0) return; |
|
|
if (!isMounted) return; |
|
|
|
|
|
// Fetch deletion events for specific reaction IDs only (batch) |
|
|
const reactionIds = allReactions.map(r => r.id); |
|
|
// Limit to first 200 to avoid massive queries |
|
|
const limitedReactionIds = reactionIds.slice(0, 200); |
|
|
|
|
|
const deletionFetchPromise = nostrClient.fetchEvents( |
|
|
[{ kinds: [KIND.EVENT_DELETION], '#e': limitedReactionIds, limit: config.feedLimit }], |
|
|
reactionRelays, |
|
|
{ useCache: 'cache-first', cacheResults: true, timeout: config.shortTimeout, priority: 'low' } |
|
|
); |
|
|
activeFetchPromises.add(deletionFetchPromise); |
|
|
const deletionEvents = await deletionFetchPromise; |
|
|
activeFetchPromises.delete(deletionFetchPromise); |
|
|
|
|
|
if (!isMounted) return; |
|
|
|
|
|
// Build deleted reaction IDs set |
|
|
const deletedReactionIds = new Set<string>(); |
|
|
for (const deletionEvent of deletionEvents) { |
|
|
for (const tag of deletionEvent.tags) { |
|
|
if (tag[0] === 'e' && tag[1]) { |
|
|
deletedReactionIds.add(tag[1]); |
|
|
} |
|
|
} |
|
|
} |
|
|
|
|
|
// Rebuild reactions map (for sorting and display) |
|
|
const updatedReactionsMap = new Map<string, NostrEvent[]>(); |
|
|
for (const reaction of allReactions) { |
|
|
if (deletedReactionIds.has(reaction.id)) continue; |
|
|
|
|
|
const threadId = reaction.tags.find(t => { |
|
|
const tagName = t[0]; |
|
|
return (tagName === 'e' || tagName === 'E') && t[1]; |
|
|
})?.[1]; |
|
|
|
|
|
if (threadId && threadsMap.has(threadId)) { |
|
|
if (!updatedReactionsMap.has(threadId)) { |
|
|
updatedReactionsMap.set(threadId, []); |
|
|
} |
|
|
updatedReactionsMap.get(threadId)!.push(reaction); |
|
|
} |
|
|
} |
|
|
|
|
|
if (isMounted) { |
|
|
reactionsMap = updatedReactionsMap; |
|
|
updateVoteCountsMap(); |
|
|
voteCountsReady = true; |
|
|
} |
|
|
}; |
|
|
|
|
|
const handleReactionUpdate = async (updated: NostrEvent[]) => { |
|
|
if (!isMounted) return; |
|
|
for (const r of updated) { |
|
|
allReactionsMap.set(r.id, r); |
|
|
} |
|
|
await processReactionUpdates(); |
|
|
}; |
|
|
|
|
|
if (!isMounted) return; |
|
|
|
|
|
// Step 1: Load from cache first (instant) |
|
|
const cachedReactionsMap = await getCachedReactionsForEvents(threadIds); |
|
|
|
|
|
// Add cached reactions immediately |
|
|
for (const [eventId, reactions] of cachedReactionsMap) { |
|
|
for (const reaction of reactions) { |
|
|
allReactionsMap.set(reaction.id, reaction); |
|
|
} |
|
|
} |
|
|
|
|
|
// Process cached reactions immediately for instant display |
|
|
if (allReactionsMap.size > 0) { |
|
|
await processReactionUpdates(); |
|
|
} |
|
|
|
|
|
// Step 2: Fetch from relays in parallel (fast, non-blocking) |
|
|
// Use shorter timeout for faster updates |
|
|
const reactionsFetchPromise = nostrClient.fetchEvents( |
|
|
[ |
|
|
{ kinds: [KIND.REACTION], '#e': threadIds, limit: config.feedLimit }, |
|
|
{ kinds: [KIND.REACTION], '#E': threadIds, limit: config.feedLimit } |
|
|
], |
|
|
reactionRelays, |
|
|
{ |
|
|
useCache: 'cache-first', // Load from cache first, then update from relays |
|
|
cacheResults: true, |
|
|
timeout: config.shortTimeout, // Faster timeout for reactions |
|
|
onUpdate: handleReactionUpdate, |
|
|
priority: 'low' // Low priority - don't block other requests |
|
|
} |
|
|
); |
|
|
activeFetchPromises.add(reactionsFetchPromise); |
|
|
|
|
|
// Don't await - let it update in background |
|
|
reactionsFetchPromise.then((allReactions) => { |
|
|
if (!isMounted) return; |
|
|
|
|
|
// Add all new reactions to map (deduplication handled by Map) |
|
|
for (const r of allReactions) { |
|
|
allReactionsMap.set(r.id, r); |
|
|
} |
|
|
|
|
|
// Process and update counts |
|
|
processReactionUpdates(); |
|
|
}).catch(() => { |
|
|
// Silently fail - reactions are non-critical |
|
|
}).finally(() => { |
|
|
activeFetchPromises.delete(reactionsFetchPromise); |
|
|
}); |
|
|
|
|
|
// Fetch comments |
|
|
if (!isMounted) return; |
|
|
const commentsFetchPromise = nostrClient.fetchEvents( |
|
|
[{ kinds: [KIND.COMMENT], '#E': threadIds, '#K': ['11'], limit: config.feedLimit }], |
|
|
commentRelays, |
|
|
{ useCache: 'cache-first', cacheResults: true, timeout: config.standardTimeout, priority: 'low' } |
|
|
); |
|
|
|
|
|
// Track promise for cleanup |
|
|
activeFetchPromises.add(commentsFetchPromise); |
|
|
|
|
|
try { |
|
|
const allComments = await 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; |
|
|
} finally { |
|
|
// Clean up promise |
|
|
activeFetchPromises.delete(commentsFetchPromise); |
|
|
} |
|
|
} |
|
|
} catch (error) { |
|
|
// Failed to load threads |
|
|
threadsMap = new Map(); |
|
|
voteCountsReady = false; |
|
|
} finally { |
|
|
loading = false; |
|
|
isLoading = false; |
|
|
} |
|
|
} |
|
|
|
|
|
// 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[] { |
|
|
switch (sortType) { |
|
|
case 'newest': |
|
|
return [...events].sort((a, b) => b.created_at - a.created_at); |
|
|
case 'active': |
|
|
// Sort by most recent activity (reactions) |
|
|
const activeSorted = events.map((event) => { |
|
|
const reactions = reactionsMap.get(event.id) || []; |
|
|
|
|
|
const lastReactionTime = reactions.length > 0 |
|
|
? Math.max(...reactions.map(r => r.created_at)) |
|
|
: 0; |
|
|
|
|
|
const lastActivity = Math.max( |
|
|
event.created_at, |
|
|
lastReactionTime |
|
|
); |
|
|
|
|
|
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, topic, and filter result) - reactive derived value |
|
|
let filteredThreads = $derived.by(() => { |
|
|
let filtered = threads; |
|
|
|
|
|
// Filter by age first |
|
|
filtered = filterByAge(filtered); |
|
|
|
|
|
// Apply filter based on filterResult type |
|
|
if (filterResult.type === 'event' && filterResult.value) { |
|
|
// Filter by specific event ID |
|
|
filtered = filtered.filter(t => t.id === filterResult.value); |
|
|
} else if (filterResult.type === 'pubkey' && filterResult.value) { |
|
|
// Filter by pubkey (should already be normalized hex) |
|
|
const normalizedPubkey = filterResult.value.toLowerCase(); |
|
|
if (/^[a-f0-9]{64}$/i.test(normalizedPubkey)) { |
|
|
filtered = filtered.filter(t => t.pubkey.toLowerCase() === normalizedPubkey); |
|
|
} |
|
|
} else if (filterResult.type === 'text' && filterResult.value) { |
|
|
// Filter by text search (pubkey, p, q, and content fields) |
|
|
const queryLower = filterResult.value.toLowerCase(); |
|
|
filtered = filtered.filter(event => { |
|
|
// Search pubkey |
|
|
const pubkeyMatch = event.pubkey.toLowerCase().includes(queryLower); |
|
|
|
|
|
// Search p tags |
|
|
const pTagMatch = event.tags.some(tag => |
|
|
tag[0] === 'p' && tag[1]?.toLowerCase().includes(queryLower) |
|
|
); |
|
|
|
|
|
// Search q tags |
|
|
const qTagMatch = event.tags.some(tag => |
|
|
tag[0] === 'q' && tag.some((val, idx) => idx > 0 && val?.toLowerCase().includes(queryLower)) |
|
|
); |
|
|
|
|
|
// Search content |
|
|
const contentMatch = event.content.toLowerCase().includes(queryLower); |
|
|
|
|
|
return pubkeyMatch || pTagMatch || qTagMatch || contentMatch; |
|
|
}); |
|
|
} |
|
|
|
|
|
// 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 navigateToEvent(event: NostrEvent, e?: MouseEvent) { |
|
|
// Don't navigate 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; |
|
|
} |
|
|
} |
|
|
goto(`/event/${event.id}`); |
|
|
} |
|
|
|
|
|
onMount(() => { |
|
|
// Listen for custom event from EmbeddedEvent components |
|
|
const handleOpenEvent = (e: CustomEvent) => { |
|
|
if (e.detail?.event) { |
|
|
navigateToEvent(e.detail.event); |
|
|
} |
|
|
}; |
|
|
|
|
|
window.addEventListener('openEventInDrawer', handleOpenEvent as EventListener); |
|
|
|
|
|
return () => { |
|
|
window.removeEventListener('openEventInDrawer', handleOpenEvent as EventListener); |
|
|
}; |
|
|
}); |
|
|
</script> |
|
|
|
|
|
<div class="thread-list"> |
|
|
|
|
|
<!-- Filter by topic buttons --> |
|
|
<div class="mb-6"> |
|
|
<div class="flex flex-wrap gap-2 items-center"> |
|
|
<span class="font-semibold text-fog-text dark:text-fog-dark-text mr-2" style="font-size: 0.875em;">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} |
|
|
{@const voteCounts = voteCountsMap.get(thread.id) ?? { upvotes: 0, downvotes: 0 }} |
|
|
<div |
|
|
data-thread-id={thread.id} |
|
|
class="thread-wrapper" |
|
|
onclick={(e) => navigateToEvent(thread, e)} |
|
|
role="button" |
|
|
tabindex="0" |
|
|
onkeydown={(e) => { |
|
|
if (e.key === 'Enter' || e.key === ' ') { |
|
|
e.preventDefault(); |
|
|
navigateToEvent(thread); |
|
|
} |
|
|
}} |
|
|
> |
|
|
<DiscussionCard |
|
|
thread={thread} |
|
|
commentCount={commentsMap.get(thread.id) ?? 0} |
|
|
upvotes={voteCounts.upvotes} |
|
|
downvotes={voteCounts.downvotes} |
|
|
votesCalculated={voteCountsReady} |
|
|
preloadedReactions={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> |
|
|
|
|
|
<style> |
|
|
.thread-list { |
|
|
max-width: var(--content-width); |
|
|
margin: 0 auto; |
|
|
padding: 1rem; |
|
|
} |
|
|
|
|
|
@media (max-width: 768px) { |
|
|
.thread-list { |
|
|
max-width: 100%; |
|
|
padding: 0.5rem; |
|
|
} |
|
|
} |
|
|
|
|
|
.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> |