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.
 
 
 
 
 

695 lines
25 KiB

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