Browse Source

fix reaction counts on /discussions

increase paragraph spacing
correct styling
master
Silberengel 1 month ago
parent
commit
dde3902573
  1. 60
      src/app.css
  2. 2
      src/lib/components/content/EmbeddedEvent.svelte
  3. 18
      src/lib/components/content/MarkdownRenderer.svelte
  4. 127
      src/lib/components/content/VoteCount.svelte
  5. 2
      src/lib/components/layout/Header.svelte
  6. 51
      src/lib/modules/comments/Comment.svelte
  7. 150
      src/lib/modules/discussions/DiscussionCard.svelte
  8. 191
      src/lib/modules/discussions/DiscussionList.svelte
  9. 143
      src/lib/modules/discussions/DiscussionView.svelte
  10. 450
      src/lib/modules/discussions/DiscussionVoteButtons.svelte
  11. 165
      src/lib/modules/events/EventView.svelte
  12. 137
      src/lib/modules/feed/FeedPage.svelte
  13. 114
      src/lib/modules/feed/FeedPost.svelte
  14. 210
      src/lib/modules/reactions/FeedReactionButtons.svelte
  15. 257
      src/lib/modules/reactions/ReactionButtons.svelte
  16. 4
      src/lib/types/kind-lookup.ts
  17. 84
      src/routes/+page.svelte
  18. 33
      src/routes/cache/+page.svelte
  19. 85
      src/routes/discussions/+page.svelte
  20. 23
      src/routes/event/[id]/+page.svelte

60
src/app.css

@ -70,6 +70,66 @@ body { @@ -70,6 +70,66 @@ body {
font-family: 'SF Mono', 'Monaco', 'Inconsolata', 'Fira Code', 'Droid Sans Mono', 'Source Code Pro', monospace;
}
/* Responsive font sizing based on screen size */
@media (max-width: 640px) {
:root {
--text-size: clamp(14px, 4vw, 16px);
}
}
@media (min-width: 641px) and (max-width: 1024px) {
:root {
--text-size: clamp(15px, 1.5vw, 18px);
}
}
@media (min-width: 1025px) {
:root {
--text-size: clamp(16px, 1.2vw, 20px);
}
}
/* Paragraph spacing - increased for better readability */
p {
margin-bottom: 1.25em;
margin-top: 0;
}
/* Markdown content paragraph spacing */
:global(.markdown-content) p {
margin-bottom: 1.25em;
margin-top: 0;
}
/* Consistent heading sizes relative to base font size */
h1 {
font-size: clamp(1.5rem, 4vw, 2rem);
line-height: 1.2;
margin-bottom: 1rem;
margin-top: 0;
}
h2 {
font-size: clamp(1.25rem, 3vw, 1.5rem);
line-height: 1.3;
margin-bottom: 0.875rem;
margin-top: 0;
}
h3 {
font-size: clamp(1.125rem, 2.5vw, 1.25rem);
line-height: 1.4;
margin-bottom: 0.75rem;
margin-top: 0;
}
h4, h5, h6 {
font-size: clamp(1rem, 2vw, 1.125rem);
line-height: 1.4;
margin-bottom: 0.625rem;
margin-top: 0;
}
/* Apply monospace font to all elements globally */
* {
font-family: inherit;

2
src/lib/components/content/EmbeddedEvent.svelte

@ -199,7 +199,7 @@ @@ -199,7 +199,7 @@
if (!event) return;
// Dispatch custom event on window to open in side-panel
// Parent components (like FeedPage, ThreadList) listen for this event on window
// Parent components (like FeedPage, DiscussionList) listen for this event on window
const openEvent = new CustomEvent('openEventInDrawer', {
detail: { event },
bubbles: true,

18
src/lib/components/content/MarkdownRenderer.svelte

@ -846,6 +846,24 @@ @@ -846,6 +846,24 @@
overflow-wrap: break-word;
}
/* Increased paragraph spacing for better readability */
:global(.markdown-content p) {
margin-bottom: 1.25em;
margin-top: 0;
}
/* Ensure consistent spacing between paragraphs and other elements */
:global(.markdown-content p + p) {
margin-top: 0;
}
:global(.markdown-content p + ul),
:global(.markdown-content p + ol),
:global(.markdown-content p + blockquote),
:global(.markdown-content p + pre) {
margin-top: 1em;
}
:global(.markdown-content img) {
max-width: 100%;
height: auto;

127
src/lib/components/content/VoteCount.svelte

@ -0,0 +1,127 @@ @@ -0,0 +1,127 @@
<script lang="ts">
interface Props {
upvotes: number;
downvotes: number;
votesCalculated?: boolean; // If false, show "Calculating votes" state
size?: 'xs' | 'sm' | 'base'; // Size variant
userVote?: '+' | '-' | null; // User's current vote
onVote?: (vote: '+' | '-') => void; // Callback when user clicks a vote
isLoggedIn?: boolean; // Whether user is logged in
}
let {
upvotes = 0,
downvotes = 0,
votesCalculated = true,
size = 'xs',
userVote = null,
onVote,
isLoggedIn = false
}: Props = $props();
// Create size class dynamically
let sizeClass = $derived(`text-${size}`);
function handleVoteClick(vote: '+' | '-') {
if (!isLoggedIn || !onVote) return;
onVote(vote);
}
</script>
<span class="vote-counts flex items-center gap-2 {sizeClass}" class:calculating={!votesCalculated}>
{#if !votesCalculated}
<span class="flex items-center gap-1 text-fog-text-light dark:text-fog-dark-text-light opacity-50">
<span class="upvotes"> 0</span>
<span class="downvotes"> 0</span>
<span class="calculating-label text-xs ml-1">Calculating votes</span>
</span>
{:else}
<span class="flex items-center gap-2 text-fog-text-light dark:text-fog-dark-text-light">
<button
type="button"
onclick={() => handleVoteClick('+')}
disabled={!isLoggedIn || !onVote}
class="vote-emoji upvotes {userVote === '+' ? 'active' : ''} {!isLoggedIn || !onVote ? 'disabled' : ''}"
class:has-votes={upvotes > 0}
title={isLoggedIn && onVote ? "Upvote" : !isLoggedIn ? "Login to vote" : ""}
aria-label="Upvote"
>
{upvotes}
</button>
<button
type="button"
onclick={() => handleVoteClick('-')}
disabled={!isLoggedIn || !onVote}
class="vote-emoji downvotes {userVote === '-' ? 'active' : ''} {!isLoggedIn || !onVote ? 'disabled' : ''}"
class:has-votes={downvotes > 0}
title={isLoggedIn && onVote ? "Downvote" : !isLoggedIn ? "Login to vote" : ""}
aria-label="Downvote"
>
{downvotes}
</button>
</span>
{/if}
</span>
<style>
.vote-counts {
display: inline-flex;
align-items: center;
}
.vote-counts.calculating {
opacity: 0.6;
}
.vote-emoji {
background: transparent;
border: none;
padding: 0.25rem 0.5rem;
cursor: pointer;
transition: all 0.2s ease;
font-size: inherit;
color: inherit;
display: inline-flex;
align-items: center;
gap: 0.25rem;
border-radius: 0.25rem;
filter: grayscale(100%);
opacity: 0.7;
}
.vote-emoji.disabled {
cursor: not-allowed;
opacity: 0.5;
}
.vote-emoji:not(.disabled):hover {
background: var(--fog-highlight, #f3f4f6);
opacity: 0.9;
}
:global(.dark) .vote-emoji:not(.disabled):hover {
background: var(--fog-dark-highlight, #374151);
}
.vote-emoji.has-votes {
font-weight: 700;
opacity: 1;
}
.vote-emoji.active {
filter: none;
opacity: 1;
background: var(--fog-accent, #64748b);
color: var(--fog-text, #ffffff);
}
:global(.dark) .vote-emoji.active {
background: var(--fog-dark-accent, #64748b);
color: var(--fog-dark-text, #ffffff);
}
.vote-counts .calculating-label {
font-style: italic;
opacity: 0.7;
}
</style>

2
src/lib/components/layout/Header.svelte

@ -37,7 +37,7 @@ @@ -37,7 +37,7 @@
<div class="flex flex-wrap items-center justify-between gap-2 max-w-7xl mx-auto">
<div class="flex flex-wrap gap-2 sm:gap-4 items-center text-sm font-mono">
<a href="/" class="text-xl font-semibold text-fog-text dark:text-fog-dark-text hover:text-fog-text-light dark:hover:text-fog-dark-text-light transition-colors">aitherboard</a>
<a href="/" class="text-fog-text dark:text-fog-dark-text hover:text-fog-accent dark:hover:text-fog-dark-accent transition-colors">/Discussions</a>
<a href="/discussions" class="text-fog-text dark:text-fog-dark-text hover:text-fog-accent dark:hover:text-fog-dark-accent transition-colors">/Discussions</a>
<a href="/feed" class="text-fog-text dark:text-fog-dark-text hover:text-fog-accent dark:hover:text-fog-dark-accent transition-colors">/Feed</a>
{#if isLoggedIn}
<a href="/write" class="text-fog-text dark:text-fog-dark-text hover:text-fog-accent dark:hover:text-fog-dark-accent transition-colors">/Write</a>

51
src/lib/modules/comments/Comment.svelte

@ -3,21 +3,28 @@ @@ -3,21 +3,28 @@
import MarkdownRenderer from '../../components/content/MarkdownRenderer.svelte';
import ReplyContext from '../../components/content/ReplyContext.svelte';
import FeedReactionButtons from '../reactions/FeedReactionButtons.svelte';
import DiscussionVoteButtons from '../discussions/DiscussionVoteButtons.svelte';
import EventMenu from '../../components/EventMenu.svelte';
import { nostrClient } from '../../services/nostr/nostr-client.js';
import { relayManager } from '../../services/nostr/relay-manager.js';
import { onMount } from 'svelte';
import type { NostrEvent } from '../../types/nostr.js';
import { getKindInfo } from '../../types/kind-lookup.js';
import { getKindInfo, KIND } from '../../types/kind-lookup.js';
interface Props {
comment: NostrEvent;
parentEvent?: NostrEvent;
onReply?: (comment: NostrEvent) => void;
rootEventKind?: number; // The kind of the root event (e.g., 11 for threads)
reactions?: NostrEvent[]; // Optional pre-loaded reactions (for performance)
}
let { comment, parentEvent, onReply, rootEventKind }: Props = $props();
let { comment, parentEvent, onReply, rootEventKind, reactions: providedReactions }: Props = $props();
let expanded = $state(false);
let contentElement: HTMLElement | null = $state(null);
let needsExpansion = $state(false);
// DiscussionVoteButtons handles all vote counting internally
function getRelativeTime(): string {
const now = Math.floor(Date.now() / 1000);
@ -94,17 +101,6 @@ @@ -94,17 +101,6 @@
<div class="comment-content mb-2">
<MarkdownRenderer content={comment.content} event={comment} />
</div>
<div class="comment-actions flex gap-2 items-center">
<!-- Always show reaction buttons, but restrict to upvote/downvote only for replies to kind 11 -->
<FeedReactionButtons event={comment} forceUpvoteDownvote={rootEventKind === 11} />
<button
onclick={handleReply}
class="text-xs text-fog-accent dark:text-fog-dark-accent hover:underline"
>
Reply
</button>
</div>
</div>
{#if needsExpansion}
@ -115,6 +111,22 @@ @@ -115,6 +111,22 @@
{expanded ? 'Show less' : 'Show more'}
</button>
{/if}
<!-- Comment actions (vote buttons, reply) - always visible, outside collapsible content -->
<div class="comment-actions flex gap-2 items-center">
{#if rootEventKind === KIND.DISCUSSION_THREAD}
<!-- DiscussionVoteButtons includes both vote counts and buttons -->
<DiscussionVoteButtons event={comment} />
{:else}
<FeedReactionButtons event={comment} />
{/if}
<button
onclick={handleReply}
class="text-xs text-fog-accent dark:text-fog-dark-accent hover:underline"
>
Reply
</button>
</div>
<div class="kind-badge">
<span class="kind-number">{getKindInfo(comment.kind).number}</span>
@ -144,12 +156,25 @@ @@ -144,12 +156,25 @@
.comment-actions {
padding-right: 6rem; /* Reserve space for kind badge */
padding-top: 0.5rem;
border-top: 1px solid var(--fog-border, #e5e7eb);
margin-top: 0.5rem;
/* Ensure footer is always visible, even when content is collapsed */
position: relative;
z-index: 1;
overflow: visible;
}
:global(.dark) .comment-actions {
border-top-color: var(--fog-dark-border, #374151);
}
.card-content {
max-height: 500px;
overflow: hidden;
transition: max-height 0.3s ease;
/* Ensure footer below is not affected by overflow */
position: relative;
}
.card-content.expanded {

150
src/lib/modules/threads/ThreadCard.svelte → src/lib/modules/discussions/DiscussionCard.svelte

@ -1,6 +1,6 @@ @@ -1,6 +1,6 @@
<script lang="ts">
import ProfileBadge from '../../components/layout/ProfileBadge.svelte';
import EventMenu from '../../components/EventMenu.svelte';
import VoteCount from '../../components/content/VoteCount.svelte';
import { nostrClient } from '../../services/nostr/nostr-client.js';
import { relayManager } from '../../services/nostr/relay-manager.js';
import { onMount } from 'svelte';
@ -10,13 +10,20 @@ @@ -10,13 +10,20 @@
interface Props {
thread: NostrEvent;
commentCount?: number; // Pre-loaded comment count from batch fetch
upvotes?: number; // Pre-calculated upvote count from batch fetch
downvotes?: number; // Pre-calculated downvote count from batch fetch
votesCalculated?: boolean; // Whether vote counts are ready to display
}
let { thread }: Props = $props();
let { thread, commentCount: providedCommentCount = 0, upvotes: providedUpvotes = 0, downvotes: providedDownvotes = 0, votesCalculated: providedVotesCalculated = false }: Props = $props();
let upvotes = $state(0);
let downvotes = $state(0);
let commentCount = $state(0);
// Update comment count when provided value changes
$effect(() => {
commentCount = providedCommentCount;
});
let zapTotal = $state(0);
let zapCount = $state(0);
let latestResponseTime = $state<number | null>(null);
@ -71,33 +78,21 @@ @@ -71,33 +78,21 @@
// Race between loading and timeout
await Promise.race([
(async () => {
// Load reactions (kind 7)
const reactionRelays = relayManager.getThreadReadRelays();
const reactionEvents = await nostrClient.fetchEvents(
[{ kinds: [KIND.REACTION], '#e': [thread.id] }],
reactionRelays,
{ 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++;
}
// Vote counting is handled by DiscussionVoteButtons component - not needed here
// Comment count is pre-loaded from batch fetch, skip individual loading
// Only load comments if not provided (fallback for backwards compatibility)
let commentEvents: NostrEvent[] = [];
if (providedCommentCount === 0) {
const commentRelays = relayManager.getCommentReadRelays();
commentEvents = await nostrClient.fetchEvents(
[{ kinds: [KIND.COMMENT], '#E': [thread.id], '#K': ['11'] }],
commentRelays,
{ useCache: true }
);
commentCount = commentEvents.length;
}
// Load comments (kind 1111)
const commentRelays = relayManager.getCommentReadRelays();
const commentEvents = await nostrClient.fetchEvents(
[{ kinds: [KIND.COMMENT], '#E': [thread.id], '#K': ['11'] }],
commentRelays,
{ useCache: true }
);
commentCount = commentEvents.length;
// Load zap receipts (kind 9735)
const zapRelays = relayManager.getZapReceiptReadRelays();
const zapReceipts = await nostrClient.fetchEvents(
@ -121,16 +116,13 @@ @@ -121,16 +116,13 @@
}
}
// Find latest response time (most recent comment, reaction, or zap)
// Find latest response time (most recent comment or zap)
// Note: Vote counting is handled by DiscussionVoteButtons, so we don't load reactions here
let latestTime = thread.created_at;
if (commentEvents.length > 0) {
const latestComment = commentEvents.sort((a, b) => b.created_at - a.created_at)[0];
const latestComment = commentEvents.sort((a: NostrEvent, b: NostrEvent) => b.created_at - a.created_at)[0];
latestTime = Math.max(latestTime, latestComment.created_at);
}
if (reactionEvents.length > 0) {
const latestReaction = reactionEvents.sort((a, b) => b.created_at - a.created_at)[0];
latestTime = Math.max(latestTime, latestReaction.created_at);
}
if (zapReceipts.length > 0) {
const latestZap = zapReceipts.sort((a, b) => b.created_at - a.created_at)[0];
latestTime = Math.max(latestTime, latestZap.created_at);
@ -142,8 +134,6 @@ @@ -142,8 +134,6 @@
} catch (error) {
console.error('Error loading thread stats:', error);
// On timeout or error, show zero stats instead of loading forever
upvotes = 0;
downvotes = 0;
commentCount = 0;
zapTotal = 0;
zapCount = 0;
@ -208,9 +198,7 @@ @@ -208,9 +198,7 @@
</h3>
<div class="flex items-center gap-2">
<span class="text-sm text-fog-text-light dark:text-fog-dark-text-light">{getRelativeTime()}</span>
<div class="interactive-element">
<EventMenu event={thread} showContentActions={true} />
</div>
<!-- Menu hidden in preview - not clickable in card preview -->
</div>
</div>
@ -226,30 +214,12 @@ @@ -226,30 +214,12 @@
<p class="text-sm mb-2">{getPreview()}</p>
{#if getTopics().length > 0}
<div class="flex gap-2 mb-2">
<div class="flex gap-2 topic-tags">
{#each getTopics() as topic}
<span class="text-xs bg-fog-highlight dark:bg-fog-dark-highlight text-fog-text dark:text-fog-dark-text px-2 py-1 rounded">{topic}</span>
{/each}
</div>
{/if}
<div class="flex items-center justify-between text-xs text-fog-text dark:text-fog-dark-text thread-stats">
<div class="flex items-center gap-4 flex-wrap">
{#if loadingStats}
<span class="text-fog-text-light dark:text-fog-dark-text-light">Loading stats...</span>
{:else}
<span class="font-medium">{upvotes}</span>
<span class="font-medium">{downvotes}</span>
<span class="font-medium">{commentCount} {commentCount === 1 ? 'comment' : 'comments'}</span>
{#if latestResponseTime}
<span class="text-fog-text-light dark:text-fog-dark-text-light">Last: {getLatestResponseTime()}</span>
{/if}
{#if zapCount > 0}
<span class="font-medium">{zapTotal.toLocaleString()} sats ({zapCount})</span>
{/if}
{/if}
</div>
</div>
</div>
</a>
@ -261,6 +231,28 @@ @@ -261,6 +231,28 @@
{expanded ? 'Show less' : 'Show more'}
</button>
{/if}
<!-- Card footer (stats) - always visible, outside collapsible content -->
<div class="flex items-center justify-between text-xs text-fog-text dark:text-fog-dark-text thread-stats">
<div class="flex items-center gap-4 flex-wrap">
{#if providedVotesCalculated}
<VoteCount upvotes={providedUpvotes} downvotes={providedDownvotes} votesCalculated={true} size="xs" />
{:else}
<span class="text-fog-text-light dark:text-fog-dark-text-light">Loading votes...</span>
{/if}
{#if loadingStats}
<span class="text-fog-text-light dark:text-fog-dark-text-light">Loading stats...</span>
{:else}
<span class="font-medium vote-comment-spacing">{commentCount} {commentCount === 1 ? 'comment' : 'comments'}</span>
{#if latestResponseTime}
<span class="text-fog-text-light dark:text-fog-dark-text-light">Last: {getLatestResponseTime()}</span>
{/if}
{#if zapCount > 0}
<span class="font-medium">{zapTotal.toLocaleString()} sats ({zapCount})</span>
{/if}
{/if}
</div>
</div>
<div class="kind-badge">
<span class="kind-number">{getKindInfo(thread.kind).number}</span>
@ -313,13 +305,15 @@ @@ -313,13 +305,15 @@
}
.thread-card a:hover {
text-decoration: underline;
text-decoration: none;
}
.card-content {
max-height: 500px;
overflow: hidden;
transition: max-height 0.3s ease;
/* Ensure footer below is not affected by overflow */
position: relative;
}
.card-content.expanded {
@ -346,6 +340,27 @@ @@ -346,6 +340,27 @@
font-size: 0.625rem;
line-height: 1;
color: var(--fog-text-light, #9ca3af);
white-space: nowrap;
}
.topic-tags {
margin-bottom: 1rem; /* Increased space between topic tags and count row */
}
@media (max-width: 640px) {
.topic-tags {
margin-bottom: 1.25rem; /* Even more space on narrow screens */
}
.kind-badge {
position: static;
margin-top: 0.25rem; /* Decreased space between count row and kind badge */
justify-content: flex-end;
}
.thread-stats {
margin-bottom: 0.25rem; /* Decreased space between count row and kind badge */
}
}
:global(.dark) .kind-badge {
@ -362,6 +377,19 @@ @@ -362,6 +377,19 @@
}
.thread-stats {
padding-right: 6rem; /* Reserve space for kind badge */
padding-right: 0;
margin-bottom: 1.5rem; /* Space for kind badge below */
padding-top: 0.5rem;
border-top: 1px solid var(--fog-border, #e5e7eb);
margin-top: 0.5rem;
/* Ensure footer is always visible, even when content is collapsed */
position: relative;
z-index: 1;
overflow: visible;
}
:global(.dark) .thread-stats {
border-top-color: var(--fog-dark-border, #374151);
}
</style>

191
src/lib/modules/threads/ThreadList.svelte → src/lib/modules/discussions/DiscussionList.svelte

@ -1,17 +1,19 @@ @@ -1,17 +1,19 @@
<script lang="ts">
import { nostrClient } from '../../services/nostr/nostr-client.js';
import { relayManager } from '../../services/nostr/relay-manager.js';
import FeedPost from '../feed/FeedPost.svelte';
import DiscussionCard from './DiscussionCard.svelte';
import ThreadDrawer from '../feed/ThreadDrawer.svelte';
import type { NostrEvent } from '../../types/nostr.js';
import { onMount } from 'svelte';
import { KIND } from '../../types/kind-lookup.js';
// Data maps - all data loaded upfront
// 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[]
let zapReceiptsMap = $state<Map<string, NostrEvent[]>>(new Map()); // threadId -> zapReceipts[]
let commentsMap = $state<Map<string, NostrEvent[]>>(new Map()); // threadId -> comments[]
let reactionsMap = $state<Map<string, NostrEvent[]>>(new Map()); // threadId -> reactions[] (for sorting only)
let zapReceiptsMap = $state<Map<string, NostrEvent[]>>(new Map()); // threadId -> zapReceipts[] (for sorting only)
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');
@ -84,6 +86,7 @@ @@ -84,6 +86,7 @@
if (!isMounted || isLoading) return; // Don't load if unmounted or already loading
loading = true;
isLoading = true;
voteCountsReady = false; // Reset vote counts ready state
try {
const config = nostrClient.getConfig();
const since = showOlder
@ -92,9 +95,9 @@ @@ -92,9 +95,9 @@
const threadRelays = relayManager.getThreadReadRelays();
// Use getProfileReadRelays() for reactions to include defaultRelays + profileRelays + user inbox + localRelays
// This ensures we get all reactions from the complete relay set
const reactionRelays = relayManager.getProfileReadRelays();
const zapRelays = relayManager.getZapReceiptReadRelays();
const commentRelays = relayManager.getCommentReadRelays();
// Query relays first with 3-second timeout, then fill from cache if needed
const fetchPromise = nostrClient.fetchEvents(
@ -141,30 +144,25 @@ @@ -141,30 +144,25 @@
threadsMap = newThreadsMap;
loading = false; // Show data immediately
// Get all thread IDs (use current threadsMap, not newThreadsMap, since it may have been updated)
// 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) {
// Don't fetch comments - they're not displayed on the list page
// Only fetch reactions and zaps for sorting and display
// Fetch all reactions in parallel
// Note: Some relays reject '#E' filter, so we only use '#e' and handle both cases in grouping
// Use onUpdate to handle real-time reaction updates
// Load reactions and zaps for sorting purposes only
// DiscussionCard components will load their own stats for display
// Fetch all reactions in parallel (for sorting)
const allReactionsMap = new Map<string, NostrEvent>();
// Function to process and group reactions (called initially and on updates)
const processReactionUpdates = async () => {
const allReactions = Array.from(allReactionsMap.values());
// Processing reaction updates
if (allReactions.length === 0) return;
if (!isMounted) return;
if (!isMounted) return; // Don't process if unmounted
// Fetch deletion events for current reactions
// Fetch deletion events for specific reaction IDs only
const reactionIds = allReactions.map(r => r.id);
const deletionFetchPromise = nostrClient.fetchEvents(
[{ kinds: [KIND.EVENT_DELETION], authors: Array.from(new Set(allReactions.map(r => r.pubkey))) }],
[{ kinds: [KIND.EVENT_DELETION], '#e': reactionIds, limit: 100 }],
reactionRelays,
{ relayFirst: true, useCache: true, cacheResults: true, timeout: 3000 }
);
@ -172,36 +170,29 @@ @@ -172,36 +170,29 @@
const deletionEvents = await deletionFetchPromise;
activeFetchPromises.delete(deletionFetchPromise);
if (!isMounted) return; // Don't process if unmounted
if (!isMounted) return;
// Build deleted reaction IDs map
const deletedReactionIdsByPubkey = new Map<string, Set<string>>();
// Build deleted reaction IDs set
const deletedReactionIds = new Set<string>();
for (const deletionEvent of deletionEvents) {
const pubkey = deletionEvent.pubkey;
if (!deletedReactionIdsByPubkey.has(pubkey)) {
deletedReactionIdsByPubkey.set(pubkey, new Set());
}
for (const tag of deletionEvent.tags) {
if (tag[0] === 'e' && tag[1]) {
deletedReactionIdsByPubkey.get(pubkey)!.add(tag[1]);
deletedReactionIds.add(tag[1]);
}
}
}
// Rebuild reactions map
// Rebuild reactions map (for sorting only)
const updatedReactionsMap = new Map<string, NostrEvent[]>();
for (const reaction of allReactions) {
const deletedIds = deletedReactionIdsByPubkey.get(reaction.pubkey);
if (deletedIds && deletedIds.has(reaction.id)) {
continue;
}
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 && newThreadsMap.has(threadId)) {
if (threadId && threadsMap.has(threadId)) {
if (!updatedReactionsMap.has(threadId)) {
updatedReactionsMap.set(threadId, []);
}
@ -211,21 +202,21 @@ @@ -211,21 +202,21 @@
if (isMounted) {
reactionsMap = updatedReactionsMap;
// Updated reactions map
// Don't update vote counts during real-time updates - only show after initial load
}
};
const handleReactionUpdate = async (updated: NostrEvent[]) => {
if (!isMounted) return; // Don't update if unmounted
if (!isMounted) return;
for (const r of updated) {
allReactionsMap.set(r.id, r);
}
// Reprocess reactions when updates arrive
await processReactionUpdates();
};
if (!isMounted) return; // Don't process if unmounted
if (!isMounted) return;
// Fetch reactions with lowercase e
const reactionsFetchPromise1 = nostrClient.fetchEvents(
[{ kinds: [KIND.REACTION], '#e': threadIds, limit: 100 }],
reactionRelays,
@ -241,9 +232,9 @@ @@ -241,9 +232,9 @@
const reactionsWithLowerE = await reactionsFetchPromise1;
activeFetchPromises.delete(reactionsFetchPromise1);
if (!isMounted) return; // Don't process if unmounted
if (!isMounted) return;
// Try uppercase filter, but some relays reject it - that's okay
// Try uppercase filter
let reactionsWithUpperE: NostrEvent[] = [];
try {
const reactionsFetchPromise2 = nostrClient.fetchEvents(
@ -260,17 +251,14 @@ @@ -260,17 +251,14 @@
activeFetchPromises.add(reactionsFetchPromise2);
reactionsWithUpperE = await reactionsFetchPromise2;
activeFetchPromises.delete(reactionsFetchPromise2);
if (!isMounted) return; // Don't process if unmounted
if (!isMounted) return;
} catch (error) {
if (isMounted) { // Only log if still mounted
console.log('[ThreadList] Upper case #E filter rejected by relay (this is normal):', error);
if (isMounted) {
console.log('[DiscussionList] Upper case #E filter rejected by relay (this is normal):', error);
}
}
// Reactions fetched
// Combine and deduplicate by reaction ID
// Combine reactions
for (const r of reactionsWithLowerE) {
allReactionsMap.set(r.id, r);
}
@ -278,11 +266,15 @@ @@ -278,11 +266,15 @@
allReactionsMap.set(r.id, r);
}
const allReactions = Array.from(allReactionsMap.values());
// Fetch all zap receipts in parallel (relay-first for first-time users)
if (!isMounted) return; // Don't process if unmounted
// Process reactions
await processReactionUpdates();
// Calculate vote counts from reactions for preview cards (only after initial load is complete)
updateVoteCountsMap();
voteCountsReady = true;
// Fetch zap receipts (for sorting)
if (!isMounted) return;
const zapFetchPromise = nostrClient.fetchEvents(
[{ kinds: [KIND.ZAP_RECEIPT], '#e': threadIds, limit: 100 }],
zapRelays,
@ -292,55 +284,91 @@ @@ -292,55 +284,91 @@
const allZapReceipts = await zapFetchPromise;
activeFetchPromises.delete(zapFetchPromise);
if (!isMounted) return; // Don't process if unmounted
// Build maps
let newReactionsMap = new Map<string, NostrEvent[]>();
const newZapReceiptsMap = new Map<string, NostrEvent[]>();
if (!isMounted) return;
// Process initial reactions (this will set newReactionsMap)
await processReactionUpdates();
newReactionsMap = reactionsMap; // Use the processed reactions map
// Group zap receipts by thread ID
// Group zap receipts by thread ID (for sorting)
const newZapReceiptsMap = new Map<string, NostrEvent[]>();
for (const zapReceipt of allZapReceipts) {
const threadId = zapReceipt.tags.find((t: string[]) => t[0] === 'e')?.[1];
if (threadId && newThreadsMap.has(threadId)) {
if (threadId && threadsMap.has(threadId)) {
if (!newZapReceiptsMap.has(threadId)) {
newZapReceiptsMap.set(threadId, []);
}
newZapReceiptsMap.get(threadId)!.push(zapReceipt);
}
}
reactionsMap = newReactionsMap;
zapReceiptsMap = newZapReceiptsMap;
// Clear comments map - we don't fetch comments for the list page
commentsMap = new Map();
} else {
// Clear maps if no threads
commentsMap = new Map();
reactionsMap = new Map();
zapReceiptsMap = new Map();
// Batch-load comment counts for all threads
if (!isMounted) return;
const commentsFetchPromise = nostrClient.fetchEvents(
[{ kinds: [KIND.COMMENT], '#E': threadIds, '#K': ['11'], limit: 100 }],
commentRelays,
{ relayFirst: true, useCache: true, cacheResults: true, timeout: 3000, priority: 'low' }
);
activeFetchPromises.add(commentsFetchPromise);
const allComments = await commentsFetchPromise;
activeFetchPromises.delete(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;
}
} catch (error) {
console.error('Error loading thread data:', error);
threadsMap = new Map();
commentsMap = new Map();
reactionsMap = new Map();
zapReceiptsMap = new Map();
voteCountsReady = false;
} finally {
loading = false;
}
}
// Sort threads from the maps (synchronous, no fetching)
// 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 or zaps - comments not fetched for list page)
// Sort by most recent activity (reactions or zaps)
const activeSorted = events.map((event) => {
const reactions = reactionsMap.get(event.id) || [];
const zapReceipts = zapReceiptsMap.get(event.id) || [];
@ -541,6 +569,7 @@ @@ -541,6 +569,7 @@
{: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"
@ -554,10 +583,12 @@ @@ -554,10 +583,12 @@
}
}}
>
<FeedPost
post={thread}
previewMode={true}
reactions={reactionsMap.get(thread.id) || []}
<DiscussionCard
thread={thread}
commentCount={commentsMap.get(thread.id) ?? 0}
upvotes={voteCounts.upvotes}
downvotes={voteCounts.downvotes}
votesCalculated={voteCountsReady}
/>
</div>
{/each}

143
src/lib/modules/discussions/DiscussionView.svelte

@ -0,0 +1,143 @@ @@ -0,0 +1,143 @@
<script lang="ts">
import FeedPost from '../feed/FeedPost.svelte';
import CommentThread from '../comments/CommentThread.svelte';
import { nostrClient } from '../../services/nostr/nostr-client.js';
import { relayManager } from '../../services/nostr/relay-manager.js';
import { onMount } from 'svelte';
import type { NostrEvent } from '../../types/nostr.js';
interface Props {
threadId: string;
}
let { threadId }: Props = $props();
let rootEvent = $state<NostrEvent | null>(null);
let loading = $state(true);
onMount(async () => {
await nostrClient.initialize();
loadRootEvent();
});
$effect(() => {
if (threadId) {
loadRootEvent();
}
});
/**
* Find the root OP event by traversing up the reply chain
*/
async function findRootEvent(event: NostrEvent, visited: Set<string> = new Set()): Promise<NostrEvent> {
// Prevent infinite loops
if (visited.has(event.id)) {
return event;
}
visited.add(event.id);
// Check for 'root' tag first (NIP-10) - this directly points to the root
const rootTag = event.tags.find((t) => t[0] === 'root');
if (rootTag && rootTag[1]) {
// If root tag points to self, we're already at root
if (rootTag[1] === event.id) {
return event;
}
const relays = relayManager.getFeedReadRelays();
const rootEvent = await nostrClient.getEventById(rootTag[1], relays);
if (rootEvent) {
return rootEvent;
}
}
// Check if this event has a parent 'e' tag (NIP-10)
const eTags = event.tags.filter((t) => t[0] === 'e' && t[1] && t[1] !== event.id);
// Prefer 'e' tag with 'reply' marker (NIP-10)
let parentId: string | undefined;
const replyTag = eTags.find((t) => t[3] === 'reply');
if (replyTag) {
parentId = replyTag[1];
} else if (eTags.length > 0) {
// Use first 'e' tag if no explicit reply marker
parentId = eTags[0][1];
}
if (!parentId) {
// No parent - this is the root
return event;
}
const relays = relayManager.getFeedReadRelays();
const parent = await nostrClient.getEventById(parentId, relays);
if (!parent) {
// Parent not found - treat current event as root
return event;
}
// Recursively find root
return findRootEvent(parent, visited);
}
async function loadRootEvent() {
loading = true;
try {
const threadRelays = relayManager.getThreadReadRelays();
const feedRelays = relayManager.getFeedReadRelays();
const allRelays = [...new Set([...threadRelays, ...feedRelays])];
// Load the event by ID
const event = await nostrClient.getEventById(threadId, allRelays);
if (event) {
// Find the root OP by traversing up the chain
rootEvent = await findRootEvent(event);
}
} catch (error) {
console.error('Error loading thread:', error);
} finally {
loading = false;
}
}
</script>
{#if loading}
<p class="text-fog-text dark:text-fog-dark-text">Loading thread...</p>
{:else if rootEvent}
<article class="thread-view">
<!-- Display the root OP event (kind 11) -->
<div class="op-section">
<FeedPost post={rootEvent} />
</div>
<!-- Display all replies (kind 1111 comments) using CommentThread -->
<div class="comments-section">
<CommentThread threadId={rootEvent.id} event={rootEvent} />
</div>
</article>
{:else}
<p class="text-fog-text dark:text-fog-dark-text">Thread not found</p>
{/if}
<style>
.thread-view {
max-width: var(--content-width);
margin: 0 auto;
padding: 1rem;
}
.op-section {
margin-bottom: 2rem;
padding-bottom: 1rem;
border-bottom: 2px solid var(--fog-border, #e5e7eb);
}
:global(.dark) .op-section {
border-bottom-color: var(--fog-dark-border, #374151);
}
.comments-section {
margin-top: 2rem;
}
</style>

450
src/lib/modules/discussions/DiscussionVoteButtons.svelte

@ -0,0 +1,450 @@ @@ -0,0 +1,450 @@
<script lang="ts">
import { sessionManager } from '../../services/auth/session-manager.js';
import { signAndPublish } from '../../services/nostr/auth-handler.js';
import { nostrClient } from '../../services/nostr/nostr-client.js';
import { relayManager } from '../../services/nostr/relay-manager.js';
import { onMount } from 'svelte';
import type { NostrEvent } from '../../types/nostr.js';
import { KIND } from '../../types/kind-lookup.js';
import VoteCount from '../../components/content/VoteCount.svelte';
interface Props {
event: NostrEvent;
preloadedReactions?: NostrEvent[]; // Optional pre-loaded reactions to avoid duplicate fetches
}
let { event, preloadedReactions }: Props = $props();
let userReaction = $state<string | null>(null);
let userReactionEventId = $state<string | null>(null); // Track the event ID of user's reaction
let loading = $state(true);
let allReactionsMap = $state<Map<string, NostrEvent>>(new Map()); // Persist reactions map for real-time updates
let loadingReactions = $state(false);
let lastEventId = $state<string | null>(null);
let isMounted = $state(false);
let processingUpdate = $state(false);
let updateDebounceTimer: ReturnType<typeof setTimeout> | null = null;
let initialLoadComplete = $state(false); // Track when initial load is done
// Count upvotes and downvotes
let upvotes = $derived.by(() => {
let count = 0;
for (const reaction of allReactionsMap.values()) {
const content = reaction.content.trim();
if (content === '+' || content === '⬆' || content === '↑') {
count++;
}
}
return count;
});
let downvotes = $derived.by(() => {
let count = 0;
for (const reaction of allReactionsMap.values()) {
const content = reaction.content.trim();
if (content === '-' || content === '⬇' || content === '↓') {
count++;
}
}
return count;
});
// Only show votes as calculated after initial load completes
let votesCalculated = $derived(initialLoadComplete && !loading);
onMount(() => {
// Set lastEventId immediately to prevent $effect from running during mount
if (event.id) {
lastEventId = event.id;
}
isMounted = true;
nostrClient.initialize().then(async () => {
if (event.id) {
// Use pre-loaded reactions if available, otherwise fetch
if (preloadedReactions && preloadedReactions.length > 0) {
const filtered = await filterDeletedReactions(preloadedReactions);
// Update the map to only contain non-deleted reactions
// Reassign map to trigger reactivity in Svelte 5
const filteredMap = new Map<string, NostrEvent>();
for (const reaction of filtered) {
filteredMap.set(reaction.id, reaction);
}
allReactionsMap = filteredMap;
processReactions(filtered);
initialLoadComplete = true; // Mark initial load as complete
} else {
loadReactions();
}
}
});
});
// Reload reactions when event changes (but prevent duplicate loads and initial mount)
$effect(() => {
// Only run after mount and when event.id actually changes
if (!isMounted || !event.id || event.id === lastEventId || loadingReactions) {
return;
}
lastEventId = event.id;
// Clear previous reactions map when event changes
allReactionsMap = new Map(); // Reassign to trigger reactivity
initialLoadComplete = false; // Reset when event changes
// Use pre-loaded reactions if available, otherwise fetch
if (preloadedReactions && preloadedReactions.length > 0) {
const newMap = new Map<string, NostrEvent>();
for (const r of preloadedReactions) {
newMap.set(r.id, r);
}
allReactionsMap = newMap; // Reassign to trigger reactivity
filterDeletedReactions(preloadedReactions).then(filtered => {
// Update the map to only contain non-deleted reactions
// Reassign map to trigger reactivity in Svelte 5
const filteredMap = new Map<string, NostrEvent>();
for (const reaction of filtered) {
filteredMap.set(reaction.id, reaction);
}
allReactionsMap = filteredMap;
processReactions(filtered);
initialLoadComplete = true; // Mark initial load as complete
});
} else {
loadReactions();
}
});
// Handle real-time updates - process reactions when new ones arrive
// Debounced to prevent excessive processing
async function handleReactionUpdate(updated: NostrEvent[]) {
// Only process real-time updates after initial load is complete
if (!initialLoadComplete) {
return;
}
// Prevent concurrent processing
if (processingUpdate) {
return;
}
// Add new reactions to the map
let hasNewReactions = false;
const newMap = new Map(allReactionsMap);
for (const r of updated) {
if (!newMap.has(r.id)) {
newMap.set(r.id, r);
hasNewReactions = true;
}
}
if (hasNewReactions) {
allReactionsMap = newMap; // Reassign to trigger reactivity
}
// Only process if we have new reactions
if (!hasNewReactions) {
return;
}
// Clear existing debounce timer
if (updateDebounceTimer) {
clearTimeout(updateDebounceTimer);
}
// Debounce processing to batch multiple rapid updates
updateDebounceTimer = setTimeout(async () => {
if (processingUpdate) return;
processingUpdate = true;
try {
// Process all accumulated reactions and update the map to remove deleted ones
const allReactions = Array.from(allReactionsMap.values());
const filtered = await filterDeletedReactions(allReactions);
// Update the map to only contain non-deleted reactions
// Reassign map to trigger reactivity in Svelte 5
const filteredMap = new Map<string, NostrEvent>();
for (const reaction of filtered) {
filteredMap.set(reaction.id, reaction);
}
allReactionsMap = filteredMap;
// Process reactions to update userReaction state
processReactions(filtered);
} finally {
processingUpdate = false;
}
}, 300); // 300ms debounce
}
async function loadReactions() {
// Prevent concurrent loads for the same event
if (loadingReactions) {
return;
}
loadingReactions = true;
loading = true;
try {
// Use getProfileReadRelays() to include defaultRelays + profileRelays + user inbox + localRelays
// This ensures we get all reactions from the complete relay set, matching DiscussionList behavior
const reactionRelays = relayManager.getProfileReadRelays();
// Clear and rebuild reactions map for this event
allReactionsMap = new Map(); // Reassign to trigger reactivity
// Use low priority for reactions - they're background data, comments should load first
const reactionsWithLowerE = await nostrClient.fetchEvents(
[{ kinds: [KIND.REACTION], '#e': [event.id], limit: 100 }],
reactionRelays,
{ useCache: true, cacheResults: true, onUpdate: handleReactionUpdate, timeout: 5000, priority: 'low' }
);
const reactionsWithUpperE = await nostrClient.fetchEvents(
[{ kinds: [KIND.REACTION], '#E': [event.id], limit: 100 }],
reactionRelays,
{ useCache: true, cacheResults: true, onUpdate: handleReactionUpdate, timeout: 5000, priority: 'low' }
);
// Combine and deduplicate by reaction ID
const newMap = new Map<string, NostrEvent>();
for (const r of reactionsWithLowerE) {
newMap.set(r.id, r);
}
for (const r of reactionsWithUpperE) {
newMap.set(r.id, r);
}
allReactionsMap = newMap; // Reassign to trigger reactivity
const reactionEvents = Array.from(allReactionsMap.values());
// Filter out deleted reactions (kind 5)
const filteredReactions = await filterDeletedReactions(reactionEvents);
// Update the map to only contain non-deleted reactions
// Reassign map to trigger reactivity in Svelte 5
const filteredMap = new Map<string, NostrEvent>();
for (const reaction of filteredReactions) {
filteredMap.set(reaction.id, reaction);
}
allReactionsMap = filteredMap;
processReactions(filteredReactions);
initialLoadComplete = true; // Mark initial load as complete after processing
} catch (error) {
console.error('[DiscussionVoteButtons] Error loading reactions:', error);
initialLoadComplete = true; // Mark as complete even on error to avoid stuck loading state
} finally {
loading = false;
loadingReactions = false;
}
}
async function filterDeletedReactions(reactions: NostrEvent[]): Promise<NostrEvent[]> {
if (reactions.length === 0) return reactions;
// Optimize: Instead of fetching all deletion events for all users,
// fetch deletion events that reference the specific reaction IDs we have
// This is much more efficient and limits memory usage
const reactionRelays = relayManager.getProfileReadRelays();
const reactionIds = reactions.map(r => r.id);
// Limit to first 100 reactions to avoid massive queries
const limitedReactionIds = reactionIds.slice(0, 100);
// Fetch deletion events that reference these specific reaction IDs
// This is much more efficient than fetching all deletion events from all users
// Use low priority for deletion events - background data
const deletionEvents = await nostrClient.fetchEvents(
[{ kinds: [KIND.EVENT_DELETION], '#e': limitedReactionIds, limit: 100 }],
reactionRelays,
{ useCache: true, timeout: 5000, priority: 'low' }
);
// Build a set of deleted reaction event IDs (more efficient - just a Set)
const deletedReactionIds = new Set<string>();
for (const deletionEvent of deletionEvents) {
// Kind 5 events have 'e' tags pointing to deleted events
for (const tag of deletionEvent.tags) {
if (tag[0] === 'e' && tag[1]) {
deletedReactionIds.add(tag[1]);
}
}
}
// Filter out deleted reactions - much simpler now
const filtered = reactions.filter(reaction => {
const isDeleted = deletedReactionIds.has(reaction.id);
return !isDeleted;
});
return filtered;
}
async function processReactions(reactionEvents: NostrEvent[]) {
// Prevent duplicate processing - check if we're already processing the same set
if (processingUpdate) {
return;
}
const currentUser = sessionManager.getCurrentPubkey();
for (const reactionEvent of reactionEvents) {
let content = reactionEvent.content.trim();
// Normalize reactions: only + and - allowed
// Backward compatibility: ⬆/↑ = +, ⬇/↓ = -
if (content === '⬆' || content === '↑') {
content = '+';
} else if (content === '⬇' || content === '↓') {
content = '-';
} else if (content !== '+' && content !== '-') {
// Skip invalid reactions for voting
continue;
}
if (currentUser && reactionEvent.pubkey === currentUser) {
userReaction = content;
userReactionEventId = reactionEvent.id;
}
}
}
async function toggleReaction(content: string) {
if (!sessionManager.isLoggedIn()) {
alert('Please log in to vote');
return;
}
// Only allow + and - (upvote/downvote)
if (content !== '+' && content !== '-') {
return;
}
// If clicking the same reaction, delete it
if (userReaction === content) {
// Remove reaction by publishing a kind 5 deletion event
if (userReactionEventId) {
const reactionIdToDelete = userReactionEventId; // Store before clearing
try {
const deletionEvent: Omit<NostrEvent, 'id' | 'sig'> = {
kind: KIND.EVENT_DELETION,
pubkey: sessionManager.getCurrentPubkey()!,
created_at: Math.floor(Date.now() / 1000),
tags: [['e', reactionIdToDelete]],
content: ''
};
const config = nostrClient.getConfig();
await signAndPublish(deletionEvent, [...config.defaultRelays]);
// Remove from map immediately so counts update
allReactionsMap.delete(reactionIdToDelete);
// Update local state immediately
userReaction = null;
userReactionEventId = null;
} catch (error) {
console.error('Error deleting reaction:', error);
alert('Error deleting reaction');
}
} else {
// Fallback: just update UI if we don't have the event ID
userReaction = null;
userReactionEventId = null;
}
return;
}
// If user has the opposite vote, delete it first
if (userReaction && userReaction !== content) {
// Delete the existing vote first
if (userReactionEventId) {
const oldReactionId = userReactionEventId; // Store before clearing
try {
const deletionEvent: Omit<NostrEvent, 'id' | 'sig'> = {
kind: KIND.EVENT_DELETION,
pubkey: sessionManager.getCurrentPubkey()!,
created_at: Math.floor(Date.now() / 1000),
tags: [['e', oldReactionId]],
content: ''
};
const config = nostrClient.getConfig();
await signAndPublish(deletionEvent, [...config.defaultRelays]);
// Remove from map immediately so counts update
// Reassign map to trigger reactivity in Svelte 5
const newMap = new Map(allReactionsMap);
newMap.delete(oldReactionId);
allReactionsMap = newMap;
} catch (error) {
console.error('Error deleting old reaction:', error);
}
}
// Clear the old reaction state
userReaction = null;
userReactionEventId = null;
}
try {
const tags: string[][] = [
['e', event.id],
['p', event.pubkey],
['k', event.kind.toString()]
];
const reactionEvent: Omit<NostrEvent, 'id' | 'sig'> = {
kind: KIND.REACTION,
pubkey: sessionManager.getCurrentPubkey()!,
created_at: Math.floor(Date.now() / 1000),
tags,
content
};
const config = nostrClient.getConfig();
// Sign the event first to get the ID
const signedEvent = await sessionManager.signEvent(reactionEvent);
// Publish the signed event
await nostrClient.publish(signedEvent, { relays: [...config.defaultRelays] });
// Update local state immediately for instant UI feedback
userReaction = content;
userReactionEventId = signedEvent.id;
// Add the new reaction to the map so counts update immediately
// Reassign map to trigger reactivity in Svelte 5
const newMap = new Map(allReactionsMap);
newMap.set(signedEvent.id, signedEvent);
allReactionsMap = newMap;
} catch (error) {
console.error('Error publishing reaction:', error);
}
}
const isLoggedIn = $derived(sessionManager.isLoggedIn());
// Get user's current vote (normalize to + or -)
let userVote = $derived<string | null>(userReaction === '+' || userReaction === '-' ? userReaction : null);
</script>
<div class="discussion-vote-buttons flex gap-2 items-center">
<!-- Vote counts with clickable emojis -->
<VoteCount
upvotes={upvotes}
downvotes={downvotes}
votesCalculated={votesCalculated}
size="xs"
userVote={userVote as '+' | '-' | null}
onVote={toggleReaction}
isLoggedIn={isLoggedIn}
/>
</div>
<style>
.discussion-vote-buttons {
margin-top: 0.5rem;
}
</style>

165
src/lib/modules/threads/ThreadView.svelte → src/lib/modules/events/EventView.svelte

@ -1,12 +1,19 @@ @@ -1,12 +1,19 @@
<script lang="ts">
import FeedPost from '../feed/FeedPost.svelte';
import CommentThread from '../comments/CommentThread.svelte';
import { nostrClient } from '../../services/nostr/nostr-client.js';
import { relayManager } from '../../services/nostr/relay-manager.js';
import { loadEventIndex, type EventIndexItem, type MissingEventInfo } from '../../services/nostr/event-index-loader.js';
import { onMount } from 'svelte';
import type { NostrEvent } from '../../types/nostr.js';
import { KIND } from '../../types/kind-lookup.js';
import MetadataCard from '../../components/content/MetadataCard.svelte';
interface Props {
eventId: string;
}
let { eventId }: Props = $props();
// Virtual scrolling for kind 30040 (event indexes with 36k+ events)
let Virtualizer: any = $state(null);
let virtualizerLoading = $state(false);
@ -19,7 +26,6 @@ @@ -19,7 +26,6 @@
virtualizerLoading = true;
try {
const module = await import('@tanstack/svelte-virtual');
// @tanstack/svelte-virtual exports Virtualizer component
Virtualizer = module.Virtualizer;
return Virtualizer;
} catch (error) {
@ -30,14 +36,9 @@ @@ -30,14 +36,9 @@
}
}
interface Props {
threadId: string;
}
let { threadId }: Props = $props();
let rootEvent = $state<NostrEvent | null>(null);
let loading = $state(true);
let error = $state<string | null>(null);
// For kind 30040: event index hierarchy
let isEventIndex = $derived(rootEvent?.kind === 30040);
@ -50,9 +51,9 @@ @@ -50,9 +51,9 @@
function countTotalItems(items: EventIndexItem[]): number {
let count = 0;
for (const item of items) {
count += 1; // Count the item itself
count += 1;
if (item.children && item.children.length > 0) {
count += countTotalItems(item.children); // Recursively count children
count += countTotalItems(item.children);
}
}
return count;
@ -60,97 +61,57 @@ @@ -60,97 +61,57 @@
let totalItemCount = $derived(countTotalItems(eventIndexItems));
// Check if event is metadata-only (no content to display)
let isMetadataOnly = $derived(rootEvent ? (
rootEvent.kind === KIND.METADATA ||
rootEvent.kind === KIND.RELAY_LIST ||
rootEvent.kind === KIND.MUTE_LIST ||
rootEvent.kind === KIND.BLOCKED_RELAYS ||
rootEvent.kind === KIND.LOCAL_RELAYS ||
rootEvent.kind === KIND.USER_STATUS ||
(rootEvent.content === '' && rootEvent.tags.length > 0)
) : false);
onMount(async () => {
await nostrClient.initialize();
loadRootEvent();
loadEvent();
});
$effect(() => {
if (threadId) {
loadRootEvent();
if (eventId) {
loadEvent();
}
});
/**
* Find the root OP event by traversing up the reply chain
*/
async function findRootEvent(event: NostrEvent, visited: Set<string> = new Set()): Promise<NostrEvent> {
// Prevent infinite loops
if (visited.has(event.id)) {
return event;
}
visited.add(event.id);
// Check for 'root' tag first (NIP-10) - this directly points to the root
const rootTag = event.tags.find((t) => t[0] === 'root');
if (rootTag && rootTag[1]) {
// If root tag points to self, we're already at root
if (rootTag[1] === event.id) {
return event;
}
const relays = relayManager.getFeedReadRelays();
const rootEvent = await nostrClient.getEventById(rootTag[1], relays);
if (rootEvent) {
return rootEvent;
}
}
// Check if this event has a parent 'e' tag (NIP-10)
const eTags = event.tags.filter((t) => t[0] === 'e' && t[1] && t[1] !== event.id);
// Prefer 'e' tag with 'reply' marker (NIP-10)
let parentId: string | undefined;
const replyTag = eTags.find((t) => t[3] === 'reply');
if (replyTag) {
parentId = replyTag[1];
} else if (eTags.length > 0) {
// Use first 'e' tag if no explicit reply marker
parentId = eTags[0][1];
}
if (!parentId) {
// No parent - this is the root
return event;
}
const relays = relayManager.getFeedReadRelays();
const parent = await nostrClient.getEventById(parentId, relays);
if (!parent) {
// Parent not found - treat current event as root
return event;
}
// Recursively find root
return findRootEvent(parent, visited);
}
async function loadRootEvent() {
async function loadEvent() {
loading = true;
error = null;
indexError = null;
eventIndexItems = [];
rootEvent = null;
try {
const threadRelays = relayManager.getThreadReadRelays();
const feedRelays = relayManager.getFeedReadRelays();
const allRelays = [...new Set([...threadRelays, ...feedRelays])];
// Load the event by ID
const event = await nostrClient.getEventById(threadId, allRelays);
const event = await nostrClient.getEventById(eventId, allRelays);
if (event) {
// Find the root OP by traversing up the chain
rootEvent = await findRootEvent(event);
rootEvent = event;
// If it's a kind 30040 event index, load the entire hierarchy
if (rootEvent && rootEvent.kind === 30040) {
await loadEventIndexHierarchy(rootEvent);
if (event.kind === 30040) {
await loadEventIndexHierarchy(event);
// Load virtualizer for large lists
await loadVirtualizer();
}
} else {
error = 'Event not found';
}
} catch (error) {
console.error('Error loading thread:', error);
indexError = error instanceof Error ? error.message : 'Failed to load event';
} catch (err) {
console.error('Error loading event:', err);
error = err instanceof Error ? err.message : 'Failed to load event';
} finally {
loading = false;
}
@ -169,11 +130,11 @@ @@ -169,11 +130,11 @@
missingEvents = result.missingEvents;
console.log(`Loaded ${result.items.length} events from index hierarchy`);
if (result.missingEvents.length > 0) {
console.warn(`[ThreadView] ${result.missingEvents.length} events are missing from the index hierarchy`);
console.warn(`[EventView] ${result.missingEvents.length} events are missing from the index hierarchy`);
}
} catch (error) {
console.error('Error loading event index:', error);
indexError = error instanceof Error ? error.message : 'Failed to load event index';
} catch (err) {
console.error('Error loading event index:', err);
indexError = err instanceof Error ? err.message : 'Failed to load event index';
} finally {
loadingIndex = false;
}
@ -181,9 +142,11 @@ @@ -181,9 +142,11 @@
</script>
{#if loading}
<p class="text-fog-text dark:text-fog-dark-text">Loading thread...</p>
<p class="text-fog-text dark:text-fog-dark-text">Loading event...</p>
{:else if error}
<p class="text-fog-text dark:text-fog-dark-text error-message">Error: {error}</p>
{:else if rootEvent}
<article class="thread-view">
<article class="event-view">
<!-- Display title prominently for kind 30040 (book index) -->
{#if rootEvent.kind === 30040}
{@const titleTag = rootEvent.tags.find(t => t[0] === 'title')}
@ -194,12 +157,12 @@ @@ -194,12 +157,12 @@
{/if}
{/if}
<!-- Display the root OP event -->
<div class="op-section">
<FeedPost post={rootEvent} />
</div>
{#if isEventIndex}
<!-- Display metadata-only events with MetadataCard -->
{#if isMetadataOnly}
<div class="metadata-section">
<MetadataCard event={rootEvent} />
</div>
{:else if isEventIndex}
<!-- For kind 30040: Display event index hierarchy with virtual scrolling -->
<div class="event-index-section">
{#if loadingIndex}
@ -306,35 +269,29 @@ @@ -306,35 +269,29 @@
{/if}
</div>
{:else}
<!-- Display all replies using CommentThread for regular threads -->
<div class="comments-section">
<CommentThread threadId={rootEvent.id} event={rootEvent} />
<!-- Display regular events using FeedPost -->
<div class="event-section">
<FeedPost post={rootEvent} />
</div>
{/if}
</article>
{:else}
<p class="text-fog-text dark:text-fog-dark-text">Thread not found</p>
<p class="text-fog-text dark:text-fog-dark-text">Event not found</p>
{/if}
<style>
.thread-view {
.event-view {
max-width: var(--content-width);
margin: 0 auto;
padding: 1rem;
}
.op-section {
.metadata-section {
margin-bottom: 2rem;
padding-bottom: 1rem;
border-bottom: 2px solid var(--fog-border, #e5e7eb);
}
:global(.dark) .op-section {
border-bottom-color: var(--fog-dark-border, #374151);
}
.comments-section {
margin-top: 2rem;
.event-section {
margin-bottom: 2rem;
}
.event-index-section {

137
src/lib/modules/feed/FeedPage.svelte

@ -70,9 +70,6 @@ @@ -70,9 +70,6 @@
let selectedListId = $state<string | null>(null); // Format: "kind:eventId"
let listFilterIds = $state<Set<string>>(new Set()); // Event IDs or pubkeys to filter by
// Batch-loaded reactions: eventId -> reactions[]
let reactionsMap = $state<Map<string, NostrEvent[]>>(new Map());
// Batch-loaded parent events: eventId -> parentEvent
let parentEventsMap = $state<Map<string, NostrEvent>>(new Map());
@ -610,32 +607,44 @@ @@ -610,32 +607,44 @@
const allTimestamps = [...sortedPosts.map(e => e.created_at), ...sortedHighlights.map(e => e.created_at)];
oldestTimestamp = Math.min(...allTimestamps);
// Load secondary data (reactions, profiles, etc.) with low priority after posts are displayed
// Use setTimeout to defer loading so posts/highlights render first
const secondaryDataPromise = new Promise<void>((resolve) => {
setTimeout(() => {
if (!isMounted) {
resolve();
return; // Don't load if component is unmounted
// Load secondary data (reactions, profiles, etc.) AFTER posts are displayed
// Collect all post IDs and pubkeys first, then batch fetch everything
const allPostIds = [
...sortedPosts.map(p => p.id),
...sortedHighlights.map(p => p.id),
...sortedOther.map(p => p.id)
];
const allPubkeys = new Set<string>();
sortedPosts.forEach(p => allPubkeys.add(p.pubkey));
sortedHighlights.forEach(p => allPubkeys.add(p.pubkey));
sortedOther.forEach(p => allPubkeys.add(p.pubkey));
// Use requestIdleCallback or setTimeout to defer loading so posts render first
const deferSecondaryData = () => {
if (!isMounted) return;
// Batch load all secondary data in parallel using collected IDs/pubkeys
// Note: Reactions are handled by FeedPost component itself
const promise = Promise.all([
loadParentAndQuotedEvents(sortedPosts),
loadZapCountsForPosts(sortedPosts),
loadProfilesForPosts(sortedPosts)
]).catch(error => {
if (isMounted) {
console.error('[FeedPage] Error loading secondary data:', error);
}
// Load in parallel but with low priority - don't await, let it load in background
const promise = Promise.all([
loadReactionsForPosts(sortedPosts),
loadParentAndQuotedEvents(sortedPosts),
loadZapCountsForPosts(sortedPosts),
loadProfilesForPosts(sortedPosts)
]).catch(error => {
if (isMounted) { // Only log if still mounted
console.error('[FeedPage] Error loading secondary data:', error);
}
}).finally(() => {
activeFetchPromises.delete(promise);
resolve();
});
activeFetchPromises.add(promise);
}, 100); // Small delay to ensure posts render first
});
activeFetchPromises.add(secondaryDataPromise);
}).finally(() => {
activeFetchPromises.delete(promise);
});
activeFetchPromises.add(promise);
};
// Use requestIdleCallback if available, otherwise setTimeout
if (typeof requestIdleCallback !== 'undefined') {
requestIdleCallback(deferSecondaryData, { timeout: 500 });
} else {
setTimeout(deferSecondaryData, 100);
}
} else {
console.log('[FeedPage] No events found. Relays:', relays);
// In single-relay mode, if we got 0 events, it might mean the relay doesn't have any
@ -725,8 +734,8 @@ @@ -725,8 +734,8 @@
resolve();
return; // Don't load if component is unmounted
}
// Note: Reactions are handled by FeedPost component itself
const promise = Promise.all([
loadReactionsForPosts(sorted),
loadParentAndQuotedEvents(sorted),
loadZapCountsForPosts(sorted),
loadProfilesForPosts(sorted)
@ -923,73 +932,8 @@ @@ -923,73 +932,8 @@
}, 500);
}
// Batch load reactions for multiple posts at once
async function loadReactionsForPosts(postsToLoad: NostrEvent[]) {
if (!isMounted || postsToLoad.length === 0) return; // Don't load if unmounted
try {
const reactionRelays = relayManager.getProfileReadRelays();
const eventIds = postsToLoad.map(p => p.id);
// Use single relay if provided, otherwise use normal reaction relays
const relaysForReactions = singleRelay ? [singleRelay] : reactionRelays;
// Batch fetch all reactions for all posts in one query
// In single-relay mode: never use cache
const fetchPromise = nostrClient.fetchEvents(
[
{ kinds: [KIND.REACTION], '#e': eventIds, limit: 100 },
{ kinds: [KIND.REACTION], '#E': eventIds, limit: 100 }
],
relaysForReactions,
singleRelay ? {
relayFirst: true,
useCache: false, // Never use cache in single-relay mode
cacheResults: false, // Don't cache in single-relay mode
timeout: 3000,
priority: 'low' // Low priority - secondary data
} : {
relayFirst: true,
useCache: true,
cacheResults: true,
timeout: 3000,
priority: 'low' // Low priority - secondary data
}
);
activeFetchPromises.add(fetchPromise);
const allReactions = await fetchPromise;
activeFetchPromises.delete(fetchPromise);
if (!isMounted) return; // Don't process if component is unmounted
// Group reactions by event ID
const newReactionsMap = new Map<string, NostrEvent[]>();
for (const reaction of allReactions) {
// Find which event(s) this reaction is for
const eTags = reaction.tags.filter(t => (t[0] === 'e' || t[0] === 'E') && t[1]);
for (const tag of eTags) {
const eventId = tag[1];
if (eventIds.includes(eventId)) {
if (!newReactionsMap.has(eventId)) {
newReactionsMap.set(eventId, []);
}
newReactionsMap.get(eventId)!.push(reaction);
}
}
}
// Merge with existing reactions
for (const [eventId, reactions] of newReactionsMap.entries()) {
const existing = reactionsMap.get(eventId) || [];
const combined = [...existing, ...reactions];
// Deduplicate by reaction ID
const unique = Array.from(new Map(combined.map(r => [r.id, r])).values());
reactionsMap.set(eventId, unique);
}
} catch (error) {
console.error('[FeedPage] Error batch loading reactions:', error);
}
}
// Reactions are handled by FeedPost component itself, not here
// FeedPage no longer loads or manages reactions
// Batch load parent and quoted events for all posts
async function loadParentAndQuotedEvents(postsToLoad: NostrEvent[]) {
@ -1239,7 +1183,6 @@ @@ -1239,7 +1183,6 @@
<FeedPost
post={event}
onOpenEvent={openDrawer}
reactions={reactionsMap.get(event.id)}
parentEvent={parentEventsMap.get(event.id)}
quotedEvent={quotedEventsMap.get(event.id)}
zapCount={zapCountsMap.get(event.id) || 0}

114
src/lib/modules/feed/FeedPost.svelte

@ -5,6 +5,7 @@ @@ -5,6 +5,7 @@
import ReplyContext from '../../components/content/ReplyContext.svelte';
import QuotedContext from '../../components/content/QuotedContext.svelte';
import FeedReactionButtons from '../reactions/FeedReactionButtons.svelte';
import DiscussionVoteButtons from '../discussions/DiscussionVoteButtons.svelte';
import EventMenu from '../../components/EventMenu.svelte';
// Lazy load PollCard component (heavy component)
@ -73,58 +74,7 @@ @@ -73,58 +74,7 @@
}
});
// Calculate votes as derived values to avoid infinite loops
// Deduplicate by pubkey - each user should only count once per vote type
let upvotes = $derived.by(() => {
if (post.kind !== KIND.DISCUSSION_THREAD) return 0;
const reactionEvents = reactions;
if (!reactionEvents || !Array.isArray(reactionEvents)) return 0;
const upvotePubkeys = new Set<string>();
const upvoteEvents: NostrEvent[] = [];
for (const r of reactionEvents) {
const content = r.content.trim();
if (content === '+' || content === '⬆' || content === '↑') {
upvotePubkeys.add(r.pubkey);
upvoteEvents.push(r);
}
}
const count = upvotePubkeys.size;
if (previewMode && count > 0) {
console.log(`[FeedPost] Upvotes calculated for thread ${post.id.substring(0, 16)}... (previewMode):`, {
count,
uniquePubkeys: Array.from(upvotePubkeys).map(p => p.substring(0, 16) + '...'),
reactionEvents: upvoteEvents.map(r => ({
id: r.id.substring(0, 16) + '...',
pubkey: r.pubkey.substring(0, 16) + '...',
content: r.content,
fullEvent: r
})),
allReactions: reactionEvents.map(r => ({
id: r.id.substring(0, 16) + '...',
pubkey: r.pubkey.substring(0, 16) + '...',
content: r.content,
fullEvent: r
}))
});
}
return count;
});
let downvotes = $derived.by(() => {
if (post.kind !== KIND.DISCUSSION_THREAD) return 0;
const reactionEvents = reactions;
if (!reactionEvents || !Array.isArray(reactionEvents)) return 0;
const downvotePubkeys = new Set<string>();
for (const r of reactionEvents) {
const content = r.content.trim();
if (content === '-' || content === '⬇' || content === '↓') {
downvotePubkeys.add(r.pubkey);
}
}
return downvotePubkeys.size;
});
// Vote counting is handled by DiscussionVoteButtons component - no calculation here
// Derive the effective parent event: prefer provided, fall back to loaded
let parentEvent = $derived(providedParentEvent || loadedParentEvent);
@ -170,18 +120,32 @@ @@ -170,18 +120,32 @@
}
});
onMount(async () => {
let isMounted = $state(true);
let zapCountTimeout: ReturnType<typeof setTimeout> | null = null;
onMount(() => {
isMounted = true;
// Only load zap count if not provided (fallback for edge cases)
// In most cases, FeedPage will have pre-loaded the zap count
// Wait longer to give FeedPage time to batch load all zaps at once
if (providedZapCount === undefined) {
// Delay loading to give FeedPage time to batch load
setTimeout(() => {
if (providedZapCount === undefined) {
// Delay loading significantly to give FeedPage time to batch load
// FeedPage loads secondary data after posts are displayed
zapCountTimeout = setTimeout(() => {
if (providedZapCount === undefined && isMounted) {
loadZapCount();
}
}, 1000);
}, 3000); // Wait 3 seconds for FeedPage to batch load
}
// Votes are now calculated as derived values, no need to load separately
return () => {
isMounted = false;
if (zapCountTimeout) {
clearTimeout(zapCountTimeout);
zapCountTimeout = null;
}
};
});
async function loadZapCount() {
@ -469,20 +433,7 @@ @@ -469,20 +433,7 @@
</div>
{/if}
<div class="flex items-center justify-between text-xs text-fog-text dark:text-fog-dark-text">
<div class="flex items-center gap-2">
{#if post.kind === KIND.DISCUSSION_THREAD && (upvotes > 0 || downvotes > 0)}
<span class="vote-counts text-fog-text-light dark:text-fog-dark-text-light">
{#if upvotes > 0}
<span class="upvotes"> {upvotes}</span>
{/if}
{#if downvotes > 0}
<span class="downvotes ml-2"> {downvotes}</span>
{/if}
</span>
{/if}
</div>
</div>
<!-- Vote counts are shown in full mode, not in preview mode -->
</div>
</a>
{:else}
@ -576,24 +527,18 @@ @@ -576,24 +527,18 @@
<!-- Post actions (reactions, etc.) - always visible, outside collapsible content -->
<div class="post-actions flex flex-wrap items-center gap-2 sm:gap-4">
{#if post.kind === KIND.DISCUSSION_THREAD}
<!-- Show vote counts for threads -->
{#if upvotes > 0 || downvotes > 0}
<span class="vote-counts text-xs text-fog-text-light dark:text-fog-dark-text-light">
<span class="upvotes"> {upvotes}</span>
{#if downvotes > 0}
<span class="downvotes ml-2"> {downvotes}</span>
{/if}
</span>
{/if}
{/if}
{#if zapCount > 0}
<span class="zap-count-display">
<span class="zap-emoji"></span>
<span class="zap-count-number">{zapCount}</span>
</span>
{/if}
<FeedReactionButtons event={post} preloadedReactions={reactions} />
{#if post.kind === KIND.DISCUSSION_THREAD}
<!-- DiscussionVoteButtons includes both vote counts and buttons -->
<DiscussionVoteButtons event={post} preloadedReactions={reactions} />
{:else}
<FeedReactionButtons event={post} preloadedReactions={reactions} />
{/if}
{#if onReply}
<button
onclick={() => onReply(post)}
@ -669,6 +614,8 @@ @@ -669,6 +614,8 @@
max-height: 500px;
overflow: hidden;
transition: max-height 0.3s ease;
/* Ensure footer below is not affected by overflow */
position: relative;
}
.card-content.expanded {
@ -766,6 +713,7 @@ @@ -766,6 +713,7 @@
line-height: 1.5;
}
.post-header :global(.profile-badge span) {
line-height: 1.5;
vertical-align: middle;

210
src/lib/modules/reactions/FeedReactionButtons.svelte

@ -13,11 +13,10 @@ @@ -13,11 +13,10 @@
interface Props {
event: NostrEvent;
forceUpvoteDownvote?: boolean; // Force upvote/downvote mode (for kind 1111 replies to kind 11 threads)
preloadedReactions?: NostrEvent[]; // Optional pre-loaded reactions to avoid duplicate fetches
}
let { event, forceUpvoteDownvote = false, preloadedReactions }: Props = $props();
let { event, preloadedReactions }: Props = $props();
let reactions = $state<Map<string, { content: string; pubkeys: Set<string>; eventIds: Map<string, string> }>>(new Map());
let userReaction = $state<string | null>(null);
@ -135,7 +134,7 @@ @@ -135,7 +134,7 @@
loading = true;
try {
// Use getProfileReadRelays() to include defaultRelays + profileRelays + user inbox + localRelays
// This ensures we get all reactions from the complete relay set, matching ThreadList behavior
// This ensures we get all reactions from the complete relay set, matching DiscussionList behavior
const reactionRelays = relayManager.getProfileReadRelays();
// Clear and rebuild reactions map for this event
@ -228,20 +227,6 @@ @@ -228,20 +227,6 @@
let content = reactionEvent.content.trim();
const originalContent = content;
// For kind 11 events (or kind 1111 replies to kind 11), normalize reactions: only + and - allowed
// Backward compatibility: ⬆/↑ = +, ⬇/↓ = -
if (event.kind === KIND.DISCUSSION_THREAD || forceUpvoteDownvote) {
if (content === '⬆' || content === '↑') {
content = '+';
} else if (content === '⬇' || content === '↓') {
content = '-';
} else if (content !== '+' && content !== '-') {
skippedInvalid++;
// Silently skip invalid reactions - no need to log every one
continue; // Skip invalid reactions for threads
}
}
if (!reactionMap.has(content)) {
reactionMap.set(content, { content, pubkeys: new Set(), eventIds: new Map() });
}
@ -336,11 +321,6 @@ @@ -336,11 +321,6 @@
return;
}
// For kind 11 events (or kind 1111 replies to kind 11), only allow + and - (upvote/downvote)
if ((event.kind === KIND.DISCUSSION_THREAD || forceUpvoteDownvote) && content !== '+' && content !== '-') {
return;
}
// If clicking the same reaction, delete it
if (userReaction === content) {
// Remove reaction by publishing a kind 5 deletion event
@ -394,43 +374,6 @@ @@ -394,43 +374,6 @@
return;
}
// For kind 11 (or kind 1111 replies to kind 11): if user has the opposite vote, delete it first
if ((event.kind === KIND.DISCUSSION_THREAD || forceUpvoteDownvote) && userReaction && userReaction !== content) {
// Delete the existing vote first
if (userReactionEventId) {
try {
const deletionEvent: Omit<NostrEvent, 'id' | 'sig'> = {
kind: KIND.EVENT_DELETION,
pubkey: sessionManager.getCurrentPubkey()!,
created_at: Math.floor(Date.now() / 1000),
tags: [['e', userReactionEventId]],
content: ''
};
const config = nostrClient.getConfig();
await signAndPublish(deletionEvent, [...config.defaultRelays]);
// Update local state for the old reaction
const oldReaction = reactions.get(userReaction);
if (oldReaction) {
const currentUser = sessionManager.getCurrentPubkey();
if (currentUser) {
oldReaction.pubkeys.delete(currentUser);
oldReaction.eventIds.delete(currentUser);
if (oldReaction.pubkeys.size === 0) {
reactions.delete(userReaction);
}
}
}
} catch (error) {
console.error('Error deleting old reaction:', error);
}
}
// Clear the old reaction state
userReaction = null;
userReactionEventId = null;
}
try {
const tags: string[][] = [
['e', event.id],
@ -593,79 +536,55 @@ @@ -593,79 +536,55 @@
</script>
<div class="Feed-reaction-buttons flex gap-2 items-center flex-wrap">
{#if event.kind === KIND.DISCUSSION_THREAD || forceUpvoteDownvote}
<!-- Kind 11 (Thread) or Kind 1111 (Reply to Thread): Only upvote and downvote buttons -->
<button
onclick={() => toggleReaction('+')}
disabled={!isLoggedIn}
class="reaction-btn vote-btn {userReaction === '+' ? 'active' : ''} {!isLoggedIn ? 'disabled' : ''}"
title={isLoggedIn ? "Upvote" : "Login to vote"}
aria-label="Upvote"
>
<span class="vote-count {getReactionCount('+') > 0 ? 'has-votes' : ''}">{getReactionCount('+')}</span>
</button>
<button
onclick={() => toggleReaction('-')}
disabled={!isLoggedIn}
class="reaction-btn vote-btn {userReaction === '-' ? 'active' : ''} {!isLoggedIn ? 'disabled' : ''}"
title={isLoggedIn ? "Downvote" : "Login to vote"}
aria-label="Downvote"
<!-- Full reaction menu for feed posts -->
{#if isLoggedIn}
<div class="reaction-wrapper">
<button
bind:this={menuButton}
onclick={handleHeartClick}
class="reaction-btn heart-btn {userReaction === '+' ? 'active' : ''}"
title="Like or choose reaction"
aria-label="Like or choose reaction"
>
</button>
<EmojiPicker
open={showMenu}
onSelect={(emoji) => {
toggleReaction(emoji);
showMenu = false;
}}
onClose={() => { showMenu = false; }}
/>
</div>
{/if}
{#each getAllReactions() as { content, count }}
<span
class="reaction-display {userReaction === content ? 'active' : ''}"
title={content === '+' ? 'Liked' : `Reacted with ${content}`}
>
<span class="vote-count {getReactionCount('-') > 0 ? 'has-votes' : ''}">{getReactionCount('-')}</span>
</button>
{:else}
<!-- Kind 1 (Feed): Full reaction menu -->
{#if isLoggedIn}
<div class="reaction-wrapper">
<button
bind:this={menuButton}
onclick={handleHeartClick}
class="reaction-btn heart-btn {userReaction === '+' ? 'active' : ''}"
title="Like or choose reaction"
aria-label="Like or choose reaction"
>
</button>
<EmojiPicker
open={showMenu}
onSelect={(emoji) => {
toggleReaction(emoji);
showMenu = false;
}}
onClose={() => { showMenu = false; }}
/>
</div>
{/if}
{#if event.kind !== KIND.DISCUSSION_THREAD}
{#each getAllReactions() as { content, count }}
<span
class="reaction-display {userReaction === content ? 'active' : ''}"
title={content === '+' ? 'Liked' : `Reacted with ${content}`}
>
{#if content === '+'}
{:else if content.startsWith(':') && content.endsWith(':')}
<!-- Text emoji - try to display as image if URL available -->
{#if hasEmojiUrl(content)}
{@const url = getCustomEmojiUrl(content)}
{#if url}
<img src={url} alt={content} class="custom-emoji-img" />
{:else}
<span class="text-emoji">{formatTextEmoji(content)}</span>
{/if}
{:else}
<span class="text-emoji">{formatTextEmoji(content)}</span>
{/if}
{#if content === '+'}
{:else if content.startsWith(':') && content.endsWith(':')}
<!-- Text emoji - try to display as image if URL available -->
{#if hasEmojiUrl(content)}
{@const url = getCustomEmojiUrl(content)}
{#if url}
<img src={url} alt={content} class="custom-emoji-img" />
{:else}
{content}
<span class="text-emoji">{formatTextEmoji(content)}</span>
{/if}
<span class="reaction-count-text">{count}</span>
</span>
{/each}
{/if}
{/if}
{:else}
<span class="text-emoji">{formatTextEmoji(content)}</span>
{/if}
{:else}
{content}
{/if}
<span class="reaction-count-text">{count}</span>
</span>
{/each}
</div>
<style>
@ -690,20 +609,15 @@ @@ -690,20 +609,15 @@
color: var(--fog-dark-text, #f9fafb);
}
.reaction-btn:hover:not(.disabled) {
.reaction-btn:hover {
background: var(--fog-highlight, #f3f4f6);
border-color: var(--fog-accent, #64748b);
}
:global(.dark) .reaction-btn:hover:not(.disabled) {
:global(.dark) .reaction-btn:hover {
background: var(--fog-dark-highlight, #374151);
}
.reaction-btn.disabled {
opacity: 0.6;
cursor: not-allowed;
}
.reaction-btn.active {
background: var(--fog-accent, #64748b);
color: var(--fog-text, #475569);
@ -768,29 +682,5 @@ @@ -768,29 +682,5 @@
color: var(--fog-dark-text, #f9fafb);
}
.vote-btn {
display: inline-flex;
align-items: center;
gap: 0.25rem;
}
.vote-count {
transition: color 0.2s;
color: #9ca3af !important; /* gray-400 for zeros */
}
.vote-count.has-votes {
color: #3b82f6 !important; /* blue-500 - brighter blue like checkbox */
font-weight: 600; /* semi-bold */
}
:global(.dark) .vote-count {
color: #6b7280 !important; /* gray-500 for zeros in dark mode */
}
:global(.dark) .vote-count.has-votes {
color: #60a5fa !important; /* blue-400 for dark mode - brighter than before */
font-weight: 600; /* semi-bold */
}
</style>

257
src/lib/modules/reactions/ReactionButtons.svelte

@ -1,257 +0,0 @@ @@ -1,257 +0,0 @@
<script lang="ts">
import { sessionManager } from '../../services/auth/session-manager.js';
import { signAndPublish } from '../../services/nostr/auth-handler.js';
import { nostrClient } from '../../services/nostr/nostr-client.js';
import { onMount } from 'svelte';
import type { NostrEvent } from '../../types/nostr.js';
import { KIND } from '../../types/kind-lookup.js';
import { shouldIncludeClientTag } from '../../services/client-tag-preference.js';
interface Props {
event: NostrEvent; // The event to react to (kind 11 or 1111)
}
let { event }: Props = $props();
let reactions = $state<Map<string, { content: string; pubkeys: Set<string> }>>(new Map());
let userReaction = $state<string | null>(null);
let loading = $state(true);
onMount(async () => {
await nostrClient.initialize();
loadReactions();
});
async function loadReactions() {
loading = true;
try {
const config = nostrClient.getConfig();
// Fetch reactions (kind 7) for this event
const filters = [
{
kinds: [KIND.REACTION],
'#e': [event.id]
}
];
const reactionEvents = await nostrClient.fetchEvents(
filters,
[...config.defaultRelays],
{ useCache: true, cacheResults: true, onUpdate: (updated) => {
processReactions(updated);
}}
);
processReactions(reactionEvents);
} catch (error) {
console.error('Error loading reactions:', error);
} finally {
loading = false;
}
}
function processReactions(reactionEvents: NostrEvent[]) {
const reactionMap = new Map<string, { content: string; pubkeys: Set<string> }>();
const currentUser = sessionManager.getCurrentPubkey();
for (const reactionEvent of reactionEvents) {
const content = reactionEvent.content.trim();
// Normalize reactions for kind 11/1111: only + and - allowed
// Backward compatibility: ⬆ = +, ⬇ = -
let normalizedContent = content;
if (event.kind === KIND.DISCUSSION_THREAD || event.kind === KIND.COMMENT) {
if (content === '⬆' || content === '↑') {
normalizedContent = '+';
} else if (content === '⬇' || content === '↓') {
normalizedContent = '-';
} else if (content !== '+' && content !== '-') {
continue; // Skip invalid reactions for threads/comments
}
}
if (!reactionMap.has(normalizedContent)) {
reactionMap.set(normalizedContent, { content: normalizedContent, pubkeys: new Set() });
}
reactionMap.get(normalizedContent)!.pubkeys.add(reactionEvent.pubkey);
// Track user's reaction
if (currentUser && reactionEvent.pubkey === currentUser) {
userReaction = normalizedContent;
}
}
reactions = reactionMap;
}
async function toggleReaction(content: string) {
if (!sessionManager.isLoggedIn()) {
alert('Please log in to react');
return;
}
// For kind 11/1111, only allow + and -
if ((event.kind === KIND.DISCUSSION_THREAD || event.kind === KIND.COMMENT) && content !== '+' && content !== '-') {
return;
}
// If clicking the same reaction, remove it
if (userReaction === content) {
// TODO: Implement reaction deletion (kind 5 or remove event)
// For now, just remove from UI
userReaction = null;
const reaction = reactions.get(content);
if (reaction) {
const currentUser = sessionManager.getCurrentPubkey();
if (currentUser) {
reaction.pubkeys.delete(currentUser);
if (reaction.pubkeys.size === 0) {
reactions.delete(content);
}
}
}
return;
}
// Publish new reaction
try {
const tags: string[][] = [
['e', event.id],
['p', event.pubkey],
['k', event.kind.toString()]
];
if (sessionManager.getCurrentPubkey() && shouldIncludeClientTag()) {
tags.push(['client', 'aitherboard']);
}
const reactionEvent: Omit<NostrEvent, 'id' | 'sig'> = {
kind: 7,
pubkey: sessionManager.getCurrentPubkey()!,
created_at: Math.floor(Date.now() / 1000),
tags,
content
};
const config = nostrClient.getConfig();
await signAndPublish(reactionEvent, [...config.defaultRelays]);
// Update local state
userReaction = content;
const currentPubkey = sessionManager.getCurrentPubkey()!;
if (!reactions.has(content)) {
reactions.set(content, { content, pubkeys: new Set([currentPubkey]) });
} else {
reactions.get(content)!.pubkeys.add(currentPubkey);
}
} catch (error) {
console.error('Error publishing reaction:', error);
alert('Error publishing reaction');
}
}
function getReactionDisplay(content: string): string {
if (content === '+') {
return event.kind === KIND.SHORT_TEXT_NOTE ? '❤' : '↑';
}
if (content === '-') {
return '↓';
}
return content;
}
function getReactionCount(content: string): number {
return reactions.get(content)?.pubkeys.size || 0;
}
const isLoggedIn = $derived(sessionManager.isLoggedIn());
</script>
{#if isLoggedIn}
<div class="reaction-buttons flex gap-2 items-center">
{#if event.kind === KIND.DISCUSSION_THREAD || event.kind === KIND.COMMENT}
<!-- Thread/Comment reactions: Only + and - -->
<button
onclick={() => toggleReaction('+')}
class="reaction-btn {userReaction === '+' ? 'active' : ''}"
title="Upvote"
aria-label="Upvote"
>
{getReactionCount('+')}
</button>
<button
onclick={() => toggleReaction('-')}
class="reaction-btn {userReaction === '-' ? 'active' : ''}"
title="Downvote"
aria-label="Downvote"
>
{getReactionCount('-')}
</button>
{:else}
<!-- Kind 1 reactions: All reactions allowed, default + -->
<button
onclick={() => toggleReaction('+')}
class="reaction-btn {userReaction === '+' ? 'active' : ''}"
title="Like"
aria-label="Like"
>
{getReactionCount('+')}
</button>
<!-- Other reactions could go in a submenu -->
{/if}
</div>
{/if}
<style>
.reaction-buttons {
display: flex;
align-items: center;
}
.reaction-btn {
padding: 0.25rem 0.75rem;
border: 1px solid var(--fog-border, #e5e7eb);
border-radius: 0.25rem;
background: var(--fog-post, #ffffff);
color: var(--fog-text, #1f2937);
cursor: pointer;
transition: all 0.2s;
font-size: 0.875rem;
line-height: 1.5;
display: inline-flex;
align-items: center;
}
:global(.dark) .reaction-btn {
background: var(--fog-dark-post, #1f2937);
border-color: var(--fog-dark-border, #374151);
color: var(--fog-dark-text, #f9fafb);
}
.reaction-btn:hover {
background: var(--fog-highlight, #f3f4f6);
border-color: var(--fog-accent, #64748b);
}
:global(.dark) .reaction-btn:hover {
background: var(--fog-dark-highlight, #374151);
}
.reaction-btn.active {
background: var(--fog-accent, #64748b);
color: var(--fog-text, #475569);
border-color: var(--fog-accent, #64748b);
}
:global(.dark) .reaction-btn.active {
background: var(--fog-dark-accent, #64748b);
color: var(--fog-dark-text, #cbd5e1);
border-color: var(--fog-dark-accent, #64748b);
}
.reaction-btn:disabled {
opacity: 0.5;
cursor: not-allowed;
}
</style>

4
src/lib/types/kind-lookup.ts

@ -111,8 +111,8 @@ export const KIND_LOOKUP: Record<number, KindInfo> = { @@ -111,8 +111,8 @@ export const KIND_LOOKUP: Record<number, KindInfo> = {
[KIND.HIGHLIGHTED_ARTICLE]: { number: KIND.HIGHLIGHTED_ARTICLE, description: 'Highlighted Article', showInFeed: true, isSecondaryKind: false },
// Threads and comments
[KIND.DISCUSSION_THREAD]: { number: KIND.DISCUSSION_THREAD, description: 'Discussion Thread', showInFeed: true, isSecondaryKind: false },
[KIND.COMMENT]: { number: KIND.COMMENT, description: 'Comment', showInFeed: true, isSecondaryKind: true },
[KIND.DISCUSSION_THREAD]: { number: KIND.DISCUSSION_THREAD, description: 'Discussion Thread', showInFeed: false, isSecondaryKind: false }, // Only shown on /discussions page
[KIND.COMMENT]: { number: KIND.COMMENT, description: 'Comment', showInFeed: true, isSecondaryKind: true }, // Only shown on /discussions page
// Media
[KIND.PICTURE_NOTE]: { number: KIND.PICTURE_NOTE, description: 'Picture Note', showInFeed: true, isSecondaryKind: false },

84
src/routes/+page.svelte

@ -1,85 +1,11 @@ @@ -1,85 +1,11 @@
<script lang="ts">
import Header from '../lib/components/layout/Header.svelte';
import ThreadList from '../lib/modules/threads/ThreadList.svelte';
import SearchBox from '../lib/components/layout/SearchBox.svelte';
import { nostrClient } from '../lib/services/nostr/nostr-client.js';
import { goto } from '$app/navigation';
import { onMount } from 'svelte';
onMount(async () => {
await nostrClient.initialize();
onMount(() => {
// Redirect root to /discussions
goto('/discussions', { replaceState: true });
});
</script>
<Header />
<main class="container mx-auto px-4 py-8">
<div class="discussions-header mb-4">
<div>
<h1 class="text-2xl font-bold mb-4 text-fog-text dark:text-fog-dark-text font-mono">/Discussions</h1>
<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>
<a href="/write?kind=11" class="write-button" title="Write a new thread">
<span class="emoji emoji-grayscale"></span>
</a>
</div>
<div class="search-section mb-6">
<SearchBox />
</div>
<ThreadList />
</main>
<style>
main {
max-width: var(--content-width);
margin: 0 auto;
}
.discussions-header {
display: flex;
justify-content: space-between;
align-items: flex-start;
}
.write-button {
display: inline-flex;
align-items: center;
justify-content: center;
padding: 0.5rem;
border: 1px solid var(--fog-border, #e5e7eb);
border-radius: 0.375rem;
background: var(--fog-post, #ffffff);
color: var(--fog-text, #1f2937);
text-decoration: none;
transition: all 0.2s;
min-width: 2.5rem;
min-height: 2.5rem;
}
:global(.dark) .write-button {
border-color: var(--fog-dark-border, #374151);
background: var(--fog-dark-post, #1f2937);
color: var(--fog-dark-text, #f9fafb);
}
.write-button:hover {
background: var(--fog-highlight, #f3f4f6);
border-color: var(--fog-accent, #64748b);
}
:global(.dark) .write-button:hover {
background: var(--fog-dark-highlight, #475569);
border-color: var(--fog-dark-accent, #94a3b8);
}
.emoji {
font-size: 1.25rem;
line-height: 1;
}
.emoji-grayscale {
filter: grayscale(100%);
opacity: 0.7;
}
</style>
<!-- This page redirects to /discussions -->

33
src/routes/cache/+page.svelte vendored

@ -670,7 +670,7 @@ @@ -670,7 +670,7 @@
}
.page-title {
font-size: 2rem;
font-size: clamp(1.5rem, 4vw, 2rem);
font-weight: 700;
margin-bottom: 2rem;
color: var(--fog-text, #1f2937);
@ -681,7 +681,7 @@ @@ -681,7 +681,7 @@
}
.section-title {
font-size: 1.5rem;
font-size: clamp(1.25rem, 3vw, 1.5rem);
font-weight: 600;
margin-bottom: 1rem;
color: var(--fog-text, #1f2937);
@ -692,7 +692,7 @@ @@ -692,7 +692,7 @@
}
.subsection-title {
font-size: 1.25rem;
font-size: clamp(1.125rem, 2.5vw, 1.25rem);
font-weight: 600;
margin-bottom: 0.75rem;
margin-top: 1.5rem;
@ -742,7 +742,7 @@ @@ -742,7 +742,7 @@
}
.stat-label {
font-size: 0.875rem;
font-size: 0.875em;
color: var(--fog-text-light, #6b7280);
margin-bottom: 0.5rem;
}
@ -752,7 +752,7 @@ @@ -752,7 +752,7 @@
}
.stat-value {
font-size: 1.5rem;
font-size: clamp(1.125rem, 2.5vw, 1.5rem);
font-weight: 600;
color: var(--fog-text, #1f2937);
}
@ -819,7 +819,7 @@ @@ -819,7 +819,7 @@
border: none;
border-radius: 0.25rem;
cursor: pointer;
font-size: 0.875rem;
font-size: 0.875em;
}
.clear-kind-button:hover {
@ -839,7 +839,7 @@ @@ -839,7 +839,7 @@
}
.filter-label {
font-size: 0.875rem;
font-size: 0.875em;
font-weight: 500;
color: var(--fog-text, #1f2937);
}
@ -855,7 +855,7 @@ @@ -855,7 +855,7 @@
border-radius: 0.375rem;
background: var(--fog-post, #ffffff);
color: var(--fog-text, #1f2937);
font-size: 0.875rem;
font-size: 0.875em;
}
:global(.dark) .filter-select,
@ -878,7 +878,7 @@ @@ -878,7 +878,7 @@
border: none;
border-radius: 0.375rem;
cursor: pointer;
font-size: 0.875rem;
font-size: 0.875em;
font-weight: 500;
}
@ -938,7 +938,7 @@ @@ -938,7 +938,7 @@
.event-id-code {
font-family: monospace;
font-size: 0.875rem;
font-size: 0.875em;
background: var(--fog-highlight, #f3f4f6);
padding: 0.25rem 0.5rem;
border-radius: 0.25rem;
@ -957,7 +957,7 @@ @@ -957,7 +957,7 @@
display: flex;
flex-wrap: wrap;
gap: 1rem;
font-size: 0.875rem;
font-size: 0.875em;
color: var(--fog-text-light, #6b7280);
word-wrap: break-word;
overflow-wrap: break-word;
@ -969,7 +969,7 @@ @@ -969,7 +969,7 @@
.event-meta code {
font-family: monospace;
font-size: 0.8125rem;
font-size: 0.8125em;
word-break: break-all;
overflow-wrap: break-word;
max-width: 100%;
@ -997,7 +997,7 @@ @@ -997,7 +997,7 @@
.event-json {
font-family: monospace;
font-size: 0.8125rem;
font-size: 0.8125em;
background: var(--fog-highlight, #f3f4f6);
border: 1px solid var(--fog-border, #e5e7eb);
border-radius: 0.375rem;
@ -1030,10 +1030,11 @@ @@ -1030,10 +1030,11 @@
.event-content-preview {
margin: 0 0 0.5rem 0;
color: var(--fog-text-light, #6b7280);
font-size: 0.875rem;
font-size: 0.875em;
word-wrap: break-word;
overflow-wrap: break-word;
max-width: 100%;
line-height: 1.6;
}
:global(.dark) .event-content-preview {
@ -1047,7 +1048,7 @@ @@ -1047,7 +1048,7 @@
border-radius: 0.375rem;
color: var(--fog-text, #1f2937);
cursor: pointer;
font-size: 0.875rem;
font-size: 0.875em;
text-decoration: none;
display: inline-block;
white-space: nowrap;
@ -1101,7 +1102,7 @@ @@ -1101,7 +1102,7 @@
border: none;
border-radius: 0.375rem;
cursor: pointer;
font-size: 0.875rem;
font-size: 0.875em;
font-weight: 500;
}

85
src/routes/discussions/+page.svelte

@ -0,0 +1,85 @@ @@ -0,0 +1,85 @@
<script lang="ts">
import Header from '../../lib/components/layout/Header.svelte';
import DiscussionList from '../../lib/modules/discussions/DiscussionList.svelte';
import SearchBox from '../../lib/components/layout/SearchBox.svelte';
import { nostrClient } from '../../lib/services/nostr/nostr-client.js';
import { onMount } from 'svelte';
onMount(async () => {
await nostrClient.initialize();
});
</script>
<Header />
<main class="container mx-auto px-4 py-8">
<div class="discussions-header mb-4">
<div>
<h1 class="text-2xl font-bold mb-4 text-fog-text dark:text-fog-dark-text font-mono">/Discussions</h1>
<p class="mb-4 text-fog-text dark:text-fog-dark-text">Decentralized discussion board on Nostr.</p>
</div>
<a href="/write?kind=11" class="write-button" title="Write a new thread">
<span class="emoji emoji-grayscale"></span>
</a>
</div>
<div class="search-section mb-6">
<SearchBox />
</div>
<DiscussionList />
</main>
<style>
main {
max-width: var(--content-width);
margin: 0 auto;
}
.discussions-header {
display: flex;
justify-content: space-between;
align-items: flex-start;
}
.write-button {
display: inline-flex;
align-items: center;
justify-content: center;
padding: 0.5rem;
border: 1px solid var(--fog-border, #e5e7eb);
border-radius: 0.375rem;
background: var(--fog-post, #ffffff);
color: var(--fog-text, #1f2937);
text-decoration: none;
transition: all 0.2s;
min-width: 2.5rem;
min-height: 2.5rem;
}
:global(.dark) .write-button {
border-color: var(--fog-dark-border, #374151);
background: var(--fog-dark-post, #1f2937);
color: var(--fog-dark-text, #f9fafb);
}
.write-button:hover {
background: var(--fog-highlight, #f3f4f6);
border-color: var(--fog-accent, #64748b);
}
:global(.dark) .write-button:hover {
background: var(--fog-dark-highlight, #475569);
border-color: var(--fog-dark-accent, #94a3b8);
}
.emoji {
font-size: 1.25rem;
line-height: 1;
}
.emoji-grayscale {
filter: grayscale(100%);
opacity: 0.7;
}
</style>

23
src/routes/event/[id]/+page.svelte

@ -1,13 +1,16 @@ @@ -1,13 +1,16 @@
<script lang="ts">
import Header from '../../../lib/components/layout/Header.svelte';
import ThreadView from '../../../lib/modules/threads/ThreadView.svelte';
import DiscussionView from '../../../lib/modules/discussions/DiscussionView.svelte';
import EventView from '../../../lib/modules/events/EventView.svelte';
import { nostrClient } from '../../../lib/services/nostr/nostr-client.js';
import { relayManager } from '../../../lib/services/nostr/relay-manager.js';
import { onMount } from 'svelte';
import { page } from '$app/stores';
import { nip19 } from 'nostr-tools';
import { KIND } from '../../../lib/types/kind-lookup.js';
let decodedEventId = $state<string | null>(null);
let eventKind = $state<number | null>(null);
let loading = $state(false);
let error = $state<string | null>(null);
@ -91,11 +94,21 @@ @@ -91,11 +94,21 @@
loading = true;
error = null;
decodedEventId = null;
eventKind = null;
try {
const eventId = await decodeEventId($page.params.id);
if (eventId) {
decodedEventId = eventId;
// Fetch the event to determine its kind for routing
const threadRelays = relayManager.getThreadReadRelays();
const feedRelays = relayManager.getFeedReadRelays();
const allRelays = [...new Set([...threadRelays, ...feedRelays])];
const event = await nostrClient.getEventById(eventId, allRelays);
if (event) {
eventKind = event.kind;
}
} else {
error = 'Event not found or invalid format';
}
@ -127,7 +140,13 @@ @@ -127,7 +140,13 @@
{:else if error}
<p class="text-fog-text dark:text-fog-dark-text">{error}</p>
{:else if decodedEventId}
<ThreadView threadId={decodedEventId} />
{#if eventKind === KIND.DISCUSSION_THREAD}
<!-- Route kind 11 (discussion threads) to DiscussionView -->
<DiscussionView threadId={decodedEventId} />
{:else}
<!-- Route all other events (including kind 30040, metadata-only, etc.) to EventView -->
<EventView eventId={decodedEventId} />
{/if}
{:else if $page.params.id}
<p class="text-fog-text dark:text-fog-dark-text">Invalid event ID format. Supported: hex event ID, note, nevent, or naddr</p>
{:else}

Loading…
Cancel
Save