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.
 
 
 
 
 

272 lines
9.8 KiB

<script lang="ts">
import { nostrClient } from '../../services/nostr/nostr-client.js';
import { onMount } from 'svelte';
import ThreadCard from './ThreadCard.svelte';
import type { NostrEvent } from '../../types/nostr.js';
let threads = $state<NostrEvent[]>([]);
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
$effect(() => {
loadThreads();
});
async function loadThreads() {
loading = true;
try {
const config = nostrClient.getConfig();
const since = showOlder
? undefined
: Math.floor(Date.now() / 1000) - config.threadTimeoutDays * 86400;
// Fetch with cache-first, background refresh
// onUpdate callback will refresh the UI when new data arrives
const events = await nostrClient.fetchEvents(
[{ kinds: [11], since, limit: 50 }],
[...config.defaultRelays],
{
useCache: true,
cacheResults: true,
onUpdate: async (updatedEvents) => {
// Update threads when fresh data arrives from relays
threads = await sortThreads(updatedEvents);
}
}
);
// Set initial cached data immediately
if (sortBy === 'newest') {
threads = sortThreadsSync(events);
} else {
threads = await sortThreads(events);
}
} catch (error) {
console.error('Error loading threads:', error);
threads = []; // Set empty array on error to prevent undefined issues
} finally {
loading = false;
}
}
function sortThreadsSync(events: NostrEvent[]): NostrEvent[] {
// Synchronous version for 'newest' sorting
return [...events].sort((a, b) => b.created_at - a.created_at);
}
async function sortThreads(events: NostrEvent[]): Promise<NostrEvent[]> {
switch (sortBy) {
case 'newest':
return sortThreadsSync(events);
case 'active':
// Sort by most recent comment activity
const activeSorted = await Promise.all(
events.map(async (event) => {
const config = nostrClient.getConfig();
const comments = await nostrClient.fetchEvents(
[{ kinds: [1111], '#E': [event.id], '#K': ['11'], limit: 1 }],
[...config.defaultRelays],
{ useCache: true }
);
const lastCommentTime = comments.length > 0
? comments.sort((a, b) => b.created_at - a.created_at)[0].created_at
: event.created_at;
return { event, lastActivity: lastCommentTime };
})
);
return activeSorted
.sort((a, b) => b.lastActivity - a.lastActivity)
.map(({ event }) => event);
case 'upvoted':
// Sort by upvote count
const upvotedSorted = await Promise.all(
events.map(async (event) => {
const config = nostrClient.getConfig();
const reactions = await nostrClient.fetchEvents(
[{ kinds: [7], '#e': [event.id] }],
[...config.defaultRelays],
{ useCache: true }
);
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;
}
}
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;
}
/**
* 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);
}
function getFilteredThreads(): NostrEvent[] {
let filtered = threads;
// Filter by age first
filtered = filterByAge(filtered);
// Then filter by topic
// selectedTopic === null means "All" - show all threads (handled in template)
// 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 getThreadsByTopic(topic: string | null): NostrEvent[] {
let filtered = threads;
// Filter by age first
filtered = filterByAge(filtered);
// Then filter by topic
if (topic === null) {
return filtered.filter((t) => !t.tags.some((tag) => tag[0] === 't'));
}
return filtered.filter((t) => t.tags.some((tag) => tag[0] === 't' && tag[1] === topic));
}
</script>
<div class="thread-list">
<div class="controls mb-4 flex gap-4 items-center flex-wrap">
<label class="text-fog-text dark:text-fog-dark-text">
<input
type="checkbox"
bind:checked={showOlder}
onchange={() => {
// If showing older threads, reload to fetch them
// If hiding older threads, just filter client-side (no reload needed)
if (showOlder) {
loadThreads();
}
}}
class="mr-2"
/>
Show older threads
</label>
<select bind:value={sortBy} onchange={async () => { threads = await sortThreads(threads); }} 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>
</div>
{#if loading}
<p class="text-fog-text dark:text-fog-dark-text">Loading threads...</p>
{:else}
<!-- Topic Filter -->
<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>
<!-- Threads Display -->
<div>
{#if selectedTopic === null}
<!-- Show all threads grouped by topic -->
<h2 class="text-xl font-bold mb-4 text-fog-text dark:text-fog-dark-text">General</h2>
{#each getThreadsByTopic(null) as thread}
<ThreadCard {thread} />
{/each}
{#each getTopics() as topic}
<h2 class="text-xl font-bold mb-4 mt-8 text-fog-text dark:text-fog-dark-text">{topic}</h2>
{#each getThreadsByTopic(topic) as thread}
<ThreadCard {thread} />
{/each}
{/each}
{:else}
<!-- Show filtered threads -->
<h2 class="text-xl font-bold mb-4 text-fog-text dark:text-fog-dark-text">
{selectedTopic === undefined ? 'General' : selectedTopic}
</h2>
{#each getFilteredThreads() as thread}
<ThreadCard {thread} />
{/each}
{#if getFilteredThreads().length === 0}
<p class="text-fog-text-light dark:text-fog-dark-text-light">No threads found in this topic.</p>
{/if}
{/if}
</div>
{/if}
</div>
<style>
.thread-list {
max-width: var(--content-width);
margin: 0 auto;
padding: 1rem;
}
</style>