Browse Source

add more functionality

master
Silberengel 1 month ago
parent
commit
43d4d28ac3
  1. 67
      src/lib/modules/feed/Kind1FeedPage.svelte
  2. 52
      src/lib/modules/threads/CreateThreadForm.svelte
  3. 56
      src/lib/modules/threads/ThreadCard.svelte
  4. 61
      src/lib/modules/threads/ThreadList.svelte
  5. 39
      src/routes/+page.svelte

67
src/lib/modules/feed/Kind1FeedPage.svelte

@ -9,21 +9,42 @@
let loading = $state(true); let loading = $state(true);
let replyingTo = $state<NostrEvent | null>(null); let replyingTo = $state<NostrEvent | null>(null);
let showNewPostForm = $state(false); let showNewPostForm = $state(false);
let hasMore = $state(true);
let loadingMore = $state(false);
onMount(async () => { onMount(async () => {
await nostrClient.initialize(); await nostrClient.initialize();
loadFeed(); loadFeed();
// Set up infinite scroll
window.addEventListener('scroll', handleScroll);
return () => {
window.removeEventListener('scroll', handleScroll);
};
}); });
async function loadFeed() { async function loadFeed(reset = true) {
loading = true; if (reset) {
loading = true;
posts = [];
hasMore = true;
} else {
loadingMore = true;
}
try { try {
const config = nostrClient.getConfig(); const config = nostrClient.getConfig();
const oldestTimestamp = posts.length > 0
? Math.min(...posts.map(p => p.created_at))
: undefined;
const filters = [ const filters = [
{ {
kinds: [1], kinds: [1],
limit: 50 limit: 50,
...(oldestTimestamp ? { until: oldestTimestamp } : {})
} }
]; ];
@ -31,15 +52,45 @@
filters, filters,
[...config.defaultRelays], [...config.defaultRelays],
{ useCache: true, cacheResults: true, onUpdate: (updated) => { { useCache: true, cacheResults: true, onUpdate: (updated) => {
posts = sortPosts(updated); if (reset) {
posts = sortPosts(updated);
} else {
// Merge new posts
const existingIds = new Set(posts.map(p => p.id));
const newPosts = updated.filter(e => !existingIds.has(e.id));
posts = sortPosts([...posts, ...newPosts]);
}
}} }}
); );
posts = sortPosts(events); if (reset) {
posts = sortPosts(events);
} else {
// Merge new posts
const existingIds = new Set(posts.map(p => p.id));
const newPosts = events.filter(e => !existingIds.has(e.id));
posts = sortPosts([...posts, ...newPosts]);
}
hasMore = events.length >= 50;
} catch (error) { } catch (error) {
console.error('Error loading feed:', error); console.error('Error loading feed:', error);
} finally { } finally {
loading = false; loading = false;
loadingMore = false;
}
}
function handleScroll() {
if (loadingMore || !hasMore) return;
const scrollTop = window.scrollY || document.documentElement.scrollTop;
const windowHeight = window.innerHeight;
const documentHeight = document.documentElement.scrollHeight;
// Load more when within 200px of bottom
if (scrollTop + windowHeight >= documentHeight - 200) {
loadFeed(false);
} }
} }
@ -94,6 +145,12 @@
<Kind1Post {post} onReply={handleReply} /> <Kind1Post {post} onReply={handleReply} />
{/each} {/each}
</div> </div>
{#if loadingMore}
<p class="text-center text-fog-text-light dark:text-fog-dark-text-light mt-4">Loading more...</p>
{/if}
{#if !hasMore && posts.length > 0}
<p class="text-center text-fog-text-light dark:text-fog-dark-text-light mt-4">No more posts</p>
{/if}
{/if} {/if}
</div> </div>

52
src/lib/modules/threads/CreateThreadForm.svelte

@ -1,6 +1,7 @@
<script lang="ts"> <script lang="ts">
import { sessionManager } from '../../services/auth/session-manager.js'; import { sessionManager } from '../../services/auth/session-manager.js';
import { nostrClient } from '../../services/nostr/nostr-client.js'; import { nostrClient } from '../../services/nostr/nostr-client.js';
import PublicationStatusModal from '../../components/modals/PublicationStatusModal.svelte';
import type { NostrEvent } from '../../types/nostr.js'; import type { NostrEvent } from '../../types/nostr.js';
let title = $state(''); let title = $state('');
@ -9,6 +10,16 @@
let topicInput = $state(''); let topicInput = $state('');
let includeClientTag = $state(true); let includeClientTag = $state(true);
let publishing = $state(false); let publishing = $state(false);
let showPublicationModal = $state(false);
let publicationResults = $state<{ success: string[]; failed: Array<{ relay: string; error: string }> } | null>(null);
let selectedRelays = $state<Set<string>>(new Set());
$effect(() => {
// Initialize selected relays with default + thecitadel
const config = nostrClient.getConfig();
const defaultRelays = [...config.defaultRelays, 'wss://thecitadel.nostr1.com'];
selectedRelays = new Set(defaultRelays);
});
function addTopic() { function addTopic() {
if (topicInput.trim() && topics.length < 3) { if (topicInput.trim() && topics.length < 3) {
@ -50,19 +61,19 @@
}; };
const signed = await sessionManager.signEvent(event); const signed = await sessionManager.signEvent(event);
const config = nostrClient.getConfig();
const result = await nostrClient.publish(signed, { const result = await nostrClient.publish(signed, {
relays: [...config.defaultRelays, 'wss://thecitadel.nostr1.com'] relays: Array.from(selectedRelays)
}); });
// Show publication status modal
publicationResults = result;
showPublicationModal = true;
if (result.success.length > 0) { if (result.success.length > 0) {
alert(`Thread published to ${result.success.length} relay(s)`); // Reset form on success
// Reset form
title = ''; title = '';
content = ''; content = '';
topics = []; topics = [];
} else {
alert('Failed to publish thread');
} }
} catch (error) { } catch (error) {
console.error('Error publishing thread:', error); console.error('Error publishing thread:', error);
@ -128,11 +139,38 @@
</label> </label>
</div> </div>
<button type="submit" disabled={publishing} class="px-4 py-2 bg-fog-accent dark:bg-fog-dark-accent hover:bg-fog-text dark:hover:bg-fog-dark-text text-white disabled:opacity-50 transition-colors rounded"> <div class="mb-4">
<label class="block mb-2 text-fog-text dark:text-fog-dark-text">Target Relays</label>
<div class="border border-fog-border dark:border-fog-dark-border rounded p-3 bg-fog-post dark:bg-fog-dark-post max-h-48 overflow-y-auto">
{#each Array.from(selectedRelays) as relay}
<label class="flex items-center gap-2 mb-2">
<input
type="checkbox"
checked={true}
onchange={(e) => {
if (!e.currentTarget.checked) {
const newSet = new Set(selectedRelays);
newSet.delete(relay);
selectedRelays = newSet;
}
}}
/>
<span class="text-sm text-fog-text dark:text-fog-dark-text">{relay}</span>
</label>
{/each}
{#if selectedRelays.size === 0}
<p class="text-sm text-fog-text-light dark:text-fog-dark-text-light">No relays selected. At least one relay is required.</p>
{/if}
</div>
</div>
<button type="submit" disabled={publishing || selectedRelays.size === 0} class="px-4 py-2 bg-fog-accent dark:bg-fog-dark-accent hover:bg-fog-text dark:hover:bg-fog-dark-text text-white disabled:opacity-50 transition-colors rounded">
{publishing ? 'Publishing...' : 'Create Thread'} {publishing ? 'Publishing...' : 'Create Thread'}
</button> </button>
</form> </form>
<PublicationStatusModal bind:open={showPublicationModal} bind:results={publicationResults} />
<style> <style>
.create-thread-form { .create-thread-form {
max-width: var(--content-width); max-width: var(--content-width);

56
src/lib/modules/threads/ThreadCard.svelte

@ -1,5 +1,7 @@
<script lang="ts"> <script lang="ts">
import ProfileBadge from '../../components/layout/ProfileBadge.svelte'; import ProfileBadge from '../../components/layout/ProfileBadge.svelte';
import { nostrClient } from '../../services/nostr/nostr-client.js';
import { onMount } from 'svelte';
import type { NostrEvent } from '../../types/nostr.js'; import type { NostrEvent } from '../../types/nostr.js';
interface Props { interface Props {
@ -8,6 +10,51 @@
let { thread }: Props = $props(); let { thread }: Props = $props();
let upvotes = $state(0);
let downvotes = $state(0);
let commentCount = $state(0);
let loadingStats = $state(true);
onMount(async () => {
await loadStats();
});
async function loadStats() {
loadingStats = true;
try {
const config = nostrClient.getConfig();
// Load reactions (kind 7)
const reactionEvents = await nostrClient.fetchEvents(
[{ kinds: [7], '#e': [thread.id] }],
[...config.defaultRelays],
{ useCache: true }
);
// Count upvotes (+) and downvotes (-)
for (const reaction of reactionEvents) {
const content = reaction.content.trim();
if (content === '+' || content === '⬆' || content === '↑') {
upvotes++;
} else if (content === '-' || content === '⬇' || content === '↓') {
downvotes++;
}
}
// Load comments (kind 1111)
const commentEvents = await nostrClient.fetchEvents(
[{ kinds: [1111], '#E': [thread.id], '#K': ['11'] }],
[...config.defaultRelays],
{ useCache: true }
);
commentCount = commentEvents.length;
} catch (error) {
console.error('Error loading thread stats:', error);
} finally {
loadingStats = false;
}
}
function getTitle(): string { function getTitle(): string {
const titleTag = thread.tags.find((t) => t[0] === 'title'); const titleTag = thread.tags.find((t) => t[0] === 'title');
return titleTag?.[1] || 'Untitled'; return titleTag?.[1] || 'Untitled';
@ -65,7 +112,14 @@
</div> </div>
{/if} {/if}
<div class="text-xs text-fog-text-light dark:text-fog-dark-text-light"> <div class="flex items-center justify-between text-xs text-fog-text-light dark:text-fog-dark-text-light">
<div class="flex items-center gap-4">
{#if !loadingStats}
<span>{upvotes}</span>
<span>{downvotes}</span>
<span>{commentCount} {commentCount === 1 ? 'comment' : 'comments'}</span>
{/if}
</div>
<a href="/thread/{thread.id}">View thread →</a> <a href="/thread/{thread.id}">View thread →</a>
</div> </div>
</article> </article>

61
src/lib/modules/threads/ThreadList.svelte

@ -30,15 +30,19 @@
{ {
useCache: true, useCache: true,
cacheResults: true, cacheResults: true,
onUpdate: (updatedEvents) => { onUpdate: async (updatedEvents) => {
// Update threads when fresh data arrives from relays // Update threads when fresh data arrives from relays
threads = sortThreads(updatedEvents); threads = await sortThreads(updatedEvents);
} }
} }
); );
// Set initial cached data immediately // Set initial cached data immediately
threads = sortThreads(events); if (sortBy === 'newest') {
threads = sortThreadsSync(events);
} else {
threads = await sortThreads(events);
}
} catch (error) { } catch (error) {
console.error('Error loading threads:', error); console.error('Error loading threads:', error);
threads = []; // Set empty array on error to prevent undefined issues threads = []; // Set empty array on error to prevent undefined issues
@ -47,16 +51,53 @@
} }
} }
function sortThreads(events: NostrEvent[]): NostrEvent[] { 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) { switch (sortBy) {
case 'newest': case 'newest':
return [...events].sort((a, b) => b.created_at - a.created_at); return sortThreadsSync(events);
case 'active': case 'active':
// Placeholder - would need to count comments // Sort by most recent comment activity
return [...events].sort((a, b) => b.created_at - a.created_at); 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': case 'upvoted':
// Placeholder - would need to count reactions // Sort by upvote count
return [...events].sort((a, b) => b.created_at - a.created_at); 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: default:
return events; return events;
} }
@ -156,7 +197,7 @@
/> />
Show older threads Show older threads
</label> </label>
<select bind:value={sortBy} onchange={() => (threads = 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"> <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="newest">Newest</option>
<option value="active">Most Active</option> <option value="active">Most Active</option>
<option value="upvoted">Most Upvoted</option> <option value="upvoted">Most Upvoted</option>

39
src/routes/+page.svelte

@ -2,11 +2,21 @@
import Header from '../lib/components/layout/Header.svelte'; import Header from '../lib/components/layout/Header.svelte';
import ThreadList from '../lib/modules/threads/ThreadList.svelte'; import ThreadList from '../lib/modules/threads/ThreadList.svelte';
import CreateThreadForm from '../lib/modules/threads/CreateThreadForm.svelte'; import CreateThreadForm from '../lib/modules/threads/CreateThreadForm.svelte';
import { sessionManager } from '../lib/services/auth/session-manager.js'; import { sessionManager, type UserSession } from '../lib/services/auth/session-manager.js';
import { nostrClient } from '../lib/services/nostr/nostr-client.js'; import { nostrClient } from '../lib/services/nostr/nostr-client.js';
import { onMount } from 'svelte'; import { onMount } from 'svelte';
let showCreateForm = $state(false); let showCreateForm = $state(false);
let currentSession = $state<UserSession | null>(sessionManager.session.value);
let isLoggedIn = $derived(currentSession !== null);
// Subscribe to session changes
$effect(() => {
const unsubscribe = sessionManager.session.subscribe((session: UserSession | null) => {
currentSession = session;
});
return unsubscribe;
});
onMount(async () => { onMount(async () => {
await nostrClient.initialize(); await nostrClient.initialize();
@ -16,22 +26,27 @@
<Header /> <Header />
<main class="container mx-auto px-4 py-8"> <main class="container mx-auto px-4 py-8">
<div class="mb-4"> <div class="threads-header mb-4">
<h1 class="text-2xl font-bold mb-4 text-fog-text dark:text-fog-dark-text">Threads</h1> <div>
<p class="mb-4 text-fog-text dark:text-fog-dark-text">Decentralized discussion board on Nostr. Brought to you by <a href="/profile/fd208ee8c8f283780a9552896e4823cc9dc6bfd442063889577106940fd927c1" class="text-fog-accent dark:text-fog-dark-accent hover:text-fog-text dark:hover:text-fog-dark-text underline transition-colors">Silberengel</a>.</p> <h1 class="text-2xl font-bold mb-4 text-fog-text dark:text-fog-dark-text">Threads</h1>
{#if sessionManager.isLoggedIn()} <p class="mb-4 text-fog-text dark:text-fog-dark-text">Decentralized discussion board on Nostr. Brought to you by <a href="/profile/fd208ee8c8f283780a9552896e4823cc9dc6bfd442063889577106940fd927c1" class="text-fog-accent dark:text-fog-dark-accent hover:text-fog-text dark:hover:text-fog-dark-text underline transition-colors">Silberengel</a>.</p>
</div>
{#if isLoggedIn}
<button <button
onclick={() => (showCreateForm = !showCreateForm)} onclick={() => (showCreateForm = !showCreateForm)}
class="mb-4 px-4 py-2 bg-fog-accent dark:bg-fog-dark-accent hover:bg-fog-text dark:hover:bg-fog-dark-text text-white transition-colors rounded" class="px-4 py-2 bg-fog-accent dark:bg-fog-dark-accent text-white rounded hover:opacity-90"
> >
{showCreateForm ? 'Cancel' : 'Create Thread'} {showCreateForm ? 'Cancel' : 'Create Thread'}
</button> </button>
{#if showCreateForm}
<CreateThreadForm />
{/if}
{/if} {/if}
</div> </div>
{#if showCreateForm}
<div class="create-thread-form mb-4">
<CreateThreadForm />
</div>
{/if}
<ThreadList /> <ThreadList />
</main> </main>
@ -40,4 +55,10 @@
max-width: var(--content-width); max-width: var(--content-width);
margin: 0 auto; margin: 0 auto;
} }
.threads-header {
display: flex;
justify-content: space-between;
align-items: flex-start;
}
</style> </style>

Loading…
Cancel
Save