Browse Source

bug-fixes

master
Silberengel 1 month ago
parent
commit
2771115862
  1. 4
      public/healthz.json
  2. 441
      src/lib/components/content/MarkdownRenderer.svelte
  3. 8
      src/lib/modules/comments/Comment.svelte
  4. 56
      src/lib/modules/comments/CommentThread.svelte
  5. 108
      src/lib/modules/feed/FeedPost.svelte
  6. 382
      src/lib/modules/reactions/FeedReactionButtons.svelte
  7. 605
      src/lib/modules/threads/ThreadList.svelte

4
public/healthz.json

@ -2,7 +2,7 @@
"status": "ok", "status": "ok",
"service": "aitherboard", "service": "aitherboard",
"version": "0.1.0", "version": "0.1.0",
"buildTime": "2026-02-03T14:56:41.652Z", "buildTime": "2026-02-03T15:56:04.366Z",
"gitCommit": "unknown", "gitCommit": "unknown",
"timestamp": 1770130601652 "timestamp": 1770134164366
} }

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

@ -3,7 +3,7 @@
import { sanitizeMarkdown } from '../../services/security/sanitizer.js'; import { sanitizeMarkdown } from '../../services/security/sanitizer.js';
import { findNIP21Links } from '../../services/nostr/nip21-parser.js'; import { findNIP21Links } from '../../services/nostr/nip21-parser.js';
import { nip19 } from 'nostr-tools'; import { nip19 } from 'nostr-tools';
import { onMount, tick } from 'svelte'; import { tick } from 'svelte';
import ProfileBadge from '../layout/ProfileBadge.svelte'; import ProfileBadge from '../layout/ProfileBadge.svelte';
import EmbeddedEvent from './EmbeddedEvent.svelte'; import EmbeddedEvent from './EmbeddedEvent.svelte';
import { mountComponent } from './mount-component-action.js'; import { mountComponent } from './mount-component-action.js';
@ -16,313 +16,185 @@
let rendered = $state(''); let rendered = $state('');
let containerElement: HTMLDivElement | null = $state(null); let containerElement: HTMLDivElement | null = $state(null);
let mountedElements = $state<Set<HTMLElement>>(new Set());
// Track profile badges and embedded events to render // Process content and render markdown
let profileBadges = $state<Map<string, string>>(new Map()); // placeholder -> pubkey
let embeddedEvents = $state<Map<string, string>>(new Map()); // placeholder -> eventId
// Process placeholder divs after HTML is rendered and mount components
$effect(() => { $effect(() => {
if (!containerElement || !rendered) return; if (!content) {
rendered = '';
tick().then(() => { return;
if (!containerElement) return; }
// Mount profile badge components // Find NIP-21 links
const badgeElements = containerElement.querySelectorAll('.nostr-profile-badge-placeholder'); const links = findNIP21Links(content);
badgeElements.forEach((el) => {
const pubkey = el.getAttribute('data-pubkey'); // Replace links with placeholders before markdown parsing
const placeholder = el.getAttribute('data-placeholder'); let processed = content;
if (pubkey && placeholder && profileBadges.has(placeholder)) { const placeholders: Map<string, { uri: string; parsed: any }> = new Map();
// Don't clear if already mounted let offset = 0;
if (el.children.length === 0) {
mountComponent(el as HTMLElement, ProfileBadge as any, { pubkey }); // Process in reverse order to maintain indices
} const sortedLinks = [...links].sort((a, b) => b.start - a.start);
}
for (const link of sortedLinks) {
const placeholder = `\`NIP21PLACEHOLDER${offset}\``;
const before = processed.slice(0, link.start);
const after = processed.slice(link.end);
processed = before + placeholder + after;
placeholders.set(placeholder, { uri: link.uri, parsed: link.parsed });
offset++;
}
// Parse markdown
const parseResult = marked.parse(processed);
const processHtml = (html: string) => {
let finalHtml = sanitizeMarkdown(html);
// Process media elements
finalHtml = finalHtml.replace(/<img([^>]*)>/gi, (match, attrs) => {
if (/loading\s*=/i.test(attrs)) return match;
return `<img${attrs} loading="lazy">`;
}); });
// Mount embedded event components finalHtml = finalHtml.replace(/<video([^>]*)>/gi, (match, attrs) => {
const eventElements = containerElement.querySelectorAll('.nostr-embedded-event-placeholder'); let newAttrs = attrs.replace(/\s*autoplay\s*/gi, ' ');
eventElements.forEach((el) => { if (!/preload\s*=/i.test(newAttrs)) {
const eventId = el.getAttribute('data-event-id'); newAttrs += ' preload="none"';
const placeholder = el.getAttribute('data-placeholder'); } else {
if (eventId && placeholder && embeddedEvents.has(placeholder)) { newAttrs = newAttrs.replace(/preload\s*=\s*["']?[^"'\s>]+["']?/gi, 'preload="none"');
// Don't clear if already mounted }
if (el.children.length === 0) { if (!/autoplay\s*=/i.test(newAttrs)) {
mountComponent(el as HTMLElement, EmbeddedEvent as any, { eventId }); newAttrs += ' autoplay="false"';
}
} }
return `<video${newAttrs}>`;
}); });
});
});
// Process rendered HTML to add lazy loading and prevent autoplay finalHtml = finalHtml.replace(/<audio([^>]*)>/gi, (match, attrs) => {
function processMediaElements(html: string): string { let newAttrs = attrs.replace(/\s*autoplay\s*/gi, ' ');
// Add loading="lazy" to all images if (!/preload\s*=/i.test(newAttrs)) {
html = html.replace(/<img([^>]*)>/gi, (match, attrs) => { newAttrs += ' preload="none"';
// Don't add if already has loading attribute } else {
if (/loading\s*=/i.test(attrs)) { newAttrs = newAttrs.replace(/preload\s*=\s*["']?[^"'\s>]+["']?/gi, 'preload="none"');
return match; }
} if (!/autoplay\s*=/i.test(newAttrs)) {
return `<img${attrs} loading="lazy">`; newAttrs += ' autoplay="false"';
}); }
return `<audio${newAttrs}>`;
// Ensure videos don't autoplay and use preload="none" });
html = html.replace(/<video([^>]*)>/gi, (match, attrs) => {
let newAttrs = attrs;
// Remove autoplay if present
newAttrs = newAttrs.replace(/\s*autoplay\s*/gi, ' ');
// Set preload to none if not already set
if (!/preload\s*=/i.test(newAttrs)) {
newAttrs += ' preload="none"';
} else {
newAttrs = newAttrs.replace(/preload\s*=\s*["']?[^"'\s>]+["']?/gi, 'preload="none"');
}
// Ensure autoplay is explicitly false
if (!/autoplay\s*=/i.test(newAttrs)) {
newAttrs += ' autoplay="false"';
}
return `<video${newAttrs}>`;
});
// Ensure audio doesn't autoplay and use preload="none"
html = html.replace(/<audio([^>]*)>/gi, (match, attrs) => {
let newAttrs = attrs;
// Remove autoplay if present
newAttrs = newAttrs.replace(/\s*autoplay\s*/gi, ' ');
// Set preload to none if not already set
if (!/preload\s*=/i.test(newAttrs)) {
newAttrs += ' preload="none"';
} else {
newAttrs = newAttrs.replace(/preload\s*=\s*["']?[^"'\s>]+["']?/gi, 'preload="none"');
}
// Ensure autoplay is explicitly false
if (!/autoplay\s*=/i.test(newAttrs)) {
newAttrs += ' autoplay="false"';
}
return `<audio${newAttrs}>`;
});
return html; // Replace placeholders with component placeholders
} for (const [placeholder, { uri, parsed }] of placeholders.entries()) {
let replacement = '';
$effect(() => { try {
if (content) { if (parsed.type === 'hexID') {
// Process NIP-21 links before markdown parsing const eventId = parsed.data;
let processed = content; replacement = `<span class="nostr-embedded-event-placeholder inline-block" data-event-id="${eventId}"></span>`;
const links = findNIP21Links(content); } else {
const decoded: any = nip19.decode(parsed.data);
// Replace links with placeholders, then restore after markdown parsing
const placeholders: Map<string, { uri: string; parsed: any }> = new Map();
let offset = 0;
// Process in reverse order to maintain indices
const sortedLinks = [...links].sort((a, b) => b.start - a.start);
for (const link of sortedLinks) {
// Use a special marker that will be replaced after markdown parsing
// Use a format that markdown won't process: a code-like structure
const placeholder = `\`NIP21PLACEHOLDER${offset}\``;
const before = processed.slice(0, link.start);
const after = processed.slice(link.end);
processed = before + placeholder + after;
placeholders.set(placeholder, { uri: link.uri, parsed: link.parsed });
offset++;
}
const parseResult = marked.parse(processed); if (decoded.type === 'npub' || decoded.type === 'nprofile') {
if (parseResult instanceof Promise) { const pubkey = decoded.type === 'npub'
parseResult.then((html) => { ? String(decoded.data)
let finalHtml = sanitizeMarkdown(html); : (decoded.data?.pubkey ? String(decoded.data.pubkey) : null);
if (pubkey) {
// Process media elements for lazy loading replacement = `<span class="nostr-profile-badge-placeholder inline-block" data-pubkey="${pubkey}"></span>`;
finalHtml = processMediaElements(finalHtml);
// Replace placeholders with actual NIP-21 links/components
for (const [placeholder, { uri, parsed }] of placeholders.entries()) {
let replacement = '';
try {
// Handle hexID type (no decoding needed)
if (parsed.type === 'hexID') {
const eventId = parsed.data;
const eventPlaceholder = `EMBEDDED_EVENT_${eventId.slice(0, 8)}_${Date.now()}_${Math.random()}`;
embeddedEvents.set(eventPlaceholder, eventId);
replacement = `<span class="nostr-embedded-event-placeholder inline-block" data-event-id="${eventId}" data-placeholder="${eventPlaceholder}"></span>`;
} else {
const decoded: any = nip19.decode(parsed.data);
if (decoded.type === 'npub' || decoded.type === 'nprofile') {
const pubkey = decoded.type === 'npub'
? String(decoded.data)
: (decoded.data && typeof decoded.data === 'object' && 'pubkey' in decoded.data
? String(decoded.data.pubkey)
: null);
if (pubkey) {
// Use custom element that will be replaced with ProfileBadge component
const badgePlaceholder = `PROFILE_BADGE_${pubkey.slice(0, 8)}_${Date.now()}_${Math.random()}`;
profileBadges.set(badgePlaceholder, pubkey);
replacement = `<span class="nostr-profile-badge-placeholder inline-block" data-pubkey="${pubkey}" data-placeholder="${badgePlaceholder}"></span>`;
} else {
replacement = `<span class="nostr-link nostr-${parsed.type}">${uri}</span>`;
}
} else if (decoded.type === 'note') {
const eventId = String(decoded.data);
// Use custom element for embedded event
const eventPlaceholder = `EMBEDDED_EVENT_${eventId.slice(0, 8)}_${Date.now()}_${Math.random()}`;
embeddedEvents.set(eventPlaceholder, eventId);
replacement = `<span class="nostr-embedded-event-placeholder inline-block" data-event-id="${eventId}" data-placeholder="${eventPlaceholder}"></span>`;
} else if (decoded.type === 'nevent' && decoded.data && typeof decoded.data === 'object' && 'id' in decoded.data) {
const eventId = String(decoded.data.id);
const eventPlaceholder = `EMBEDDED_EVENT_${eventId.slice(0, 8)}_${Date.now()}_${Math.random()}`;
embeddedEvents.set(eventPlaceholder, eventId);
replacement = `<span class="nostr-embedded-event-placeholder inline-block" data-event-id="${eventId}" data-placeholder="${eventPlaceholder}"></span>`;
} else if (decoded.type === 'naddr' && decoded.data && typeof decoded.data === 'object') {
// For naddr, we'd need to fetch by kind+pubkey+d, but for now use the bech32 string
const eventPlaceholder = `EMBEDDED_EVENT_NADDR_${Date.now()}_${Math.random()}`;
embeddedEvents.set(eventPlaceholder, parsed.data); // Store the bech32 string
replacement = `<span class="nostr-embedded-event-placeholder inline-block" data-event-id="${parsed.data}" data-placeholder="${eventPlaceholder}"></span>`;
} else { } else {
replacement = `<span class="nostr-link nostr-${parsed.type}">${uri}</span>`; replacement = `<span class="nostr-link nostr-${parsed.type}">${uri}</span>`;
}
} }
} catch { } else if (decoded.type === 'note' || decoded.type === 'nevent') {
// Fallback to generic link if decoding fails const eventId = decoded.type === 'note'
const parsedType = parsed.type; ? String(decoded.data)
if (parsedType === 'npub' || parsedType === 'nprofile') { : (decoded.data?.id ? String(decoded.data.id) : null);
// Try to extract pubkey from bech32 if (eventId) {
try { replacement = `<span class="nostr-embedded-event-placeholder inline-block" data-event-id="${eventId}"></span>`;
const decoded: any = nip19.decode(parsed.data);
const pubkey = decoded.type === 'npub'
? String(decoded.data)
: (decoded.data && typeof decoded.data === 'object' && 'pubkey' in decoded.data
? String(decoded.data.pubkey)
: null);
if (pubkey) {
const badgePlaceholder = `PROFILE_BADGE_${pubkey.slice(0, 8)}_${Date.now()}_${Math.random()}`;
profileBadges.set(badgePlaceholder, pubkey);
replacement = `<span class="nostr-profile-badge-placeholder inline-block" data-pubkey="${pubkey}" data-placeholder="${badgePlaceholder}"></span>`;
} else {
replacement = `<span class="nostr-link nostr-${parsedType}">${uri}</span>`;
}
} catch {
replacement = `<span class="nostr-link nostr-${parsedType}">${uri}</span>`;
}
} else { } else {
replacement = `<span class="nostr-link nostr-${parsedType}">${uri}</span>`;
}
}
// Replace placeholder - it will be in a <code> tag after markdown parsing
// The placeholder is like `NIP21PLACEHOLDER0`, which becomes <code>NIP21PLACEHOLDER0</code>
const placeholderText = placeholder.replace(/`/g, ''); // Remove backticks
const codePlaceholder = `<code>${placeholderText}</code>`;
// Escape special regex characters
const escapedCodePlaceholder = codePlaceholder.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
finalHtml = finalHtml.replace(new RegExp(escapedCodePlaceholder, 'g'), replacement);
// Also try without code tag (in case markdown didn't process it)
const escapedPlaceholder = placeholderText.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
finalHtml = finalHtml.replace(new RegExp(escapedPlaceholder, 'g'), replacement);
}
// Clean up any remaining placeholders (fallback) - look for code tags with our placeholder
finalHtml = finalHtml.replace(/<code>NIP21PLACEHOLDER\d+<\/code>/g, '');
finalHtml = finalHtml.replace(/NIP21PLACEHOLDER\d+/g, '');
rendered = finalHtml;
});
} else {
let finalHtml = sanitizeMarkdown(parseResult);
// Process media elements for lazy loading
finalHtml = processMediaElements(finalHtml);
// Replace placeholders with actual NIP-21 links/components
for (const [placeholder, { uri, parsed }] of placeholders.entries()) {
let replacement = '';
try {
// Handle hexID type (no decoding needed)
if (parsed.type === 'hexID') {
const eventId = parsed.data;
const eventPlaceholder = `EMBEDDED_EVENT_${eventId.slice(0, 8)}_${Date.now()}_${Math.random()}`;
embeddedEvents.set(eventPlaceholder, eventId);
replacement = `<span class="nostr-embedded-event-placeholder inline-block" data-event-id="${eventId}" data-placeholder="${eventPlaceholder}"></span>`;
} else {
const decoded: any = nip19.decode(parsed.data);
if (decoded.type === 'npub' || decoded.type === 'nprofile') {
const pubkey = decoded.type === 'npub'
? String(decoded.data)
: (decoded.data && typeof decoded.data === 'object' && 'pubkey' in decoded.data
? String(decoded.data.pubkey)
: null);
if (pubkey) {
const badgePlaceholder = `PROFILE_BADGE_${pubkey.slice(0, 8)}_${Date.now()}_${Math.random()}`;
profileBadges.set(badgePlaceholder, pubkey);
replacement = `<span class="nostr-profile-badge-placeholder inline-block" data-pubkey="${pubkey}" data-placeholder="${badgePlaceholder}"></span>`;
} else {
replacement = `<span class="nostr-link nostr-${parsed.type}">${uri}</span>`;
}
} else if (decoded.type === 'note') {
const eventId = String(decoded.data);
const eventPlaceholder = `EMBEDDED_EVENT_${eventId.slice(0, 8)}_${Date.now()}_${Math.random()}`;
embeddedEvents.set(eventPlaceholder, eventId);
replacement = `<span class="nostr-embedded-event-placeholder inline-block" data-event-id="${eventId}" data-placeholder="${eventPlaceholder}"></span>`;
} else if (decoded.type === 'nevent' && decoded.data && typeof decoded.data === 'object' && 'id' in decoded.data) {
const eventId = String(decoded.data.id);
const eventPlaceholder = `EMBEDDED_EVENT_${eventId.slice(0, 8)}_${Date.now()}_${Math.random()}`;
embeddedEvents.set(eventPlaceholder, eventId);
replacement = `<span class="nostr-embedded-event-placeholder inline-block" data-event-id="${eventId}" data-placeholder="${eventPlaceholder}"></span>`;
} else if (decoded.type === 'naddr' && decoded.data && typeof decoded.data === 'object') {
const eventPlaceholder = `EMBEDDED_EVENT_NADDR_${Date.now()}_${Math.random()}`;
embeddedEvents.set(eventPlaceholder, parsed.data);
replacement = `<span class="nostr-embedded-event-placeholder inline-block" data-event-id="${parsed.data}" data-placeholder="${eventPlaceholder}"></span>`;
} else {
replacement = `<span class="nostr-link nostr-${parsed.type}">${uri}</span>`;
}
}
} catch {
// Fallback to generic link if decoding fails
if (parsed.type === 'npub' || parsed.type === 'nprofile') {
try {
const decoded: any = nip19.decode(parsed.data);
const pubkey = decoded.type === 'npub'
? String(decoded.data)
: (decoded.data && typeof decoded.data === 'object' && 'pubkey' in decoded.data
? String(decoded.data.pubkey)
: null);
if (pubkey) {
const badgePlaceholder = `PROFILE_BADGE_${pubkey.slice(0, 8)}_${Date.now()}_${Math.random()}`;
profileBadges.set(badgePlaceholder, pubkey);
replacement = `<div class="nostr-profile-badge-placeholder" data-pubkey="${pubkey}" data-placeholder="${badgePlaceholder}"></div>`;
} else {
replacement = `<span class="nostr-link nostr-${parsed.type}">${uri}</span>`;
}
} catch {
replacement = `<span class="nostr-link nostr-${parsed.type}">${uri}</span>`; replacement = `<span class="nostr-link nostr-${parsed.type}">${uri}</span>`;
} }
} else if (decoded.type === 'naddr') {
// For naddr, store the bech32 string
replacement = `<span class="nostr-embedded-event-placeholder inline-block" data-event-id="${parsed.data}"></span>`;
} else { } else {
replacement = `<span class="nostr-link nostr-${parsed.type}">${uri}</span>`; replacement = `<span class="nostr-link nostr-${parsed.type}">${uri}</span>`;
} }
} }
} catch {
// Replace placeholder - it will be in a <code> tag after markdown parsing replacement = `<span class="nostr-link nostr-${parsed.type || 'unknown'}">${uri}</span>`;
const placeholderText = placeholder.replace(/`/g, ''); // Remove backticks
const codePlaceholder = `<code>${placeholderText}</code>`;
// Escape special regex characters
const escapedCodePlaceholder = codePlaceholder.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
finalHtml = finalHtml.replace(new RegExp(escapedCodePlaceholder, 'g'), replacement);
// Also try without code tag (in case markdown didn't process it)
const escapedPlaceholder = placeholderText.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
finalHtml = finalHtml.replace(new RegExp(escapedPlaceholder, 'g'), replacement);
} }
// Clean up any remaining placeholders (fallback) // Replace placeholder in HTML
finalHtml = finalHtml.replace(/\u200B\u200B\u200BNIP21_LINK_\d+\u200B\u200B\u200B/g, ''); const placeholderText = placeholder.replace(/`/g, '');
const codePlaceholder = `<code>${placeholderText}</code>`;
rendered = finalHtml; const escapedCodePlaceholder = codePlaceholder.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
finalHtml = finalHtml.replace(new RegExp(escapedCodePlaceholder, 'g'), replacement);
const escapedPlaceholder = placeholderText.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
finalHtml = finalHtml.replace(new RegExp(escapedPlaceholder, 'g'), replacement);
} }
// Clean up any remaining placeholders
finalHtml = finalHtml.replace(/<code>NIP21PLACEHOLDER\d+<\/code>/g, '');
finalHtml = finalHtml.replace(/NIP21PLACEHOLDER\d+/g, '');
return finalHtml;
};
if (parseResult instanceof Promise) {
parseResult.then(processHtml).then(html => {
rendered = html;
// Clear mounted elements when content changes
mountedElements.clear();
// Mount components after a delay
tick().then(() => tick()).then(() => {
mountComponents();
});
});
} else { } else {
rendered = ''; rendered = processHtml(parseResult);
// Clear mounted elements when content changes
mountedElements.clear();
// Mount components after a delay
tick().then(() => tick()).then(() => {
mountComponents();
});
}
});
// Mount components into placeholder elements
function mountComponents() {
if (!containerElement) return;
// Mount profile badges
const badgeElements = containerElement.querySelectorAll('.nostr-profile-badge-placeholder:not([data-mounted])');
badgeElements.forEach((el) => {
if (mountedElements.has(el as HTMLElement)) return;
const pubkey = el.getAttribute('data-pubkey');
if (pubkey) {
mountComponent(el as HTMLElement, ProfileBadge as any, { pubkey });
el.setAttribute('data-mounted', 'true');
mountedElements.add(el as HTMLElement);
}
});
// Mount embedded events
const eventElements = containerElement.querySelectorAll('.nostr-embedded-event-placeholder:not([data-mounted])');
eventElements.forEach((el) => {
if (mountedElements.has(el as HTMLElement)) return;
const eventId = el.getAttribute('data-event-id');
if (eventId) {
mountComponent(el as HTMLElement, EmbeddedEvent as any, { eventId });
el.setAttribute('data-mounted', 'true');
mountedElements.add(el as HTMLElement);
}
});
}
// Mount components when container is available and rendered changes
$effect(() => {
if (containerElement && rendered) {
tick().then(() => tick()).then(() => {
mountComponents();
});
} }
}); });
</script> </script>
@ -419,7 +291,6 @@
vertical-align: middle; vertical-align: middle;
} }
/* Style emojis in content */
.markdown-content :global(span[role="img"]), .markdown-content :global(span[role="img"]),
.markdown-content :global(.emoji) { .markdown-content :global(.emoji) {
filter: grayscale(100%) sepia(15%) hue-rotate(200deg) saturate(60%) brightness(0.95); filter: grayscale(100%) sepia(15%) hue-rotate(200deg) saturate(60%) brightness(0.95);

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

@ -2,6 +2,7 @@
import ProfileBadge from '../../components/layout/ProfileBadge.svelte'; import ProfileBadge from '../../components/layout/ProfileBadge.svelte';
import MarkdownRenderer from '../../components/content/MarkdownRenderer.svelte'; import MarkdownRenderer from '../../components/content/MarkdownRenderer.svelte';
import ReplyContext from '../../components/content/ReplyContext.svelte'; import ReplyContext from '../../components/content/ReplyContext.svelte';
import FeedReactionButtons from '../reactions/FeedReactionButtons.svelte';
import type { NostrEvent } from '../../types/nostr.js'; import type { NostrEvent } from '../../types/nostr.js';
import { getKindInfo } from '../../types/kind-lookup.js'; import { getKindInfo } from '../../types/kind-lookup.js';
@ -9,9 +10,10 @@
comment: NostrEvent; comment: NostrEvent;
parentEvent?: NostrEvent; parentEvent?: NostrEvent;
onReply?: (comment: NostrEvent) => void; onReply?: (comment: NostrEvent) => void;
rootEventKind?: number; // The kind of the root event (e.g., 11 for threads)
} }
let { comment, parentEvent, onReply }: Props = $props(); let { comment, parentEvent, onReply, rootEventKind }: Props = $props();
let expanded = $state(false); let expanded = $state(false);
let contentElement: HTMLElement | null = $state(null); let contentElement: HTMLElement | null = $state(null);
let needsExpansion = $state(false); let needsExpansion = $state(false);
@ -89,7 +91,9 @@
<MarkdownRenderer content={comment.content} /> <MarkdownRenderer content={comment.content} />
</div> </div>
<div class="comment-actions flex gap-2"> <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 <button
onclick={handleReply} onclick={handleReply}
class="text-xs text-fog-accent dark:text-fog-dark-accent hover:underline" class="text-xs text-fog-accent dark:text-fog-dark-accent hover:underline"

56
src/lib/modules/comments/CommentThread.svelte

@ -30,31 +30,28 @@
await nostrClient.initialize(); await nostrClient.initialize();
}); });
// Reload comments when threadId or event changes // Reload comments when threadId changes
$effect(() => { $effect(() => {
if (threadId) { if (!threadId) {
// Access event to make it a dependency of this effect return;
// This ensures the effect re-runs when event changes from undefined to the actual event }
const currentEvent = event;
const currentIsKind1 = isKind1;
// Prevent concurrent loads
if (loadingPromise) {
return;
}
// Load comments - filters will adapt based on whether event is available // Prevent concurrent loads
// Ensure nostrClient is initialized first if (loadingPromise) {
loadingPromise = nostrClient.initialize().then(() => { return;
return loadComments();
}).catch((error) => {
console.error('Error initializing nostrClient in CommentThread:', error);
// Still try to load comments even if initialization fails
return loadComments();
}).finally(() => {
loadingPromise = null;
});
} }
// Load comments - filters will adapt based on whether event is available
// Ensure nostrClient is initialized first
loadingPromise = nostrClient.initialize().then(() => {
return loadComments();
}).catch((error) => {
console.error('Error initializing nostrClient in CommentThread:', error);
// Still try to load comments even if initialization fails
return loadComments();
}).finally(() => {
loadingPromise = null;
});
}); });
/** /**
@ -421,10 +418,22 @@
// Everything else gets kind 1111 // Everything else gets kind 1111
return 1111; return 1111;
} }
// Calculate total comment count (includes all reply types)
const totalCommentCount = $derived.by(() => {
return getThreadItems().length;
});
</script> </script>
<div class="comment-thread"> <div class="comment-thread">
<h2 class="text-xl font-bold mb-4">Comments</h2> <h2 class="text-xl font-bold mb-4">
Comments
{#if !loading && totalCommentCount > 0}
<span class="text-base font-normal text-fog-text-light dark:text-fog-dark-text-light ml-2">
({totalCommentCount})
</span>
{/if}
</h2>
{#if loading} {#if loading}
<p class="text-fog-text-light dark:text-fog-dark-text-light">Loading comments...</p> <p class="text-fog-text-light dark:text-fog-dark-text-light">Loading comments...</p>
@ -439,6 +448,7 @@
comment={item.event} comment={item.event}
parentEvent={parent} parentEvent={parent}
onReply={handleReply} onReply={handleReply}
rootEventKind={rootKind}
/> />
{:else if item.type === 'reply'} {:else if item.type === 'reply'}
<!-- Kind 1 reply - render as FeedPost --> <!-- Kind 1 reply - render as FeedPost -->

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

@ -21,9 +21,10 @@
onQuotedLoaded?: (event: NostrEvent) => void; // Callback when quoted event is loaded onQuotedLoaded?: (event: NostrEvent) => void; // Callback when quoted event is loaded
onOpenEvent?: (event: NostrEvent) => void; // Callback to open event in drawer onOpenEvent?: (event: NostrEvent) => void; // Callback to open event in drawer
previewMode?: boolean; // If true, show only title and first 150 chars of content previewMode?: boolean; // If true, show only title and first 150 chars of content
reactions?: NostrEvent[]; // Optional pre-loaded reactions (for performance)
} }
let { post, parentEvent: providedParentEvent, quotedEvent: providedQuotedEvent, onReply, onParentLoaded, onQuotedLoaded, onOpenEvent, previewMode = false }: Props = $props(); let { post, parentEvent: providedParentEvent, quotedEvent: providedQuotedEvent, onReply, onParentLoaded, onQuotedLoaded, onOpenEvent, previewMode = false, reactions }: Props = $props();
let loadedParentEvent = $state<NostrEvent | null>(null); let loadedParentEvent = $state<NostrEvent | null>(null);
let loadingParent = $state(false); let loadingParent = $state(false);
@ -32,6 +33,59 @@
let needsExpansion = $state(false); let needsExpansion = $state(false);
let zapCount = $state(0); let zapCount = $state(0);
// 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 !== 11) 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 !== 11) 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;
});
// Derive the effective parent event: prefer provided, fall back to loaded // Derive the effective parent event: prefer provided, fall back to loaded
let parentEvent = $derived(providedParentEvent || loadedParentEvent); let parentEvent = $derived(providedParentEvent || loadedParentEvent);
@ -55,6 +109,7 @@
} }
// Load zap receipt count // Load zap receipt count
await loadZapCount(); await loadZapCount();
// Votes are now calculated as derived values, no need to load separately
}); });
async function loadZapCount() { async function loadZapCount() {
@ -201,6 +256,10 @@
return plaintext.slice(0, 150) + (plaintext.length > 150 ? '...' : ''); return plaintext.slice(0, 150) + (plaintext.length > 150 ? '...' : '');
} }
function getTopics(): string[] {
return post.tags.filter(t => t[0] === 't').map(t => t[1]);
}
function handlePostClick(e: MouseEvent) { function handlePostClick(e: MouseEvent) {
// Don't open drawer if clicking on interactive elements // Don't open drawer if clicking on interactive elements
const target = e.target as HTMLElement; const target = e.target as HTMLElement;
@ -270,16 +329,38 @@
<span class="text-sm text-fog-text-light dark:text-fog-dark-text-light">{getRelativeTime()}</span> <span class="text-sm text-fog-text-light dark:text-fog-dark-text-light">{getRelativeTime()}</span>
</div> </div>
<div class="mb-2 flex items-center gap-2"> <div class="mb-2 flex items-center gap-2 flex-wrap">
<ProfileBadge pubkey={post.pubkey} /> <ProfileBadge pubkey={post.pubkey} />
{#if getClientName()} {#if getClientName()}
<span class="text-xs text-fog-text-light dark:text-fog-dark-text-light">via {getClientName()}</span> <span class="text-xs text-fog-text-light dark:text-fog-dark-text-light">via {getClientName()}</span>
{/if} {/if}
{#if post.kind === 11}
{@const topics = getTopics()}
{#if topics.length === 0}
<span class="topic-badge text-xs px-2 py-0.5 rounded bg-fog-border dark:bg-fog-dark-border text-fog-text-light dark:text-fog-dark-text-light">General</span>
{:else}
{#each topics.slice(0, 3) as topic}
<span class="topic-badge text-xs px-2 py-0.5 rounded bg-fog-border dark:bg-fog-dark-border text-fog-text-light dark:text-fog-dark-text-light">{topic}</span>
{/each}
{/if}
{/if}
</div> </div>
<p class="text-sm mb-2">{getPreviewContent()}</p> <p class="text-sm mb-2">{getPreviewContent()}</p>
<div class="flex items-center justify-end text-xs text-fog-text dark:text-fog-dark-text"> <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 === 11 && (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>
<a href="/thread/{post.id}" class="text-fog-accent dark:text-fog-dark-accent hover:underline">View thread →</a> <a href="/thread/{post.id}" class="text-fog-accent dark:text-fog-dark-accent hover:underline">View thread →</a>
</div> </div>
</div> </div>
@ -315,6 +396,16 @@
{#if isReply()} {#if isReply()}
<span class="text-xs text-fog-text-light dark:text-fog-dark-text-light flex-shrink-0">↳ Reply</span> <span class="text-xs text-fog-text-light dark:text-fog-dark-text-light flex-shrink-0">↳ Reply</span>
{/if} {/if}
{#if post.kind === 11}
{@const topics = post.tags.filter((t) => t[0] === 't').map((t) => t[1])}
{#if topics.length === 0}
<span class="topic-badge text-xs px-2 py-0.5 rounded bg-fog-border dark:bg-fog-dark-border text-fog-text-light dark:text-fog-dark-text-light">General</span>
{:else}
{#each topics.slice(0, 3) as topic}
<span class="topic-badge text-xs px-2 py-0.5 rounded bg-fog-border dark:bg-fog-dark-border text-fog-text-light dark:text-fog-dark-text-light">{topic}</span>
{/each}
{/if}
{/if}
</div> </div>
<div class="post-content mb-2"> <div class="post-content mb-2">
@ -323,6 +414,17 @@
</div> </div>
<div class="post-actions flex flex-wrap items-center gap-2 sm:gap-4"> <div class="post-actions flex flex-wrap items-center gap-2 sm:gap-4">
{#if post.kind === 11}
<!-- 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} {#if zapCount > 0}
<span class="zap-count-display"> <span class="zap-count-display">
<span class="zap-emoji"></span> <span class="zap-emoji"></span>

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

@ -2,6 +2,7 @@
import { sessionManager } from '../../services/auth/session-manager.js'; import { sessionManager } from '../../services/auth/session-manager.js';
import { signAndPublish } from '../../services/nostr/auth-handler.js'; import { signAndPublish } from '../../services/nostr/auth-handler.js';
import { nostrClient } from '../../services/nostr/nostr-client.js'; import { nostrClient } from '../../services/nostr/nostr-client.js';
import { relayManager } from '../../services/nostr/relay-manager.js';
import { onMount } from 'svelte'; import { onMount } from 'svelte';
import type { NostrEvent } from '../../types/nostr.js'; import type { NostrEvent } from '../../types/nostr.js';
import emojiData from 'unicode-emoji-json/data-ordered-emoji.json'; import emojiData from 'unicode-emoji-json/data-ordered-emoji.json';
@ -10,9 +11,10 @@
interface Props { interface Props {
event: NostrEvent; event: NostrEvent;
forceUpvoteDownvote?: boolean; // Force upvote/downvote mode (for kind 1111 replies to kind 11 threads)
} }
let { event }: Props = $props(); let { event, forceUpvoteDownvote = false }: Props = $props();
let reactions = $state<Map<string, { content: string; pubkeys: Set<string>; eventIds: Map<string, string> }>>(new Map()); let reactions = $state<Map<string, { content: string; pubkeys: Set<string>; eventIds: Map<string, string> }>>(new Map());
let userReaction = $state<string | null>(null); let userReaction = $state<string | null>(null);
@ -22,6 +24,7 @@
let menuButton: HTMLButtonElement | null = $state(null); let menuButton: HTMLButtonElement | null = $state(null);
let customEmojiUrls = $state<Map<string, string>>(new Map()); let customEmojiUrls = $state<Map<string, string>>(new Map());
let emojiSearchQuery = $state(''); let emojiSearchQuery = $state('');
let allReactionsMap = $state<Map<string, NostrEvent>>(new Map()); // Persist reactions map for real-time updates
let heartCount = $derived(getReactionCount('+')); let heartCount = $derived(getReactionCount('+'));
@ -68,32 +71,215 @@
}); });
}); });
// Reload reactions when event changes
$effect(() => {
if (event.id) {
// Clear previous reactions map when event changes
allReactionsMap.clear();
loadReactions();
}
});
// Handle real-time updates - process reactions when new ones arrive
async function handleReactionUpdate(updated: NostrEvent[]) {
console.log(`[FeedReactionButtons] Received reaction update for event ${event.id.substring(0, 16)}...:`, {
count: updated.length,
events: updated.map(r => ({
id: r.id.substring(0, 16) + '...',
pubkey: r.pubkey.substring(0, 16) + '...',
content: r.content,
fullEvent: r
}))
});
// Add new reactions to the map
for (const r of updated) {
allReactionsMap.set(r.id, r);
}
// Process all accumulated reactions
const allReactions = Array.from(allReactionsMap.values());
const filtered = await filterDeletedReactions(allReactions);
processReactions(filtered);
}
async function loadReactions() { async function loadReactions() {
loading = true; loading = true;
try { try {
const config = nostrClient.getConfig(); // Use getProfileReadRelays() to include defaultRelays + profileRelays + user inbox + localRelays
const filters = [{ kinds: [7], '#e': [event.id] }]; // This ensures we get all reactions from the complete relay set, matching ThreadList behavior
const reactionEvents = await nostrClient.fetchEvents( const reactionRelays = relayManager.getProfileReadRelays();
filters, console.log(`[FeedReactionButtons] Loading reactions for event ${event.id.substring(0, 16)}... (kind ${event.kind})`);
[...config.defaultRelays], console.log(`[FeedReactionButtons] Using relays:`, reactionRelays);
{ useCache: true, cacheResults: true, onUpdate: (updated) => {
processReactions(updated); // Clear and rebuild reactions map for this event
}} allReactionsMap.clear();
const reactionsWithLowerE = await nostrClient.fetchEvents(
[{ kinds: [7], '#e': [event.id] }],
reactionRelays,
{ useCache: true, cacheResults: true, onUpdate: handleReactionUpdate }
);
const reactionsWithUpperE = await nostrClient.fetchEvents(
[{ kinds: [7], '#E': [event.id] }],
reactionRelays,
{ useCache: true, cacheResults: true, onUpdate: handleReactionUpdate }
); );
processReactions(reactionEvents);
console.log(`[FeedReactionButtons] Reactions fetched:`, {
eventId: event.id.substring(0, 16) + '...',
kind: event.kind,
withLowerE: reactionsWithLowerE.length,
withUpperE: reactionsWithUpperE.length,
lowerE_events: reactionsWithLowerE.map(r => ({
id: r.id.substring(0, 16) + '...',
pubkey: r.pubkey.substring(0, 16) + '...',
content: r.content,
tags: r.tags.filter(t => t[0] === 'e' || t[0] === 'E'),
fullEvent: r
})),
upperE_events: reactionsWithUpperE.map(r => ({
id: r.id.substring(0, 16) + '...',
pubkey: r.pubkey.substring(0, 16) + '...',
content: r.content,
tags: r.tags.filter(t => t[0] === 'e' || t[0] === 'E'),
fullEvent: r
}))
});
// Combine and deduplicate by reaction ID
for (const r of reactionsWithLowerE) {
allReactionsMap.set(r.id, r);
}
for (const r of reactionsWithUpperE) {
allReactionsMap.set(r.id, r);
}
const reactionEvents = Array.from(allReactionsMap.values());
console.log(`[FeedReactionButtons] All reactions (deduplicated):`, {
total: reactionEvents.length,
events: reactionEvents.map(r => ({
id: r.id.substring(0, 16) + '...',
pubkey: r.pubkey.substring(0, 16) + '...',
content: r.content,
tags: r.tags.filter(t => t[0] === 'e' || t[0] === 'E'),
created_at: new Date(r.created_at * 1000).toISOString(),
fullEvent: r
}))
});
// Filter out deleted reactions (kind 5)
const filteredReactions = await filterDeletedReactions(reactionEvents);
console.log(`[FeedReactionButtons] After filtering deleted reactions:`, {
before: reactionEvents.length,
after: filteredReactions.length,
filtered: reactionEvents.length - filteredReactions.length,
events: filteredReactions.map(r => ({
id: r.id.substring(0, 16) + '...',
pubkey: r.pubkey.substring(0, 16) + '...',
content: r.content,
fullEvent: r
}))
});
processReactions(filteredReactions);
} catch (error) { } catch (error) {
console.error('Error loading reactions:', error); console.error('[FeedReactionButtons] Error loading reactions:', error);
} finally { } finally {
loading = false; loading = false;
} }
} }
async function filterDeletedReactions(reactions: NostrEvent[]): Promise<NostrEvent[]> {
if (reactions.length === 0) return reactions;
// Fetch deletion events (kind 5) to filter out deleted reactions
const reactionRelays = relayManager.getProfileReadRelays();
const deletionEvents = await nostrClient.fetchEvents(
[{ kinds: [5], authors: Array.from(new Set(reactions.map(r => r.pubkey))) }],
reactionRelays,
{ useCache: true }
);
console.log(`[FeedReactionButtons] Deletion events fetched:`, {
count: deletionEvents.length,
events: deletionEvents.map(d => ({
id: d.id.substring(0, 16) + '...',
pubkey: d.pubkey.substring(0, 16) + '...',
deletedEventIds: d.tags.filter(t => t[0] === 'e').map(t => t[1]?.substring(0, 16) + '...'),
fullEvent: d
}))
});
// Build a set of deleted reaction event IDs (keyed by pubkey)
const deletedReactionIdsByPubkey = new Map<string, Set<string>>();
for (const deletionEvent of deletionEvents) {
const pubkey = deletionEvent.pubkey;
if (!deletedReactionIdsByPubkey.has(pubkey)) {
deletedReactionIdsByPubkey.set(pubkey, new Set());
}
// Kind 5 events have 'e' tags pointing to deleted events
for (const tag of deletionEvent.tags) {
if (tag[0] === 'e' && tag[1]) {
deletedReactionIdsByPubkey.get(pubkey)!.add(tag[1]);
}
}
}
console.log(`[FeedReactionButtons] Deleted reaction IDs by pubkey:`,
Array.from(deletedReactionIdsByPubkey.entries()).map(([pubkey, ids]) => ({
pubkey: pubkey.substring(0, 16) + '...',
deletedIds: Array.from(ids).map(id => id.substring(0, 16) + '...')
}))
);
// Filter out deleted reactions
const filtered = reactions.filter(reaction => {
const deletedIds = deletedReactionIdsByPubkey.get(reaction.pubkey);
const isDeleted = deletedIds && deletedIds.has(reaction.id);
if (isDeleted) {
console.log(`[FeedReactionButtons] Filtering out deleted reaction:`, {
id: reaction.id.substring(0, 16) + '...',
pubkey: reaction.pubkey.substring(0, 16) + '...',
content: reaction.content,
fullEvent: reaction
});
}
return !isDeleted;
});
return filtered;
}
async function processReactions(reactionEvents: NostrEvent[]) { async function processReactions(reactionEvents: NostrEvent[]) {
console.log(`[FeedReactionButtons] Processing ${reactionEvents.length} reactions for event ${event.id.substring(0, 16)}... (kind ${event.kind})`);
const reactionMap = new Map<string, { content: string; pubkeys: Set<string>; eventIds: Map<string, string> }>(); const reactionMap = new Map<string, { content: string; pubkeys: Set<string>; eventIds: Map<string, string> }>();
const currentUser = sessionManager.getCurrentPubkey(); const currentUser = sessionManager.getCurrentPubkey();
let skippedInvalid = 0;
for (const reactionEvent of reactionEvents) { for (const reactionEvent of reactionEvents) {
const content = reactionEvent.content.trim(); 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 === 11 || forceUpvoteDownvote) {
if (content === '⬆' || content === '↑') {
content = '+';
} else if (content === '⬇' || content === '↓') {
content = '-';
} else if (content !== '+' && content !== '-') {
skippedInvalid++;
console.log(`[FeedReactionButtons] Skipping invalid reaction for kind 11:`, {
originalContent,
reactionId: reactionEvent.id.substring(0, 16) + '...',
pubkey: reactionEvent.pubkey.substring(0, 16) + '...',
fullEvent: reactionEvent
});
continue; // Skip invalid reactions for threads
}
}
if (!reactionMap.has(content)) { if (!reactionMap.has(content)) {
reactionMap.set(content, { content, pubkeys: new Set(), eventIds: new Map() }); reactionMap.set(content, { content, pubkeys: new Set(), eventIds: new Map() });
} }
@ -106,6 +292,27 @@
} }
} }
console.log(`[FeedReactionButtons] Processed reactions summary:`, {
totalReactions: reactionEvents.length,
skippedInvalid,
reactionCounts: Array.from(reactionMap.entries()).map(([content, data]) => ({
content,
count: data.pubkeys.size,
pubkeys: Array.from(data.pubkeys).map(p => p.substring(0, 16) + '...'),
eventIds: Array.from(data.eventIds.entries()).map(([pubkey, eventId]) => ({
pubkey: pubkey.substring(0, 16) + '...',
eventId: eventId.substring(0, 16) + '...'
}))
})),
userReaction,
allReactionEvents: reactionEvents.map(r => ({
id: r.id.substring(0, 16) + '...',
pubkey: r.pubkey.substring(0, 16) + '...',
content: r.content,
fullEvent: r
}))
});
reactions = reactionMap; reactions = reactionMap;
const emojiUrls = await resolveCustomEmojis(reactionMap); const emojiUrls = await resolveCustomEmojis(reactionMap);
customEmojiUrls = emojiUrls; customEmojiUrls = emojiUrls;
@ -117,6 +324,12 @@
return; return;
} }
// For kind 11 events (or kind 1111 replies to kind 11), only allow + and - (upvote/downvote)
if ((event.kind === 11 || forceUpvoteDownvote) && content !== '+' && content !== '-') {
return;
}
// If clicking the same reaction, delete it
if (userReaction === content) { if (userReaction === content) {
// Remove reaction by publishing a kind 5 deletion event // Remove reaction by publishing a kind 5 deletion event
if (userReactionEventId) { if (userReactionEventId) {
@ -169,11 +382,48 @@
return; return;
} }
// For kind 11 (or kind 1111 replies to kind 11): if user has the opposite vote, delete it first
if ((event.kind === 11 || forceUpvoteDownvote) && userReaction && userReaction !== content) {
// Delete the existing vote first
if (userReactionEventId) {
try {
const deletionEvent: Omit<NostrEvent, 'id' | 'sig'> = {
kind: 5,
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 { try {
const tags: string[][] = [ const tags: string[][] = [
['e', event.id], ['e', event.id],
['p', event.pubkey], ['p', event.pubkey],
['k', '1'] ['k', event.kind.toString()]
]; ];
if (sessionManager.getCurrentPubkey() && includeClientTag) { if (sessionManager.getCurrentPubkey() && includeClientTag) {
@ -315,16 +565,36 @@
</script> </script>
<div class="Feed-reaction-buttons flex gap-2 items-center flex-wrap"> <div class="Feed-reaction-buttons flex gap-2 items-center flex-wrap">
<div class="reaction-wrapper"> {#if event.kind === 11 || forceUpvoteDownvote}
<!-- Kind 11 (Thread) or Kind 1111 (Reply to Thread): Only upvote and downvote buttons -->
<button <button
bind:this={menuButton} onclick={() => toggleReaction('+')}
onclick={handleHeartClick} class="reaction-btn vote-btn {userReaction === '+' ? 'active' : ''}"
class="reaction-btn heart-btn {userReaction === '+' ? 'active' : ''}" title="Upvote"
title="Like or choose reaction" aria-label="Upvote"
aria-label="Like or choose reaction"
> >
<span class="vote-count {getReactionCount('+') > 0 ? 'has-votes' : ''}">{getReactionCount('+')}</span>
</button> </button>
<button
onclick={() => toggleReaction('-')}
class="reaction-btn vote-btn {userReaction === '-' ? 'active' : ''}"
title="Downvote"
aria-label="Downvote"
>
<span class="vote-count {getReactionCount('-') > 0 ? 'has-votes' : ''}">{getReactionCount('-')}</span>
</button>
{:else}
<!-- Kind 1 (Feed): Full reaction menu -->
<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>
{#if showMenu} {#if showMenu}
<div <div
@ -428,28 +698,31 @@
</div> </div>
</div> </div>
{/if} {/if}
</div> </div>
{#each getAllReactions() as { content, count }} {#if event.kind !== 11}
<span {#each getAllReactions() as { content, count }}
class="reaction-display {userReaction === content ? 'active' : ''}" <span
title={content === '+' ? 'Liked' : `Reacted with ${content}`} class="reaction-display {userReaction === content ? 'active' : ''}"
> title={content === '+' ? 'Liked' : `Reacted with ${content}`}
{#if content === '+'} >
{#if content === '+'}
{:else if isCustomEmoji(content)}
{@const url = getCustomEmojiUrl(content)} {:else if isCustomEmoji(content)}
{#if url} {@const url = getCustomEmojiUrl(content)}
<img src={url} alt={content} class="custom-emoji-img" /> {#if url}
{:else} <img src={url} alt={content} class="custom-emoji-img" />
{content} {:else}
{/if} {content}
{:else} {/if}
{content} {:else}
{/if} {content}
<span class="reaction-count-text">{count}</span> {/if}
</span> <span class="reaction-count-text">{count}</span>
{/each} </span>
{/each}
{/if}
{/if}
</div> </div>
<style> <style>
@ -777,6 +1050,31 @@
color: var(--fog-dark-text-light, #9ca3af); color: var(--fog-dark-text-light, #9ca3af);
} }
.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 */
}
@media (max-width: 768px) { @media (max-width: 768px) {
.reaction-menu-grid { .reaction-menu-grid {
grid-template-columns: repeat(8, 1fr); grid-template-columns: repeat(8, 1fr);

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

@ -1,12 +1,16 @@
<script lang="ts"> <script lang="ts">
import { nostrClient } from '../../services/nostr/nostr-client.js'; import { nostrClient } from '../../services/nostr/nostr-client.js';
import { relayManager } from '../../services/nostr/relay-manager.js'; import { relayManager } from '../../services/nostr/relay-manager.js';
import { onMount } from 'svelte';
import FeedPost from '../feed/FeedPost.svelte'; import FeedPost from '../feed/FeedPost.svelte';
import ThreadDrawer from '../feed/ThreadDrawer.svelte'; import ThreadDrawer from '../feed/ThreadDrawer.svelte';
import type { NostrEvent } from '../../types/nostr.js'; import type { NostrEvent } from '../../types/nostr.js';
let threads = $state<NostrEvent[]>([]); // Data maps - all data loaded upfront
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 loading = $state(true); let loading = $state(true);
let sortBy = $state<'newest' | 'active' | 'upvoted'>('newest'); let sortBy = $state<'newest' | 'active' | 'upvoted'>('newest');
let showOlder = $state(false); let showOlder = $state(false);
@ -16,30 +20,18 @@
let drawerOpen = $state(false); let drawerOpen = $state(false);
let selectedEvent = $state<NostrEvent | null>(null); let selectedEvent = $state<NostrEvent | null>(null);
$effect(() => { // Computed: get sorted and filtered threads from maps
loadThreads(); let threads = $derived.by(() => {
const allThreads = Array.from(threadsMap.values());
const sorted = sortThreadsFromMaps(allThreads, sortBy);
return sorted;
}); });
// Re-sort threads when sortBy changes (but not when threads changes)
let lastSortBy = $state<'newest' | 'active' | 'upvoted' | null>(null);
$effect(() => { $effect(() => {
// Only re-sort if sortBy actually changed loadAllData();
if (sortBy !== lastSortBy && threads.length > 0 && !loading) {
lastSortBy = sortBy;
if (sortBy === 'newest') {
threads = sortThreadsSync(threads);
} else {
// For async sorts, trigger the sort without blocking
// Pass sortBy explicitly to ensure we use the current value
sortThreads(threads, sortBy).then(sorted => {
threads = sorted;
});
}
}
}); });
async function loadThreads() { async function loadAllData() {
loading = true; loading = true;
try { try {
const config = nostrClient.getConfig(); const config = nostrClient.getConfig();
@ -47,117 +39,282 @@
? undefined ? undefined
: Math.floor(Date.now() / 1000) - config.threadTimeoutDays * 86400; : Math.floor(Date.now() / 1000) - config.threadTimeoutDays * 86400;
// Fetch with cache-first, background refresh const threadRelays = relayManager.getThreadReadRelays();
// onUpdate callback will refresh the UI when new data arrives const commentRelays = relayManager.getCommentReadRelays();
const relays = relayManager.getThreadReadRelays(); // Use getProfileReadRelays() for reactions to include defaultRelays + profileRelays + user inbox + localRelays
const events = await nostrClient.fetchEvents( // This ensures we get all reactions from the complete relay set
const reactionRelays = relayManager.getProfileReadRelays();
const zapRelays = relayManager.getZapReceiptReadRelays();
// Fetch all threads
const threadEvents = await nostrClient.fetchEvents(
[{ kinds: [11], since, limit: 50 }], [{ kinds: [11], since, limit: 50 }],
relays, threadRelays,
{ {
useCache: true, useCache: true,
cacheResults: true, cacheResults: true,
onUpdate: async (updatedEvents) => { onUpdate: async (updatedEvents) => {
// Update threads when fresh data arrives from relays // Update threads map when fresh data arrives
// Pass sortBy explicitly to ensure we use the current value for (const event of updatedEvents) {
threads = await sortThreads(updatedEvents, sortBy); threadsMap.set(event.id, event);
}
} }
} }
); );
// Set initial cached data immediately using current sortBy value // Build threads map
// Capture sortBy at this point to ensure we use the correct value const newThreadsMap = new Map<string, NostrEvent>();
const currentSort = sortBy; for (const event of threadEvents) {
if (currentSort === 'newest') { newThreadsMap.set(event.id, event);
threads = sortThreadsSync(events);
} else {
// Pass sortBy explicitly to ensure we use the current value
threads = await sortThreads(events, currentSort);
} }
// Update lastSortBy to match current sort so effect doesn't re-trigger threadsMap = newThreadsMap;
lastSortBy = currentSort;
} catch (error) {
console.error('Error loading threads:', error);
threads = []; // Set empty array on error to prevent undefined issues
} finally {
loading = false;
}
}
function sortThreadsSync(events: NostrEvent[]): NostrEvent[] { // Get all thread IDs
// Synchronous version for 'newest' sorting const threadIds = Array.from(newThreadsMap.keys());
return [...events].sort((a, b) => b.created_at - a.created_at);
}
async function sortThreads(events: NostrEvent[], sortType: 'newest' | 'active' | 'upvoted' = sortBy): Promise<NostrEvent[]> { if (threadIds.length > 0) {
switch (sortType) { // Fetch all comments in parallel
case 'newest':
return sortThreadsSync(events);
case 'active':
// Sort by most recent activity (comments, reactions, or zaps)
// Thread bumping: active threads rise to top
// Batch fetch all comments and reactions at once to avoid concurrent request issues
const threadIds = events.map(e => e.id);
const commentRelays = relayManager.getCommentReadRelays();
const reactionRelays = relayManager.getThreadReadRelays();
// Batch fetch all comments for all threads
const allComments = await nostrClient.fetchEvents( const allComments = await nostrClient.fetchEvents(
[{ kinds: [1111], '#E': threadIds, '#K': ['11'] }], [{ kinds: [1111], '#E': threadIds, '#K': ['11'] }],
commentRelays, commentRelays,
{ useCache: true } { useCache: true }
); );
// 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
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());
console.log('[ThreadList] Processing reaction updates, total reactions:', allReactions.length);
if (allReactions.length === 0) return;
// Fetch deletion events for current reactions
const deletionEvents = await nostrClient.fetchEvents(
[{ kinds: [5], authors: Array.from(new Set(allReactions.map(r => r.pubkey))) }],
reactionRelays,
{ useCache: true }
);
// Build deleted reaction IDs map
const deletedReactionIdsByPubkey = new Map<string, 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]);
}
}
}
// Batch fetch all reactions for all threads // Rebuild reactions map
const allReactions = await nostrClient.fetchEvents( const updatedReactionsMap = new Map<string, NostrEvent[]>();
for (const reaction of allReactions) {
const deletedIds = deletedReactionIdsByPubkey.get(reaction.pubkey);
if (deletedIds && deletedIds.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 (!updatedReactionsMap.has(threadId)) {
updatedReactionsMap.set(threadId, []);
}
updatedReactionsMap.get(threadId)!.push(reaction);
}
}
reactionsMap = updatedReactionsMap;
console.log('[ThreadList] Updated reactions map:', {
threadCounts: Array.from(updatedReactionsMap.entries()).map(([threadId, reactions]) => ({
threadId: threadId.substring(0, 16) + '...',
count: reactions.length,
upvotes: reactions.filter(r => {
const content = r.content.trim();
return content === '+' || content === '⬆' || content === '↑';
}).length,
reactionEvents: reactions.map(r => ({
id: r.id.substring(0, 16) + '...',
pubkey: r.pubkey.substring(0, 16) + '...',
content: r.content,
fullEvent: r
}))
}))
});
};
const handleReactionUpdate = async (updated: NostrEvent[]) => {
console.log('[ThreadList] Received reaction update:', {
count: updated.length,
events: updated.map(r => ({
id: r.id.substring(0, 16) + '...',
pubkey: r.pubkey.substring(0, 16) + '...',
content: r.content,
tags: r.tags.filter(t => t[0] === 'e' || t[0] === 'E'),
fullEvent: r
}))
});
for (const r of updated) {
allReactionsMap.set(r.id, r);
}
// Reprocess reactions when updates arrive
await processReactionUpdates();
};
const reactionsWithLowerE = await nostrClient.fetchEvents(
[{ kinds: [7], '#e': threadIds }], [{ kinds: [7], '#e': threadIds }],
reactionRelays, reactionRelays,
{ useCache: true } {
); useCache: true,
onUpdate: handleReactionUpdate
}
);
// Try uppercase filter, but some relays reject it - that's okay
let reactionsWithUpperE: NostrEvent[] = [];
try {
reactionsWithUpperE = await nostrClient.fetchEvents(
[{ kinds: [7], '#E': threadIds }],
reactionRelays,
{
useCache: true,
onUpdate: handleReactionUpdate
}
);
} catch (error) {
console.log('[ThreadList] Upper case #E filter rejected by relay (this is normal):', error);
}
console.log('[ThreadList] Reactions fetched:', {
withLowerE: reactionsWithLowerE.length,
withUpperE: reactionsWithUpperE.length,
lowerE_events: reactionsWithLowerE,
upperE_events: reactionsWithUpperE
});
// Group comments and reactions by thread ID // Combine and deduplicate by reaction ID
const commentsByThread = new Map<string, NostrEvent[]>(); for (const r of reactionsWithLowerE) {
const reactionsByThread = new Map<string, NostrEvent[]>(); allReactionsMap.set(r.id, r);
}
for (const r of reactionsWithUpperE) {
allReactionsMap.set(r.id, r);
}
const allReactions = Array.from(allReactionsMap.values());
console.log('[ThreadList] All reactions (deduplicated):', {
total: allReactions.length,
events: allReactions.map(r => ({
id: r.id.substring(0, 16) + '...',
pubkey: r.pubkey.substring(0, 16) + '...',
content: r.content,
tags: r.tags.filter(t => t[0] === 'e' || t[0] === 'E'),
created_at: new Date(r.created_at * 1000).toISOString()
}))
});
// Fetch all zap receipts in parallel
const allZapReceipts = await nostrClient.fetchEvents(
[{ kinds: [9735], '#e': threadIds }],
zapRelays,
{ useCache: true }
);
// Build maps
const newCommentsMap = new Map<string, NostrEvent[]>();
let newReactionsMap = new Map<string, NostrEvent[]>();
const newZapReceiptsMap = new Map<string, NostrEvent[]>();
// Process initial reactions (this will set newReactionsMap)
await processReactionUpdates();
newReactionsMap = reactionsMap; // Use the processed reactions map
// Group comments by thread ID
for (const comment of allComments) { for (const comment of allComments) {
const threadId = comment.tags.find(t => t[0] === 'E' || t[0] === 'e')?.[1]; const threadId = comment.tags.find(t => t[0] === 'E' || t[0] === 'e')?.[1];
if (threadId) { if (threadId && newThreadsMap.has(threadId)) {
if (!commentsByThread.has(threadId)) { if (!newCommentsMap.has(threadId)) {
commentsByThread.set(threadId, []); newCommentsMap.set(threadId, []);
} }
commentsByThread.get(threadId)!.push(comment); newCommentsMap.get(threadId)!.push(comment);
} }
} }
for (const reaction of allReactions) { // Reactions are already processed by processReactionUpdates() above
const threadId = reaction.tags.find(t => t[0] === 'e')?.[1]; // newReactionsMap is now set from reactionsMap
if (threadId) {
if (!reactionsByThread.has(threadId)) { // Group zap receipts by thread ID
reactionsByThread.set(threadId, []); for (const zapReceipt of allZapReceipts) {
const threadId = zapReceipt.tags.find(t => t[0] === 'e')?.[1];
if (threadId && newThreadsMap.has(threadId)) {
if (!newZapReceiptsMap.has(threadId)) {
newZapReceiptsMap.set(threadId, []);
} }
reactionsByThread.get(threadId)!.push(reaction); newZapReceiptsMap.get(threadId)!.push(zapReceipt);
} }
} }
// Calculate last activity for each thread commentsMap = newCommentsMap;
reactionsMap = newReactionsMap;
zapReceiptsMap = newZapReceiptsMap;
} else {
// Clear maps if no threads
commentsMap = new Map();
reactionsMap = new Map();
zapReceiptsMap = new Map();
}
} catch (error) {
console.error('Error loading thread data:', error);
threadsMap = new Map();
commentsMap = new Map();
reactionsMap = new Map();
zapReceiptsMap = new Map();
} finally {
loading = false;
}
}
// Sort threads from the maps (synchronous, no fetching)
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 (comments, reactions, or zaps)
const activeSorted = events.map((event) => { const activeSorted = events.map((event) => {
const comments = commentsByThread.get(event.id) || []; const comments = commentsMap.get(event.id) || [];
const reactions = reactionsByThread.get(event.id) || []; const reactions = reactionsMap.get(event.id) || [];
const zapReceipts = zapReceiptsMap.get(event.id) || [];
const lastCommentTime = comments.length > 0 const lastCommentTime = comments.length > 0
? Math.max(...comments.map(c => c.created_at)) ? Math.max(...comments.map(c => c.created_at))
: 0; : 0;
const lastReactionTime = reactions.length > 0 const lastReactionTime = reactions.length > 0
? Math.max(...reactions.map(r => r.created_at)) ? Math.max(...reactions.map(r => r.created_at))
: 0; : 0;
const lastZapTime = zapReceipts.length > 0
? Math.max(...zapReceipts.map(z => z.created_at))
: 0;
const lastActivity = Math.max( const lastActivity = Math.max(
event.created_at, event.created_at,
lastCommentTime, lastCommentTime,
lastReactionTime lastReactionTime,
); lastZapTime
);
return { event, lastActivity }; return { event, lastActivity };
}); });
return activeSorted return activeSorted
@ -165,35 +322,12 @@
.map(({ event }) => event); .map(({ event }) => event);
case 'upvoted': case 'upvoted':
// Sort by upvote count // Sort by upvote count
// Batch fetch all reactions at once to avoid concurrent request issues
const allThreadIds = events.map(e => e.id);
const reactionRelaysForUpvotes = relayManager.getThreadReadRelays();
const allReactionsForUpvotes = await nostrClient.fetchEvents(
[{ kinds: [7], '#e': allThreadIds }],
reactionRelaysForUpvotes,
{ useCache: true }
);
// Group reactions by thread ID
const reactionsByThreadForUpvotes = new Map<string, NostrEvent[]>();
for (const reaction of allReactionsForUpvotes) {
const threadId = reaction.tags.find(t => t[0] === 'e')?.[1];
if (threadId) {
if (!reactionsByThreadForUpvotes.has(threadId)) {
reactionsByThreadForUpvotes.set(threadId, []);
}
reactionsByThreadForUpvotes.get(threadId)!.push(reaction);
}
}
// Calculate upvote count for each thread
const upvotedSorted = events.map((event) => { const upvotedSorted = events.map((event) => {
const reactions = reactionsByThreadForUpvotes.get(event.id) || []; const reactions = reactionsMap.get(event.id) || [];
const upvoteCount = reactions.filter( const upvoteCount = reactions.filter(
(r) => r.content.trim() === '+' || r.content.trim() === '⬆' || r.content.trim() === '↑' (r) => r.content.trim() === '+' || r.content.trim() === '⬆' || r.content.trim() === '↑'
).length; ).length;
return { event, upvotes: upvoteCount }; return { event, upvotes: upvoteCount };
}); });
return upvotedSorted return upvotedSorted
@ -204,6 +338,39 @@
} }
} }
/**
* Filter threads by age (30 days)
*/
function filterByAge(events: NostrEvent[]): NostrEvent[] {
if (showOlder) {
return events; // Show all threads if "show older" is checked
}
const config = nostrClient.getConfig();
const cutoffTime = Math.floor(Date.now() / 1000) - config.threadTimeoutDays * 86400;
return events.filter((t) => t.created_at >= cutoffTime);
}
// Get filtered threads (by age and topic) - reactive derived value
let filteredThreads = $derived.by(() => {
let filtered = threads;
// Filter by age first
filtered = filterByAge(filtered);
// Then filter by topic
// selectedTopic === null means "All" - show all threads
if (selectedTopic === null) {
return filtered;
}
// selectedTopic === undefined means "General" - show threads without topics
if (selectedTopic === undefined) {
return filtered.filter((t) => !t.tags.some((tag) => tag[0] === 't'));
}
// selectedTopic is a string - show threads with that topic
return filtered.filter((t) => t.tags.some((tag) => tag[0] === 't' && tag[1] === selectedTopic));
});
function getTopics(): string[] { function getTopics(): string[] {
const topicSet = new Set<string>(); const topicSet = new Set<string>();
// Use age-filtered threads for topic extraction // Use age-filtered threads for topic extraction
@ -238,48 +405,6 @@
return result; return result;
} }
/**
* Filter threads by age (30 days)
*/
function filterByAge(events: NostrEvent[]): NostrEvent[] {
if (showOlder) {
return events; // Show all threads if "show older" is checked
}
const config = nostrClient.getConfig();
const cutoffTime = Math.floor(Date.now() / 1000) - config.threadTimeoutDays * 86400;
return events.filter((t) => t.created_at >= cutoffTime);
}
function getFilteredThreads(): NostrEvent[] {
let filtered = threads;
// Filter by age first
filtered = filterByAge(filtered);
// Then filter by topic
// selectedTopic === null means "All" - show all threads (handled in template)
// selectedTopic === undefined means "General" - show threads without topics
if (selectedTopic === undefined) {
return filtered.filter((t) => !t.tags.some((tag) => tag[0] === 't'));
}
// selectedTopic is a string - show threads with that topic
return filtered.filter((t) => t.tags.some((tag) => tag[0] === 't' && tag[1] === selectedTopic));
}
function getThreadsByTopic(topic: string | null): NostrEvent[] {
let filtered = threads;
// Filter by age first
filtered = filterByAge(filtered);
// Then filter by topic
if (topic === null) {
return filtered.filter((t) => !t.tags.some((tag) => tag[0] === 't'));
}
return filtered.filter((t) => t.tags.some((tag) => tag[0] === 't' && tag[1] === topic));
}
function openThreadDrawer(event: NostrEvent, e?: MouseEvent) { function openThreadDrawer(event: NostrEvent, e?: MouseEvent) {
// Don't open drawer if clicking on interactive elements // Don't open drawer if clicking on interactive elements
if (e) { if (e) {
@ -299,45 +424,45 @@
</script> </script>
<div class="thread-list"> <div class="thread-list">
<!-- Top row: Sorting and Show Older checkbox -->
<div class="controls mb-4 flex gap-4 items-center flex-wrap"> <div class="controls mb-4 flex gap-4 items-center flex-wrap">
<label class="text-fog-text dark:text-fog-dark-text"> <select
bind:value={sortBy}
class="border border-fog-border dark:border-fog-dark-border bg-fog-post dark:bg-fog-dark-post text-fog-text dark:text-fog-dark-text px-2 py-1 rounded"
>
<option value="newest">Newest</option>
<option value="active">Most Active</option>
<option value="upvoted">Most Upvoted</option>
</select>
<label class="text-fog-text dark:text-fog-dark-text flex items-center gap-2">
<input <input
type="checkbox" type="checkbox"
bind:checked={showOlder} bind:checked={showOlder}
onchange={() => { onchange={() => {
// If showing older threads, reload to fetch them // If showing older threads, reload to fetch them
// If hiding older threads, just filter client-side (no reload needed)
if (showOlder) { if (showOlder) {
loadThreads(); loadAllData();
} }
}} }}
class="mr-2"
/> />
Show older threads Show older posts (than 30 days)
</label> </label>
<select bind:value={sortBy} class="border border-fog-border dark:border-fog-dark-border bg-fog-post dark:bg-fog-dark-post text-fog-text dark:text-fog-dark-text px-2 py-1 rounded">
<option value="newest">Newest</option>
<option value="active">Most Active</option>
<option value="upvoted">Most Upvoted</option>
</select>
</div> </div>
{#if loading} <!-- Filter by topic buttons -->
<p class="text-fog-text dark:text-fog-dark-text">Loading threads...</p> <div class="mb-6">
{:else} <div class="flex flex-wrap gap-2 items-center">
<!-- Topic Filter --> <span class="text-sm font-semibold text-fog-text dark:text-fog-dark-text mr-2">Filter by topic:</span>
<div class="mb-6"> <button
<div class="flex flex-wrap gap-2 items-center"> onclick={() => (selectedTopic = null)}
<span class="text-sm font-semibold text-fog-text dark:text-fog-dark-text mr-2">Filter by topic:</span> class="px-3 py-1 rounded border transition-colors {selectedTopic === null
<button ? 'bg-fog-accent dark:bg-fog-dark-accent text-white border-fog-accent dark:border-fog-dark-accent'
onclick={() => (selectedTopic = null)} : 'bg-fog-post dark:bg-fog-dark-post text-fog-text dark:text-fog-dark-text border-fog-border dark:border-fog-dark-border hover:bg-fog-highlight dark:hover:bg-fog-dark-highlight'}"
class="px-3 py-1 rounded border transition-colors {selectedTopic === null >
? 'bg-fog-accent dark:bg-fog-dark-accent text-white border-fog-accent dark:border-fog-dark-accent' All ({filterByAge(threads).length})
: 'bg-fog-post dark:bg-fog-dark-post text-fog-text dark:text-fog-dark-text border-fog-border dark:border-fog-dark-border hover:bg-fog-highlight dark:hover:bg-fog-dark-highlight'}" </button>
> {#each getTopicsWithCounts() as { topic, count }}
All ({filterByAge(threads).length})
</button>
{#each getTopicsWithCounts() as { topic, count }}
<button <button
onclick={() => (selectedTopic = topic === null ? undefined : topic)} onclick={() => (selectedTopic = topic === null ? undefined : topic)}
class="px-3 py-1 rounded border transition-colors {selectedTopic === (topic === null ? undefined : topic) class="px-3 py-1 rounded border transition-colors {selectedTopic === (topic === null ? undefined : topic)
@ -346,78 +471,38 @@
> >
{topic === null ? 'General' : topic} ({count}) {topic === null ? 'General' : topic} ({count})
</button> </button>
{/each} {/each}
</div>
</div> </div>
</div>
<!-- Threads Display --> <!-- Thread list -->
{#if loading}
<p class="text-fog-text dark:text-fog-dark-text">Loading threads...</p>
{:else}
<div> <div>
{#if selectedTopic === null} {#each filteredThreads as thread}
<!-- Show all threads grouped by topic --> <div
<h2 class="text-xl font-bold mb-4 text-fog-text dark:text-fog-dark-text">General</h2> data-thread-id={thread.id}
{#each getThreadsByTopic(null) as thread} class="thread-wrapper"
<div onclick={(e) => openThreadDrawer(thread, e)}
data-thread-id={thread.id} role="button"
class="thread-wrapper" tabindex="0"
onclick={(e) => openThreadDrawer(thread, e)} onkeydown={(e) => {
role="button" if (e.key === 'Enter' || e.key === ' ') {
tabindex="0" e.preventDefault();
onkeydown={(e) => { openThreadDrawer(thread);
if (e.key === 'Enter' || e.key === ' ') { }
e.preventDefault(); }}
openThreadDrawer(thread); >
} <FeedPost
}} post={thread}
> previewMode={true}
<FeedPost post={thread} previewMode={true} /> reactions={reactionsMap.get(thread.id) || []}
</div> />
{/each} </div>
{/each}
{#each getTopics() as topic} {#if filteredThreads.length === 0}
<h2 class="text-xl font-bold mb-4 mt-8 text-fog-text dark:text-fog-dark-text">{topic}</h2> <p class="text-fog-text-light dark:text-fog-dark-text-light">No threads found.</p>
{#each getThreadsByTopic(topic) as thread}
<div
data-thread-id={thread.id}
class="thread-wrapper"
onclick={(e) => openThreadDrawer(thread, e)}
role="button"
tabindex="0"
onkeydown={(e) => {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault();
openThreadDrawer(thread);
}
}}
>
<FeedPost post={thread} previewMode={true} />
</div>
{/each}
{/each}
{:else}
<!-- Show filtered threads -->
<h2 class="text-xl font-bold mb-4 text-fog-text dark:text-fog-dark-text">
{selectedTopic === undefined ? 'General' : selectedTopic}
</h2>
{#each getFilteredThreads() as thread}
<div
data-thread-id={thread.id}
class="thread-wrapper"
onclick={(e) => openThreadDrawer(thread, e)}
role="button"
tabindex="0"
onkeydown={(e) => {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault();
openThreadDrawer(thread);
}
}}
>
<FeedPost post={thread} previewMode={true} />
</div>
{/each}
{#if getFilteredThreads().length === 0}
<p class="text-fog-text-light dark:text-fog-dark-text-light">No threads found in this topic.</p>
{/if}
{/if} {/if}
</div> </div>
{/if} {/if}

Loading…
Cancel
Save