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