Browse Source

bug-fixes

master
Silberengel 1 month ago
parent
commit
f70b9216d2
  1. 4
      src/lib/components/content/EmbeddedEvent.svelte
  2. 45
      src/lib/components/content/MarkdownRenderer.svelte
  3. 136
      src/lib/components/content/MediaAttachments.svelte
  4. 151
      src/lib/components/content/MediaViewer.svelte
  5. 1
      src/lib/components/content/MetadataCard.svelte
  6. 79
      src/lib/components/layout/Header.svelte
  7. 35
      src/lib/components/layout/ProfileBadge.svelte
  8. 50
      src/lib/modules/comments/CommentThread.svelte
  9. 16
      src/lib/modules/discussions/DiscussionCard.svelte
  10. 19
      src/lib/modules/discussions/DiscussionVoteButtons.svelte
  11. 500
      src/lib/modules/feed/FeedPage.svelte
  12. 891
      src/lib/modules/feed/FeedPost.svelte
  13. 252
      src/lib/modules/feed/ThreadDrawer.svelte
  14. 19
      src/lib/modules/reactions/FeedReactionButtons.svelte
  15. 41
      src/routes/topics/[name]/+page.svelte

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

@ -246,7 +246,7 @@ @@ -246,7 +246,7 @@
<div class="embedded-event" onclick={handleClick} role="button" tabindex="0" onkeydown={(e) => { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); handleClick(e as any); } }}>
{#if getImageUrl()}
<div class="embedded-event-image">
<img src={getImageUrl()} alt={getTitle()} loading="lazy" />
<img src={getImageUrl()} alt={getTitle()} />
</div>
{:else}
{@const contentImages = getImageUrlsFromContent()}
@ -254,7 +254,7 @@ @@ -254,7 +254,7 @@
<div class="embedded-event-images">
{#each contentImages.slice(0, 3) as imageUrl}
<div class="embedded-event-image">
<img src={imageUrl} alt={getTitle()} loading="lazy" />
<img src={imageUrl} alt={getTitle()} />
</div>
{/each}
</div>

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

@ -11,10 +11,8 @@ @@ -11,10 +11,8 @@
import { mountComponent } from './mount-component-action.js';
import type { NostrEvent } from '../../types/nostr.js';
// Lazy load EmbeddedEvent component (heavy component) - will be loaded on demand
let EmbeddedEventComponent: any = null;
let embeddedEventLoading = $state(false);
let mountingEmbeddedEvents = $state(false); // Guard for mounting, separate from loading component
import EmbeddedEvent from './EmbeddedEvent.svelte';
let mountingEmbeddedEvents = $state(false); // Guard for mounting
interface Props {
content: string;
@ -145,7 +143,7 @@ @@ -145,7 +143,7 @@
const escapedUrl = escapeHtml(url);
if (type === 'image') {
result = result.substring(0, index) + `<img src="${escapedUrl}" alt="" loading="lazy" style="max-width: 600px; width: 100%; height: auto;" />` + result.substring(endIndex);
result = result.substring(0, index) + `<img src="${escapedUrl}" alt="" style="max-width: 600px; width: 100%; height: auto;" />` + result.substring(endIndex);
} else if (type === 'video') {
result = result.substring(0, index) + `<video src="${escapedUrl}" controls preload="none" style="max-width: 600px; width: 100%; height: auto; max-height: 500px;"></video>` + result.substring(endIndex);
} else if (type === 'audio') {
@ -617,7 +615,7 @@ @@ -617,7 +615,7 @@
if (url.startsWith('http://') || url.startsWith('https://') || url.startsWith('data:')) {
const escapedUrl = escapeHtml(url);
const escapedAlt = escapeHtml(alt);
return `<img src="${escapedUrl}" alt="${escapedAlt}" loading="lazy" style="max-width: 600px; width: 100%; height: auto;" />`;
return `<img src="${escapedUrl}" alt="${escapedAlt}" style="max-width: 600px; width: 100%; height: auto;" />`;
}
// If not a valid URL, remove the markdown syntax to prevent 404s
return alt || '';
@ -629,7 +627,7 @@ @@ -629,7 +627,7 @@
// If it's a valid image URL, convert to a proper img tag
if (url.startsWith('http://') || url.startsWith('https://') || url.startsWith('data:')) {
const escapedUrl = escapeHtml(url);
return `<img src="${escapedUrl}" alt="" loading="lazy" style="max-width: 600px; width: 100%; height: auto;" />`;
return `<img src="${escapedUrl}" alt="" style="max-width: 600px; width: 100%; height: auto;" />`;
}
// Otherwise, remove to prevent 404s
return '';
@ -646,7 +644,7 @@ @@ -646,7 +644,7 @@
// If it's an image URL, convert to proper img tag
if (/\.(png|jpg|jpeg|gif|webp|svg|bmp|ico)(\?[^\s<>"']*)?$/i.test(url)) {
const escapedUrl = escapeHtml(url);
return `>${before}<img src="${escapedUrl}" alt="" loading="lazy" />${after}<`;
return `>${before}<img src="${escapedUrl}" alt="" />${after}<`;
}
}
// Otherwise, remove the problematic pattern to prevent 404s
@ -733,26 +731,8 @@ @@ -733,26 +731,8 @@
}
}
// Lazy load EmbeddedEvent component when needed
async function loadEmbeddedEventComponent() {
if (EmbeddedEventComponent) return EmbeddedEventComponent;
if (embeddedEventLoading) return null;
embeddedEventLoading = true;
try {
const module = await import('./EmbeddedEvent.svelte');
EmbeddedEventComponent = module.default;
return EmbeddedEventComponent;
} catch (error) {
console.error('Error loading EmbeddedEvent component:', error);
return null;
} finally {
embeddedEventLoading = false;
}
}
// Mount EmbeddedEvent components after rendering (lazy loaded)
async function mountEmbeddedEvents() {
// Mount EmbeddedEvent components after rendering
function mountEmbeddedEvents() {
if (!containerRef || mountingEmbeddedEvents) return;
// Find all event placeholders and mount EmbeddedEvent components
@ -779,13 +759,6 @@ @@ -779,13 +759,6 @@
if (validPlaceholders.length > 0) {
console.debug(`Mounting ${validPlaceholders.length} EmbeddedEvent components`);
// Load component only when we have placeholders to mount
const Component = await loadEmbeddedEventComponent();
if (!Component) {
console.warn('Failed to load EmbeddedEvent component');
return;
}
validPlaceholders.forEach((placeholder) => {
const eventId = placeholder.getAttribute('data-event-id');
if (eventId) {
@ -795,7 +768,7 @@ @@ -795,7 +768,7 @@
// Clear and mount component
placeholder.innerHTML = '';
// Mount EmbeddedEvent component - it will decode and fetch the event
const instance = mountComponent(placeholder as HTMLElement, Component as any, { eventId });
const instance = mountComponent(placeholder as HTMLElement, EmbeddedEvent as any, { eventId });
if (!instance) {
console.warn('EmbeddedEvent mount returned null', { eventId });

136
src/lib/components/content/MediaAttachments.svelte

@ -1,6 +1,5 @@ @@ -1,6 +1,5 @@
<script lang="ts">
import type { NostrEvent } from '../../types/nostr.js';
import { onMount } from 'svelte';
interface Props {
event: NostrEvent;
@ -8,10 +7,6 @@ @@ -8,10 +7,6 @@
let { event }: Props = $props();
// Track which media items should be loaded
let loadedMedia = $state<Set<string>>(new Set());
let mediaRefs = $state<Map<string, HTMLElement>>(new Map());
interface MediaItem {
url: string;
type: 'image' | 'video' | 'audio' | 'file';
@ -188,103 +183,17 @@ @@ -188,103 +183,17 @@
const coverImage = $derived(mediaItems.find((m) => m.source === 'image-tag'));
const otherMedia = $derived(mediaItems.filter((m) => m.source !== 'image-tag'));
// Intersection Observer for lazy loading
let observer: IntersectionObserver | null = $state(null);
onMount(() => {
observer = new IntersectionObserver(
(entries) => {
entries.forEach((entry) => {
if (entry.isIntersecting) {
const url = entry.target.getAttribute('data-media-url');
if (url) {
loadedMedia.add(url);
// Force reactivity update
loadedMedia = new Set(loadedMedia);
observer?.unobserve(entry.target);
}
}
});
},
{
rootMargin: '100px' // Start loading 100px before element is visible
}
);
// Observe all existing placeholders
$effect(() => {
if (observer && containerRef) {
const placeholders = containerRef.querySelectorAll('[data-media-url]');
placeholders.forEach((placeholder) => {
if (observer) {
observer.observe(placeholder);
}
});
}
});
return () => {
observer?.disconnect();
observer = null;
};
});
let containerRef = $state<HTMLElement | null>(null);
// Action to set media ref and observe it
function mediaRefAction(node: HTMLElement, url: string) {
mediaRefs.set(url, node);
// Observe the element when it's added
if (observer) {
observer.observe(node);
// Also check if it's already visible (in case IntersectionObserver hasn't fired yet)
const rect = node.getBoundingClientRect();
const isVisible = rect.top < window.innerHeight + 100 && rect.bottom > -100;
if (isVisible) {
loadedMedia.add(url);
loadedMedia = new Set(loadedMedia);
observer.unobserve(node);
}
}
return {
destroy() {
if (observer) {
observer.unobserve(node);
}
mediaRefs.delete(url);
}
};
}
function shouldLoad(url: string): boolean {
// Always load cover images immediately
if (coverImage && coverImage.url === url) {
return true;
}
return loadedMedia.has(url);
}
</script>
<div bind:this={containerRef}>
{#if coverImage}
<div class="cover-image mb-4">
{#if shouldLoad(coverImage.url)}
<img
src={coverImage.url}
alt=""
class="w-full max-h-96 object-cover rounded"
loading="lazy"
/>
{:else}
<div
class="media-placeholder w-full max-h-96 bg-fog-border dark:bg-fog-dark-border rounded flex items-center justify-center"
use:mediaRefAction={coverImage.url}
data-media-url={coverImage.url}
style="min-height: 200px;"
>
<span class="text-fog-text-light dark:text-fog-dark-text-light">Loading image...</span>
</div>
{/if}
</div>
{/if}
@ -293,31 +202,18 @@ @@ -293,31 +202,18 @@
{#each otherMedia as item}
{#if item.type === 'image'}
<div class="media-item">
{#if shouldLoad(item.url)}
<img
src={item.url}
alt=""
class="max-w-full rounded"
loading="lazy"
/>
{:else}
<div
class="media-placeholder bg-fog-border dark:bg-fog-dark-border rounded flex items-center justify-center"
use:mediaRefAction={item.url}
data-media-url={item.url}
style="min-height: 150px; min-width: 150px;"
>
<span class="text-fog-text-light dark:text-fog-dark-text-light text-sm">Loading...</span>
</div>
{/if}
</div>
{:else if item.type === 'video'}
<div class="media-item">
{#if shouldLoad(item.url)}
<video
src={item.url}
controls
preload="none"
preload="metadata"
class="max-w-full rounded"
style="max-height: 500px;"
autoplay={false}
@ -326,39 +222,18 @@ @@ -326,39 +222,18 @@
<track kind="captions" />
Your browser does not support the video tag.
</video>
{:else}
<div
class="media-placeholder bg-fog-border dark:bg-fog-dark-border rounded flex items-center justify-center"
use:mediaRefAction={item.url}
data-media-url={item.url}
style="min-height: 200px; min-width: 200px;"
>
<span class="text-fog-text-light dark:text-fog-dark-text-light"> Video</span>
</div>
{/if}
</div>
{:else if item.type === 'audio'}
<div class="media-item">
{#if shouldLoad(item.url)}
<audio
src={item.url}
controls
preload="none"
preload="metadata"
class="w-full"
autoplay={false}
>
Your browser does not support the audio tag.
</audio>
{:else}
<div
class="media-placeholder bg-fog-border dark:bg-fog-dark-border rounded flex items-center justify-center"
use:mediaRefAction={item.url}
data-media-url={item.url}
style="min-height: 60px; width: 100%;"
>
<span class="text-fog-text-light dark:text-fog-dark-text-light">🎵 Audio</span>
</div>
{/if}
</div>
{:else if item.type === 'file'}
<div class="media-item file-item">
@ -448,11 +323,4 @@ @@ -448,11 +323,4 @@
text-decoration: underline;
}
.media-placeholder {
border: 1px solid var(--fog-border, #e5e7eb);
}
:global(.dark) .media-placeholder {
border-color: var(--fog-dark-border, #374151);
}
</style>

151
src/lib/components/content/MediaViewer.svelte

@ -0,0 +1,151 @@ @@ -0,0 +1,151 @@
<script lang="ts">
interface Props {
url: string;
isOpen: boolean;
onClose: () => void;
}
let { url, isOpen, onClose }: Props = $props();
function getMediaType(url: string): 'image' | 'video' | 'audio' | 'unknown' {
const lower = url.toLowerCase();
if (/\.(jpg|jpeg|png|gif|webp|svg|bmp)$/i.test(lower)) return 'image';
if (/\.(mp4|webm|ogg|mov|avi|mkv)$/i.test(lower)) return 'video';
if (/\.(mp3|wav|ogg|flac|aac|m4a)$/i.test(lower)) return 'audio';
return 'unknown';
}
function handleBackdropClick(e: MouseEvent) {
if (e.target === e.currentTarget) {
onClose();
}
}
function handleKeyDown(e: KeyboardEvent) {
if (e.key === 'Escape') {
onClose();
}
}
const mediaType = $derived(getMediaType(url));
</script>
{#if isOpen}
<div
class="media-viewer-backdrop"
onclick={handleBackdropClick}
onkeydown={handleKeyDown}
role="dialog"
aria-modal="true"
tabindex="-1"
>
<div class="media-viewer-content">
<button class="media-viewer-close" onclick={onClose} aria-label="Close">×</button>
{#if mediaType === 'image'}
<img src={url} alt="Media" class="media-viewer-media" />
{:else if mediaType === 'video'}
<video src={url} controls class="media-viewer-media" autoplay={false} />
{:else if mediaType === 'audio'}
<audio src={url} controls class="media-viewer-audio" autoplay={false} />
{:else}
<div class="media-viewer-unknown">
<p>Unsupported media type</p>
<a href={url} target="_blank" rel="noopener noreferrer" class="media-viewer-link">
Open in new tab
</a>
</div>
{/if}
</div>
</div>
{/if}
<style>
.media-viewer-backdrop {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: rgba(0, 0, 0, 0.9);
z-index: 10000;
display: flex;
align-items: center;
justify-content: center;
padding: 2rem;
animation: fadeIn 0.2s ease-out;
}
@keyframes fadeIn {
from { opacity: 0; }
to { opacity: 1; }
}
.media-viewer-content {
position: relative;
max-width: 90vw;
max-height: 90vh;
display: flex;
align-items: center;
justify-content: center;
}
.media-viewer-close {
position: absolute;
top: -2.5rem;
right: 0;
background: rgba(255, 255, 255, 0.2);
border: none;
color: white;
font-size: 2rem;
width: 2.5rem;
height: 2.5rem;
border-radius: 50%;
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
line-height: 1;
transition: background 0.2s;
}
.media-viewer-close:hover {
background: rgba(255, 255, 255, 0.3);
}
.media-viewer-media {
max-width: 100%;
max-height: 90vh;
object-fit: contain;
border-radius: 0.5rem;
}
.media-viewer-audio {
width: 100%;
max-width: 600px;
}
.media-viewer-unknown {
background: var(--fog-post, #ffffff);
padding: 2rem;
border-radius: 0.5rem;
text-align: center;
color: var(--fog-text, #1f2937);
}
:global(.dark) .media-viewer-unknown {
background: var(--fog-dark-post, #1f2937);
color: var(--fog-dark-text, #f9fafb);
}
.media-viewer-link {
display: inline-block;
margin-top: 1rem;
color: var(--fog-accent, #64748b);
text-decoration: underline;
}
:global(.dark) .media-viewer-link {
color: var(--fog-dark-accent, #94a3b8);
}
</style>

1
src/lib/components/content/MetadataCard.svelte

@ -83,7 +83,6 @@ @@ -83,7 +83,6 @@
<img
src={image}
alt={title || description || summary || 'Metadata image'}
loading="lazy"
/>
</div>
{/if}

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

@ -33,12 +33,12 @@ @@ -33,12 +33,12 @@
<header class="relative border-b border-fog-border dark:border-fog-dark-border">
<!-- Banner image -->
<div class="h-32 md:h-48 overflow-hidden bg-fog-surface dark:bg-fog-dark-surface">
<div class="max-w-7xl mx-auto h-full">
<div class="h-24 sm:h-32 md:h-48 overflow-hidden bg-fog-surface dark:bg-fog-dark-surface">
<div class="max-w-7xl mx-auto h-full relative">
<img
src="/aither.png"
alt="aitherboard banner"
class="w-full h-full object-cover opacity-90 dark:opacity-70"
class="w-full h-full object-cover object-center opacity-90 dark:opacity-70"
loading="eager"
/>
<!-- Overlay gradient for text readability -->
@ -47,38 +47,38 @@ @@ -47,38 +47,38 @@
</div>
<!-- Navigation -->
<nav class="bg-fog-surface/95 dark:bg-fog-dark-surface/95 backdrop-blur-sm border-b border-fog-border dark:border-fog-dark-border px-4 py-3">
<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 font-mono" style="font-size: 0.875em;">
<a href="/" class="font-semibold text-fog-text dark:text-fog-dark-text hover:text-fog-text-light dark:hover:text-fog-dark-text-light transition-colors" style="font-size: 1.25em;">aitherboard</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>
<nav class="bg-fog-surface/95 dark:bg-fog-dark-surface/95 backdrop-blur-sm border-b border-fog-border dark:border-fog-dark-border px-2 sm:px-4 py-2 sm:py-3">
<div class="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-2 sm:gap-4 max-w-7xl mx-auto">
<div class="flex flex-wrap gap-1.5 sm:gap-2 md:gap-4 items-center font-mono min-w-0 nav-links">
<a href="/" class="font-semibold text-fog-text dark:text-fog-dark-text hover:text-fog-text-light dark:hover:text-fog-dark-text-light transition-colors flex-shrink-0 nav-brand">aitherboard</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 whitespace-nowrap">/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 whitespace-nowrap">/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>
<a href="/write" class="text-fog-text dark:text-fog-dark-text hover:text-fog-accent dark:hover:text-fog-dark-accent transition-colors whitespace-nowrap">/Write</a>
{/if}
<a href="/find" class="text-fog-text dark:text-fog-dark-text hover:text-fog-accent dark:hover:text-fog-dark-accent transition-colors">/Find</a>
<a href="/find" class="text-fog-text dark:text-fog-dark-text hover:text-fog-accent dark:hover:text-fog-dark-accent transition-colors whitespace-nowrap">/Find</a>
{#if isLoggedIn}
<a href="/rss" class="text-fog-text dark:text-fog-dark-text hover:text-fog-accent dark:hover:text-fog-dark-accent transition-colors">/RSS</a>
<a href="/rss" class="text-fog-text dark:text-fog-dark-text hover:text-fog-accent dark:hover:text-fog-dark-accent transition-colors whitespace-nowrap">/RSS</a>
{/if}
<a href="/relay" class="text-fog-text dark:text-fog-dark-text hover:text-fog-accent dark:hover:text-fog-dark-accent transition-colors">/Relay</a>
<a href="/topics" class="text-fog-text dark:text-fog-dark-text hover:text-fog-accent dark:hover:text-fog-dark-accent transition-colors">/Topics</a>
<a href="/repos" class="text-fog-text dark:text-fog-dark-text hover:text-fog-accent dark:hover:text-fog-dark-accent transition-colors">/Repos</a>
<a href="/cache" class="text-fog-text dark:text-fog-dark-text hover:text-fog-accent dark:hover:text-fog-dark-accent transition-colors">/Cache</a>
<a href="/relay" class="text-fog-text dark:text-fog-dark-text hover:text-fog-accent dark:hover:text-fog-dark-accent transition-colors whitespace-nowrap">/Relay</a>
<a href="/topics" class="text-fog-text dark:text-fog-dark-text hover:text-fog-accent dark:hover:text-fog-dark-accent transition-colors whitespace-nowrap">/Topics</a>
<a href="/repos" class="text-fog-text dark:text-fog-dark-text hover:text-fog-accent dark:hover:text-fog-dark-accent transition-colors whitespace-nowrap">/Repos</a>
<a href="/cache" class="text-fog-text dark:text-fog-dark-text hover:text-fog-accent dark:hover:text-fog-dark-accent transition-colors whitespace-nowrap">/Cache</a>
</div>
<div class="flex flex-wrap gap-2 sm:gap-4 items-center" style="font-size: 0.875em;">
<div class="flex flex-wrap gap-1.5 sm:gap-2 md:gap-4 items-center min-w-0 flex-shrink-0 nav-links">
{#if isLoggedIn && currentPubkey}
<UserPreferences />
<ProfileBadge pubkey={currentPubkey} />
<button
onclick={handleLogout}
class="px-3 py-1 rounded 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 hover:bg-fog-highlight dark:hover:bg-fog-dark-highlight transition-colors"
class="px-2 sm:px-3 py-1 rounded 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 hover:bg-fog-highlight dark:hover:bg-fog-dark-highlight transition-colors flex-shrink-0"
title="Logout"
aria-label="Logout"
>
<span class="emoji emoji-grayscale">🚪</span>
</button>
{:else}
<a href="/login" class="text-fog-text dark:text-fog-dark-text hover:text-fog-accent dark:hover:text-fog-dark-accent transition-colors">Login</a>
<a href="/login" class="text-fog-text dark:text-fog-dark-text hover:text-fog-accent dark:hover:text-fog-dark-accent transition-colors whitespace-nowrap">Login</a>
<UserPreferences />
{/if}
</div>
@ -109,13 +109,46 @@ @@ -109,13 +109,46 @@
filter: grayscale(80%);
}
@media (max-width: 768px) {
/* Responsive navigation links */
.nav-links {
font-size: 0.75rem;
}
@media (min-width: 640px) {
.nav-links {
font-size: 0.875rem;
}
}
.nav-brand {
font-size: 1rem;
}
@media (min-width: 640px) {
.nav-brand {
font-size: 1.25rem;
}
}
/* Ensure navigation items don't overflow */
nav a {
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
}
/* Better spacing on very small screens */
@media (max-width: 480px) {
nav {
padding: 0.5rem 0.75rem; /* Smaller padding on mobile */
padding: 0.5rem 0.75rem;
}
.nav-links {
font-size: 0.7rem;
}
nav a, nav button {
font-size: 0.875rem; /* Slightly smaller text on mobile */
.nav-brand {
font-size: 0.9rem;
}
}
</style>

35
src/lib/components/layout/ProfileBadge.svelte

@ -10,7 +10,7 @@ @@ -10,7 +10,7 @@
let { pubkey, inline = false }: Props = $props();
let profile = $state<{ name?: string; picture?: string } | null>(null);
let profile = $state<{ name?: string; picture?: string; nip05?: string[] } | null>(null);
let status = $state<string | null>(null);
let activityStatus = $state<'red' | 'yellow' | 'green' | null>(null);
let activityMessage = $state<string | null>(null);
@ -31,7 +31,7 @@ @@ -31,7 +31,7 @@
activityMessage = null;
// Load immediately - no debounce
loadProfile();
// Only load status and activity if not inline
// Only load status if not inline (feed view doesn't need status)
if (!inline) {
loadStatus();
updateActivityStatus();
@ -65,12 +65,12 @@ @@ -65,12 +65,12 @@
async function loadStatus() {
const currentPubkey = pubkey;
if (!currentPubkey || loadingStatus || lastLoadedPubkey !== currentPubkey) return;
if (!currentPubkey || loadingStatus) return;
loadingStatus = true;
try {
const s = await fetchUserStatus(currentPubkey);
// Only update if pubkey hasn't changed during load
if (pubkey === currentPubkey && lastLoadedPubkey === currentPubkey) {
if (pubkey === currentPubkey) {
status = s;
}
} finally {
@ -144,7 +144,7 @@ @@ -144,7 +144,7 @@
});
</script>
<a href="/profile/{pubkey}" class="profile-badge inline-flex items-center gap-2 min-w-0 max-w-full">
<a href="/profile/{pubkey}" class="profile-badge inline-flex items-start gap-2 min-w-0 max-w-full">
{#if !inline}
{#if profile?.picture && !imageError}
<img
@ -166,10 +166,21 @@ @@ -166,10 +166,21 @@
</div>
{/if}
{/if}
<span class="truncate min-w-0">{profile?.name || shortenedNpub}</span>
{#if !inline && status}
<span class="status-text text-sm text-fog-text-light dark:text-fog-dark-text-light flex-shrink-0 whitespace-nowrap">({status})</span>
<div class="flex flex-col min-w-0 flex-1">
<div class="flex items-center gap-2 flex-wrap">
<span class="truncate min-w-0">
{profile?.name || shortenedNpub}
</span>
{#if profile?.nip05 && profile.nip05.length > 0}
<span class="nip05-text text-fog-text-light dark:text-fog-dark-text-light flex-shrink-0 whitespace-nowrap">
{profile.nip05[0]}
</span>
{/if}
</div>
{#if status && status.trim()}
<span class="status-text text-sm text-fog-text-light dark:text-fog-dark-text-light">{status}</span>
{/if}
</div>
</a>
<style>
@ -196,6 +207,12 @@ @@ -196,6 +207,12 @@
}
.status-text {
display: inline-block;
display: block;
font-size: 0.75em;
line-height: 1.2;
}
.nip05-text {
font-size: 0.875em;
}
</style>

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

@ -13,9 +13,11 @@ @@ -13,9 +13,11 @@
interface Props {
threadId: string; // The event ID of the root event
event?: NostrEvent; // The root event itself (optional, used to determine reply types)
onCommentsLoaded?: (eventIds: string[]) => void; // Callback when comments are loaded
preloadedReactions?: Map<string, NostrEvent[]>; // Pre-loaded reactions by event ID
}
let { threadId, event }: Props = $props();
let { threadId, event, onCommentsLoaded, preloadedReactions }: Props = $props();
let comments = $state<NostrEvent[]>([]); // kind 1111
let kind1Replies = $state<NostrEvent[]>([]); // kind 1 replies (should only be for kind 1 events, but some apps use them for everything)
@ -263,6 +265,16 @@ @@ -263,6 +265,16 @@
.sort((a, b) => b.created_at - a.created_at)
.slice(0, MAX_REPLIES);
// Notify parent when comments are loaded
if (onCommentsLoaded) {
const allEventIds = [
...allComments.map(c => c.id),
...allKind1Replies.map(r => r.id),
...allYakBacks.map(y => y.id)
];
onCommentsLoaded(allEventIds);
}
// Clear loading flag as soon as we get the first results
// This allows comments to render immediately instead of waiting for all fetches
if (loading) {
@ -365,6 +377,30 @@ @@ -365,6 +377,30 @@
kind1Replies = Array.from(new Map(allKind1Replies.map(r => [r.id, r])).values());
yakBacks = Array.from(new Map(allYakBacks.map(y => [y.id, y])).values());
zapReceipts = Array.from(new Map(allZapReceipts.map(z => [z.id, z])).values());
// Notify parent when comments are loaded
if (onCommentsLoaded) {
const allEventIds = [
...allComments.map(c => c.id),
...allKind1Replies.map(r => r.id),
...allYakBacks.map(y => y.id)
];
onCommentsLoaded(allEventIds);
}
}
// Notify parent after initial load completes (even if no new replies)
if (onCommentsLoaded && comments.length === 0 && kind1Replies.length === 0 && yakBacks.length === 0) {
// Still notify with empty array so parent knows loading is complete
onCommentsLoaded([]);
} else if (onCommentsLoaded && (comments.length > 0 || kind1Replies.length > 0 || yakBacks.length > 0)) {
// Notify with all event IDs
const allEventIds = [
...comments.map(c => c.id),
...kind1Replies.map(r => r.id),
...yakBacks.map(y => y.id)
];
onCommentsLoaded(allEventIds);
}
// ALWAYS clear loading flag after fetch completes, even if no events matched
@ -750,12 +786,20 @@ @@ -750,12 +786,20 @@
{:else if item.type === 'reply'}
<!-- Kind 1 reply - render as FeedPost -->
<div class="kind1-reply mb-4">
<FeedPost post={item.event} />
<FeedPost
post={item.event}
fullView={true}
preloadedReactions={preloadedReactions?.get(item.event.id)}
/>
</div>
{:else if item.type === 'yak'}
<!-- Yak back (kind 1244) - render as FeedPost -->
<div class="yak-back mb-4">
<FeedPost post={item.event} />
<FeedPost
post={item.event}
fullView={true}
preloadedReactions={preloadedReactions?.get(item.event.id)}
/>
</div>
{:else if item.type === 'zap'}
<!-- Zap receipt - render with lightning bolt -->

16
src/lib/modules/discussions/DiscussionCard.svelte

@ -36,6 +36,7 @@ @@ -36,6 +36,7 @@
let expanded = $state(false);
let contentElement: HTMLElement | null = $state(null);
let needsExpansion = $state(false);
let lastStatsLoadEventId = $state<string | null>(null);
onMount(async () => {
await loadStats();
@ -69,6 +70,12 @@ @@ -69,6 +70,12 @@
}
async function loadStats() {
// Prevent duplicate loads for the same event
if (loadingStats || lastStatsLoadEventId === thread.id) {
return;
}
lastStatsLoadEventId = thread.id;
loadingStats = true;
const timeout = 30000; // 30 seconds
@ -93,17 +100,18 @@ @@ -93,17 +100,18 @@
commentEvents = await nostrClient.fetchEvents(
[{ kinds: [KIND.COMMENT], '#E': [thread.id], '#K': ['11'] }],
commentRelays,
{ useCache: true }
{ useCache: true, cacheResults: true }
);
commentCount = commentEvents.length;
}
// Load zap receipts (kind 9735)
// Load zap receipts (kind 9735) - only if we don't already have zap data
// Use low priority and cache aggressively to avoid repeated fetches
const zapRelays = relayManager.getZapReceiptReadRelays();
const zapReceipts = await nostrClient.fetchEvents(
[{ kinds: [KIND.ZAP_RECEIPT], '#e': [thread.id] }],
[{ kinds: [KIND.ZAP_RECEIPT], '#e': [thread.id], limit: config.feedLimit }],
zapRelays,
{ useCache: true }
{ useCache: true, cacheResults: true, timeout: config.mediumTimeout, priority: 'low' }
);
// Calculate zap totals

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

@ -242,14 +242,25 @@ @@ -242,14 +242,25 @@
}
}
// Cache of checked reaction IDs to avoid repeated deletion checks
const checkedReactionIds = new Set<string>();
async function filterDeletedReactions(reactions: NostrEvent[]): Promise<NostrEvent[]> {
if (reactions.length === 0) return reactions;
// Filter out reactions we've already checked and found to be non-deleted
const uncheckedReactions = reactions.filter(r => !checkedReactionIds.has(r.id));
// If all reactions have been checked, return as-is (they're all non-deleted)
if (uncheckedReactions.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);
const reactionIds = uncheckedReactions.map(r => r.id);
// Limit to first 100 reactions to avoid massive queries
const limitedReactionIds = reactionIds.slice(0, 100);
@ -274,9 +285,13 @@ @@ -274,9 +285,13 @@
}
}
// Filter out deleted reactions - much simpler now
// Filter out deleted reactions and mark non-deleted ones as checked
const filtered = reactions.filter(reaction => {
const isDeleted = deletedReactionIds.has(reaction.id);
if (!isDeleted) {
// Cache that this reaction is not deleted
checkedReactionIds.add(reaction.id);
}
return !isDeleted;
});

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

@ -10,29 +10,30 @@ @@ -10,29 +10,30 @@
import { getRecentFeedEvents } from '../../services/cache/event-cache.js';
interface Props {
singleRelay?: string; // If provided, use only this relay and disable cache
singleRelay?: string;
}
let { singleRelay }: Props = $props();
// Core state
let events = $state<NostrEvent[]>([]);
let loading = $state(true);
let relayError = $state<string | null>(null);
// Batch-loaded parent and quoted events
let parentEventsMap = $state<Map<string, NostrEvent>>(new Map());
let quotedEventsMap = $state<Map<string, NostrEvent>>(new Map());
// Drawer state
let drawerOpen = $state(false);
let drawerEvent = $state<NostrEvent | null>(null);
// Waiting room for new events
let waitingRoomEvents = $state<NostrEvent[]>([]);
// Pagination
let oldestTimestamp = $state<number | null>(null);
let loadingMore = $state(false);
let hasMoreEvents = $state(true);
// Subscription
let subscriptionId: string | null = $state(null);
let isMounted = $state(true);
let loadingParents = $state(false); // Guard to prevent concurrent parent/quoted event loads
let loadingFeed = $state(false); // Guard to prevent concurrent feed loads
let pendingSubscriptionEvents = $state<NostrEvent[]>([]); // Batch subscription events
let subscriptionBatchTimeout: ReturnType<typeof setTimeout> | null = null;
let initialLoadComplete = $state(false);
function openDrawer(event: NostrEvent) {
drawerEvent = event;
@ -44,211 +45,118 @@ @@ -44,211 +45,118 @@
drawerEvent = null;
}
onMount(() => {
isMounted = true;
(async () => {
await nostrClient.initialize();
if (!isMounted) return;
// Load cached feed events immediately (15 minute cache)
await loadCachedFeed();
if (!isMounted) return;
// Then fetch fresh data in the background
await loadFeed();
if (!isMounted) return;
setupSubscription();
})();
return () => {
isMounted = false;
if (subscriptionId) {
nostrClient.unsubscribe(subscriptionId);
subscriptionId = null;
}
if (subscriptionBatchTimeout) {
clearTimeout(subscriptionBatchTimeout);
subscriptionBatchTimeout = null;
}
pendingSubscriptionEvents = [];
};
});
// Listen for custom event from EmbeddedEvent components
$effect(() => {
const handleOpenEvent = (e: CustomEvent) => {
if (e.detail?.event) {
openDrawer(e.detail.event);
}
};
window.addEventListener('openEventInDrawer', handleOpenEvent as EventListener);
return () => {
window.removeEventListener('openEventInDrawer', handleOpenEvent as EventListener);
};
});
function processSubscriptionBatch() {
if (!isMounted || pendingSubscriptionEvents.length === 0 || loadingFeed) {
pendingSubscriptionEvents = [];
subscriptionBatchTimeout = null;
return;
}
// Get current events snapshot to avoid race conditions
const currentEvents = events;
const currentEventIds = new Set(currentEvents.map(e => e.id));
// Filter out discussion threads and deduplicate within pending events
const seenInPending = new Set<string>();
const newEvents: NostrEvent[] = [];
for (const event of pendingSubscriptionEvents) {
// Skip discussion threads
if (event.kind === KIND.DISCUSSION_THREAD) continue;
// Skip if already in current events or already seen in this batch
if (!currentEventIds.has(event.id) && !seenInPending.has(event.id)) {
newEvents.push(event);
seenInPending.add(event.id);
}
}
if (newEvents.length === 0) {
pendingSubscriptionEvents = [];
subscriptionBatchTimeout = null;
return;
}
// Create a completely new events array with all unique events
// Use a Map to ensure uniqueness by event ID (last one wins if duplicates somehow exist)
const eventsMap = new Map<string, NostrEvent>();
// Add all current events first
for (const event of currentEvents) {
eventsMap.set(event.id, event);
}
// Load waiting room events into feed
function loadWaitingRoomEvents() {
if (waitingRoomEvents.length === 0) return;
// Add new events (will overwrite if somehow duplicate, but shouldn't happen)
for (const event of newEvents) {
eventsMap.set(event.id, event);
const eventMap = new Map(events.map(e => [e.id, e]));
for (const event of waitingRoomEvents) {
eventMap.set(event.id, event);
}
// Convert to array and sort
events = Array.from(eventsMap.values()).sort((a, b) => b.created_at - a.created_at);
pendingSubscriptionEvents = [];
subscriptionBatchTimeout = null;
events = Array.from(eventMap.values()).sort((a, b) => b.created_at - a.created_at);
waitingRoomEvents = [];
}
function setupSubscription() {
if (subscriptionId || singleRelay) return;
// Load older events (pagination)
async function loadOlderEvents() {
if (loadingMore || !hasMoreEvents) return;
const relays = relayManager.getFeedReadRelays();
const feedKinds = getFeedKinds().filter(kind => kind !== KIND.DISCUSSION_THREAD);
const filters = feedKinds.map(kind => ({ kinds: [kind], limit: config.feedLimit }));
const untilTimestamp = oldestTimestamp ?? Math.floor(Date.now() / 1000);
if (!untilTimestamp) return;
subscriptionId = nostrClient.subscribe(
loadingMore = true;
try {
const relays = singleRelay ? [singleRelay] : relayManager.getFeedReadRelays();
const feedKinds = getFeedKinds().filter(k => k !== KIND.DISCUSSION_THREAD);
const filters = feedKinds.map(k => ({
kinds: [k],
limit: config.feedLimit,
until: untilTimestamp - 1
}));
const fetched = await nostrClient.fetchEvents(
filters,
relays,
(event: NostrEvent) => {
if (!isMounted || event.kind === KIND.DISCUSSION_THREAD || loadingFeed) return;
// Add to pending batch
pendingSubscriptionEvents.push(event);
// Clear existing timeout
if (subscriptionBatchTimeout) {
clearTimeout(subscriptionBatchTimeout);
{
relayFirst: true,
useCache: true,
cacheResults: true,
timeout: config.standardTimeout
}
// Process batch after a short delay (debounce rapid updates)
subscriptionBatchTimeout = setTimeout(() => {
processSubscriptionBatch();
}, 100); // 100ms debounce
},
() => {}
);
}
if (!isMounted) return;
async function loadCachedFeed() {
if (!isMounted || singleRelay) return;
try {
const feedKinds = getFeedKinds().filter(kind => kind !== KIND.DISCUSSION_THREAD);
// Load events cached within the last 15 minutes
const cachedEvents = await getRecentFeedEvents(feedKinds, 15 * 60 * 1000, 50);
if (cachedEvents.length > 0 && isMounted) {
// Filter to only showInFeed kinds and exclude kind 11
const filteredEvents = cachedEvents.filter((e: NostrEvent) =>
const filtered = fetched.filter(e =>
e.kind !== KIND.DISCUSSION_THREAD &&
getKindInfo(e.kind).showInFeed === true
);
// Deduplicate
const uniqueMap = new Map<string, NostrEvent>();
for (const event of filteredEvents) {
if (!uniqueMap.has(event.id)) {
uniqueMap.set(event.id, event);
}
if (filtered.length === 0) {
hasMoreEvents = false;
return;
}
const sortedEvents = Array.from(uniqueMap.values()).sort((a, b) => b.created_at - a.created_at);
if (sortedEvents.length > 0) {
events = sortedEvents;
// Load parent/quoted events in background, don't await
// Only load if not already loading to prevent cascading fetches
if (!loadingParents) {
loadParentAndQuotedEvents(events).catch(err => {
console.error('Error loading parent/quoted events from cache:', err);
});
}
// Don't set loading to false here - let loadFeed() handle that
}
const eventMap = new Map(events.map(e => [e.id, e]));
for (const event of filtered) {
eventMap.set(event.id, event);
}
const sorted = Array.from(eventMap.values()).sort((a, b) => b.created_at - a.created_at);
events = sorted;
oldestTimestamp = Math.min(...sorted.map(e => e.created_at));
} catch (error) {
console.error('Error loading cached feed:', error);
// Don't set loading to false - let loadFeed() handle that
console.error('Error loading older events:', error);
} finally {
loadingMore = false;
}
}
// Initial feed load
async function loadFeed() {
if (!isMounted || loadingFeed) return; // Prevent concurrent loads
if (!isMounted) return;
loadingFeed = true;
// Only show loading spinner if we don't have cached events
const hasCachedEvents = events.length > 0;
if (!hasCachedEvents) {
loading = true;
}
relayError = null;
try {
// Load from cache first (fast)
if (!singleRelay) {
const feedKinds = getFeedKinds().filter(k => k !== KIND.DISCUSSION_THREAD);
const cached = await getRecentFeedEvents(feedKinds, 15 * 60 * 1000, config.feedLimit);
const filtered = cached.filter(e =>
e.kind !== KIND.DISCUSSION_THREAD &&
getKindInfo(e.kind).showInFeed === true
);
if (filtered.length > 0 && isMounted) {
const unique = Array.from(new Map(filtered.map(e => [e.id, e])).values());
events = unique.sort((a, b) => b.created_at - a.created_at);
oldestTimestamp = Math.min(...events.map(e => e.created_at));
loading = false; // Show cached content immediately
}
}
// Fetch fresh data from relays (can be slow)
const relays = singleRelay ? [singleRelay] : relayManager.getFeedReadRelays();
if (singleRelay) {
try {
const relay = await nostrClient.getRelay(singleRelay);
if (!relay) {
relayError = `Relay ${singleRelay} is unavailable or returned an error.`;
loading = false;
return;
}
} catch (error) {
relayError = `Failed to connect to relay ${singleRelay}: ${error instanceof Error ? error.message : 'Unknown error'}`;
relayError = `Relay ${singleRelay} is unavailable.`;
loading = false;
return;
}
}
const feedKinds = getFeedKinds().filter(kind => kind !== KIND.DISCUSSION_THREAD);
const filters = feedKinds.map(kind => ({ kinds: [kind], limit: config.feedLimit }));
const feedKinds = getFeedKinds().filter(k => k !== KIND.DISCUSSION_THREAD);
const filters = feedKinds.map(k => ({ kinds: [k], limit: config.feedLimit }));
const fetchOptions = singleRelay ? {
const fetched = await nostrClient.fetchEvents(
filters,
relays,
singleRelay ? {
relayFirst: true,
useCache: false,
cacheResults: false,
@ -258,140 +166,97 @@ @@ -258,140 +166,97 @@
useCache: true,
cacheResults: true,
timeout: config.standardTimeout
};
const fetchedEvents = await nostrClient.fetchEvents(filters, relays, fetchOptions);
}
);
if (!isMounted) return;
// Filter to only showInFeed kinds and exclude kind 11
const filteredEvents = fetchedEvents.filter((e: NostrEvent) =>
const filtered = fetched.filter(e =>
e.kind !== KIND.DISCUSSION_THREAD &&
getKindInfo(e.kind).showInFeed === true
);
// Deduplicate and merge with existing events
const existingIds = new Set(events.map(e => e.id));
const uniqueMap = new Map<string, NostrEvent>();
// Add existing events first
for (const event of events) {
uniqueMap.set(event.id, event);
const eventMap = new Map(events.map(e => [e.id, e]));
for (const event of filtered) {
eventMap.set(event.id, event);
}
// Add new events
for (const event of filteredEvents) {
if (!uniqueMap.has(event.id)) {
uniqueMap.set(event.id, event);
}
}
events = Array.from(uniqueMap.values()).sort((a, b) => b.created_at - a.created_at);
const sorted = Array.from(eventMap.values()).sort((a, b) => b.created_at - a.created_at);
events = sorted;
if (events.length > 0) {
// Load parent/quoted events in background, don't await
// Only load if not already loading to prevent cascading fetches
if (!loadingParents) {
loadParentAndQuotedEvents(events).catch(err => {
console.error('Error loading parent/quoted events:', err);
});
}
if (sorted.length > 0) {
oldestTimestamp = Math.min(...sorted.map(e => e.created_at));
}
} catch (error) {
console.error('Error loading feed:', error);
if (!events.length) {
relayError = 'Failed to load feed.';
}
} finally {
loading = false;
loadingFeed = false;
initialLoadComplete = true;
}
}
// Setup subscription (only adds to waiting room)
function setupSubscription() {
if (subscriptionId || singleRelay) return;
async function loadParentAndQuotedEvents(postsToLoad: NostrEvent[]) {
if (!isMounted || postsToLoad.length === 0 || loadingParents) return;
loadingParents = true;
try {
const relays = singleRelay ? [singleRelay] : relayManager.getFeedReadRelays();
const parentEventIds = new Set<string>();
const quotedEventIds = new Set<string>();
for (const post of postsToLoad) {
const replyTag = post.tags.find((t) => t[0] === 'e' && t[3] === 'reply');
if (replyTag && replyTag[1]) {
parentEventIds.add(replyTag[1]);
} else {
const rootId = post.tags.find((t) => t[0] === 'root')?.[1];
const eTag = post.tags.find((t) => t[0] === 'e' && t[1] !== rootId && t[1] !== post.id);
if (eTag && eTag[1]) {
parentEventIds.add(eTag[1]);
}
}
const quotedTag = post.tags.find((t) => t[0] === 'q');
if (quotedTag && quotedTag[1]) {
quotedEventIds.add(quotedTag[1]);
}
}
const allEventIds = [...parentEventIds, ...quotedEventIds];
if (allEventIds.length === 0) return;
const relays = relayManager.getFeedReadRelays();
const feedKinds = getFeedKinds().filter(k => k !== KIND.DISCUSSION_THREAD);
const filters = feedKinds.map(k => ({ kinds: [k], limit: config.feedLimit }));
const fetchedEvents = await nostrClient.fetchEvents(
[{ kinds: [KIND.SHORT_TEXT_NOTE], ids: allEventIds }],
subscriptionId = nostrClient.subscribe(
filters,
relays,
singleRelay ? {
relayFirst: true,
useCache: false,
cacheResults: false,
timeout: config.standardTimeout
} : {
relayFirst: true,
useCache: true,
cacheResults: true,
timeout: config.standardTimeout
(event: NostrEvent) => {
if (!isMounted || event.kind === KIND.DISCUSSION_THREAD || !initialLoadComplete) return;
// Add to waiting room if not already in feed or waiting room
const eventIds = new Set([...events.map(e => e.id), ...waitingRoomEvents.map(e => e.id)]);
if (!eventIds.has(event.id)) {
waitingRoomEvents = [...waitingRoomEvents, event].sort((a, b) => b.created_at - a.created_at);
}
},
() => {}
);
if (!isMounted) return;
const eventsById = new Map<string, NostrEvent>();
for (const event of fetchedEvents) {
eventsById.set(event.id, event);
}
for (const post of postsToLoad) {
const replyTag = post.tags.find((t) => t[0] === 'e' && t[3] === 'reply');
let parentId: string | undefined;
if (replyTag && replyTag[1]) {
parentId = replyTag[1];
} else {
const rootId = post.tags.find((t) => t[0] === 'root')?.[1];
const eTag = post.tags.find((t) => t[0] === 'e' && t[1] !== rootId && t[1] !== post.id);
parentId = eTag?.[1];
onMount(() => {
isMounted = true;
nostrClient.initialize().then(() => {
if (isMounted) {
loadFeed().then(() => {
if (isMounted) {
setupSubscription();
}
if (parentId && eventsById.has(parentId)) {
parentEventsMap.set(post.id, eventsById.get(parentId)!);
});
}
});
const quotedTag = post.tags.find((t) => t[0] === 'q');
if (quotedTag && quotedTag[1] && eventsById.has(quotedTag[1])) {
quotedEventsMap.set(post.id, eventsById.get(quotedTag[1])!);
}
}
} catch (error) {
console.error('[FeedPage] Error loading parent/quoted events:', error);
} finally {
loadingParents = false;
}
return () => {
isMounted = false;
if (subscriptionId) {
nostrClient.unsubscribe(subscriptionId);
subscriptionId = null;
}
};
});
// Listen for drawer events
$effect(() => {
const handler = (e: CustomEvent) => {
if (e.detail?.event) openDrawer(e.detail.event);
};
window.addEventListener('openEventInDrawer', handler as EventListener);
return () => window.removeEventListener('openEventInDrawer', handler as EventListener);
});
</script>
<div class="feed-page">
{#if singleRelay}
<div class="relay-info">
<p class="relay-info-text">
Showing feed from: <code class="relay-url">{singleRelay}</code>
</p>
<p class="relay-info-text">Showing feed from: <code class="relay-url">{singleRelay}</code></p>
</div>
{/if}
@ -408,17 +273,30 @@ @@ -408,17 +273,30 @@
<p class="text-fog-text dark:text-fog-dark-text">No posts found.</p>
</div>
{:else}
{#if waitingRoomEvents.length > 0}
<div class="waiting-room-banner">
<button onclick={loadWaitingRoomEvents} class="see-new-events-btn">
See {waitingRoomEvents.length} new event{waitingRoomEvents.length === 1 ? '' : 's'}
</button>
</div>
{/if}
<div class="feed-posts">
{#each events as event (event.id)}
<FeedPost
post={event}
onOpenEvent={openDrawer}
parentEvent={parentEventsMap.get(event.id)}
quotedEvent={quotedEventsMap.get(event.id)}
/>
<FeedPost post={event} onOpenEvent={openDrawer} />
{/each}
</div>
<div class="load-more-section">
<button
onclick={loadOlderEvents}
disabled={loadingMore || !hasMoreEvents}
class="see-more-events-btn"
>
{loadingMore ? 'Loading...' : 'See more events'}
</button>
</div>
{#if drawerOpen && drawerEvent}
<ThreadDrawer opEvent={drawerEvent} isOpen={drawerOpen} onClose={closeDrawer} />
{/if}
@ -489,21 +367,57 @@ @@ -489,21 +367,57 @@
border-color: var(--fog-dark-border, #374151);
}
/* Responsive images and media in feed */
:global(.feed-page img):not(.profile-picture) {
max-width: 600px;
width: 100%;
height: auto;
.waiting-room-banner {
padding: 1rem;
margin-bottom: 1rem;
background: var(--fog-highlight, #f3f4f6);
border: 1px solid var(--fog-border, #e5e7eb);
border-radius: 0.375rem;
text-align: center;
}
:global(.dark) .waiting-room-banner {
background: var(--fog-dark-highlight, #374151);
border-color: var(--fog-dark-border, #475569);
}
:global(.feed-page video) {
max-width: 600px;
width: 100%;
height: auto;
.see-new-events-btn,
.see-more-events-btn {
padding: 0.75rem 1.5rem;
background: var(--fog-accent, #64748b);
color: white;
border: none;
border-radius: 0.375rem;
font-size: 0.875rem;
font-weight: 500;
cursor: pointer;
transition: all 0.2s;
}
:global(.feed-page audio) {
max-width: 600px;
width: 100%;
.see-new-events-btn:hover,
.see-more-events-btn:hover:not(:disabled) {
background: var(--fog-text, #475569);
}
.see-new-events-btn:disabled,
.see-more-events-btn:disabled {
opacity: 0.5;
cursor: not-allowed;
}
:global(.dark) .see-new-events-btn,
:global(.dark) .see-more-events-btn {
background: var(--fog-dark-accent, #94a3b8);
color: var(--fog-dark-text, #f9fafb);
}
:global(.dark) .see-new-events-btn:hover:not(:disabled),
:global(.dark) .see-more-events-btn:hover:not(:disabled) {
background: var(--fog-dark-text, #cbd5e1);
}
.load-more-section {
padding: 2rem;
text-align: center;
}
</style>

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

File diff suppressed because it is too large Load Diff

252
src/lib/modules/feed/ThreadDrawer.svelte

@ -3,9 +3,11 @@ @@ -3,9 +3,11 @@
import CommentThread from '../comments/CommentThread.svelte';
import { nostrClient } from '../../services/nostr/nostr-client.js';
import { relayManager } from '../../services/nostr/relay-manager.js';
import { config } from '../../services/nostr/config.js';
import { buildEventHierarchy, getHierarchyChain } from '../../services/nostr/event-hierarchy.js';
import { onMount } from 'svelte';
import type { NostrEvent } from '../../types/nostr.js';
import { KIND } from '../../types/kind-lookup.js';
interface Props {
opEvent: NostrEvent | null;
@ -22,6 +24,12 @@ @@ -22,6 +24,12 @@
let hierarchyChain = $state<NostrEvent[]>([]);
let currentLoadEventId = $state<string | null>(null); // Track which event is currently being loaded
let lastOpEventId = $state<string | null>(null); // Track the last event ID to prevent reactive loops
let loadFullThread = $state(false); // Only load full thread when "View thread" is clicked
// Batch-loaded reactions and zaps for all events in thread
let reactionsByEventId = $state<Map<string, NostrEvent[]>>(new Map());
let zapsByEventId = $state<Map<string, NostrEvent[]>>(new Map());
let loadingReactions = $state(false);
// Derive event ID separately to avoid reactive loops from object reference changes
let opEventId = $derived(opEvent?.id || null);
@ -36,43 +44,180 @@ @@ -36,43 +44,180 @@
// Build event hierarchy when drawer opens
async function loadHierarchy(abortSignal: AbortSignal, eventId: string) {
if (!opEvent || !isInitialized) return;
if (!opEvent || !isInitialized) {
console.warn('loadHierarchy: opEvent or isInitialized missing', { opEvent: !!opEvent, isInitialized });
loading = false;
return;
}
// If we're already loading this event, don't start another load
if (currentLoadEventId === eventId && loading) {
console.log('loadHierarchy: Already loading this event', eventId);
return;
}
currentLoadEventId = eventId;
loading = true;
console.log('loadHierarchy: Starting load for event', eventId);
try {
const hierarchy = await buildEventHierarchy(opEvent);
// Add timeout to prevent hanging
const timeoutPromise = new Promise((_, reject) => {
setTimeout(() => reject(new Error('buildEventHierarchy timeout')), config.standardTimeout);
});
const hierarchy = await Promise.race([
buildEventHierarchy(opEvent),
timeoutPromise
]) as Awaited<ReturnType<typeof buildEventHierarchy>>;
// Check if operation was aborted or event changed
if (abortSignal.aborted || currentLoadEventId !== eventId) return;
if (abortSignal.aborted || currentLoadEventId !== eventId) {
console.log('loadHierarchy: Aborted or event changed', { aborted: abortSignal.aborted, currentId: currentLoadEventId, eventId });
return;
}
const chain = getHierarchyChain(hierarchy);
console.log('loadHierarchy: Got hierarchy chain', chain.length, 'events');
// Check again before final state update
if (abortSignal.aborted || currentLoadEventId !== eventId) return;
if (abortSignal.aborted || currentLoadEventId !== eventId) {
console.log('loadHierarchy: Aborted before setting chain');
return;
}
hierarchyChain = chain;
// After hierarchy is loaded, batch fetch reactions and zaps for all events
if (!abortSignal.aborted && currentLoadEventId === eventId) {
batchLoadReactionsAndZaps(chain);
}
} catch (error) {
// Only update state if not aborted and still loading this event
if (abortSignal.aborted || currentLoadEventId !== eventId) return;
if (abortSignal.aborted || currentLoadEventId !== eventId) {
console.log('loadHierarchy: Error but aborted or event changed');
return;
}
console.error('Error building event hierarchy:', error);
hierarchyChain = [opEvent]; // Fallback to just the event
// Still try to batch load reactions/zaps for the single event
if (!abortSignal.aborted && currentLoadEventId === eventId) {
batchLoadReactionsAndZaps([opEvent]);
}
} finally {
// Only update loading state if not aborted and still loading this event
if (!abortSignal.aborted && currentLoadEventId === eventId) {
loading = false;
console.log('loadHierarchy: Finished loading', eventId);
} else {
console.log('loadHierarchy: Not updating loading state', { aborted: abortSignal.aborted, currentId: currentLoadEventId, eventId });
}
}
}
// Batch fetch reactions and zaps for all events in the thread
async function batchLoadReactionsAndZaps(events: NostrEvent[]) {
if (loadingReactions || events.length === 0) return;
loadingReactions = true;
try {
// Collect all event IDs from hierarchy
const allEventIds = new Set<string>();
for (const event of events) {
allEventIds.add(event.id);
}
await batchLoadReactionsForEvents(Array.from(allEventIds));
} catch (error) {
console.error('[ThreadDrawer] Error batch loading reactions/zaps:', error);
} finally {
loadingReactions = false;
}
}
// Batch fetch reactions and zaps for a set of event IDs
async function batchLoadReactionsForEvents(eventIds: string[]) {
if (eventIds.length === 0) return;
const allEventIds = new Set([...Array.from(reactionsByEventId.keys()), ...eventIds]);
const eventIdsArray = Array.from(allEventIds);
const reactionRelays = relayManager.getProfileReadRelays();
// Batch fetch reactions for all events
const reactionsWithLowerE = await nostrClient.fetchEvents(
[{ kinds: [KIND.REACTION], '#e': eventIdsArray, limit: config.feedLimit }],
reactionRelays,
{ useCache: true, cacheResults: true, timeout: config.mediumTimeout, priority: 'low' }
);
const reactionsWithUpperE = await nostrClient.fetchEvents(
[{ kinds: [KIND.REACTION], '#E': eventIdsArray, limit: config.feedLimit }],
reactionRelays,
{ useCache: true, cacheResults: true, timeout: config.mediumTimeout, priority: 'low' }
);
// Group reactions by event ID
const reactionsMap = new Map(reactionsByEventId);
for (const reaction of [...reactionsWithLowerE, ...reactionsWithUpperE]) {
// Find which event this reaction is for
const eTag = reaction.tags.find(t => t[0] === 'e' || t[0] === 'E');
if (eTag && eTag[1] && allEventIds.has(eTag[1])) {
const eventId = eTag[1];
if (!reactionsMap.has(eventId)) {
reactionsMap.set(eventId, []);
}
reactionsMap.get(eventId)!.push(reaction);
}
}
// Batch fetch zap receipts for all events
const zapReceipts = await nostrClient.fetchEvents(
[{ kinds: [KIND.ZAP_RECEIPT], '#e': eventIdsArray, limit: config.feedLimit }],
reactionRelays,
{ useCache: true, cacheResults: true, timeout: config.mediumTimeout, priority: 'low' }
);
// Group zap receipts by event ID
const zapsMap = new Map(zapsByEventId);
for (const zap of zapReceipts) {
const eTag = zap.tags.find(t => t[0] === 'e');
if (eTag && eTag[1] && allEventIds.has(eTag[1])) {
const eventId = eTag[1];
if (!zapsMap.has(eventId)) {
zapsMap.set(eventId, []);
}
zapsMap.get(eventId)!.push(zap);
}
}
// Handle drawer open/close - only load when opening
// Track event ID separately to prevent reactive loops from object reference changes
reactionsByEventId = reactionsMap;
zapsByEventId = zapsMap;
}
// Function to trigger full thread loading
function loadFullThreadHierarchy() {
if (!opEvent || !isInitialized || loadFullThread) return;
loadFullThread = true;
const eventId = opEvent.id;
// Create abort controller to track operation lifecycle
const abortController = new AbortController();
// Load hierarchy with abort signal
loadHierarchy(abortController.signal, eventId).catch((error) => {
// Handle any unhandled promise rejections
if (!abortController.signal.aborted) {
console.error('ThreadDrawer: Unhandled error in loadHierarchy', error);
loading = false;
hierarchyChain = opEvent ? [opEvent] : [];
}
});
}
// Handle drawer open/close - reset state when opening/closing
$effect(() => {
const eventId = opEventId;
@ -82,50 +227,37 @@ @@ -82,50 +227,37 @@
if (!isOpen || !eventId) {
currentLoadEventId = null;
lastOpEventId = null;
loadFullThread = false;
if (subscriptionId) {
nostrClient.unsubscribe(subscriptionId);
subscriptionId = null;
}
hierarchyChain = [];
loading = false;
reactionsByEventId.clear();
zapsByEventId.clear();
} else if (!opEvent) {
// Event ID exists but opEvent is null - might be loading, set loading to false
console.warn('ThreadDrawer: opEvent is null but eventId exists', eventId);
loading = false;
}
return;
}
// Only load if event ID changed (not just object reference)
// Don't check hierarchyChain.length here - it causes reactive loops
if (lastOpEventId === eventId) {
return; // Already loaded or loading this event
}
// Reset loadFullThread when event changes
if (lastOpEventId !== eventId) {
loadFullThread = false;
hierarchyChain = [];
lastOpEventId = eventId;
// Create abort controller to track effect lifecycle
const abortController = new AbortController();
// Cancel any previous load for a different event
if (currentLoadEventId && currentLoadEventId !== eventId) {
currentLoadEventId = null;
// Load reactions for the single event when drawer opens
if (opEvent) {
batchLoadReactionsForEvents([opEvent.id]);
}
// Drawer opened - load hierarchy with abort signal
loadHierarchy(abortController.signal, eventId);
// Cleanup subscription when drawer closes or event changes
return () => {
// Abort the async operation
abortController.abort();
// Clear current load if this was the active one
if (currentLoadEventId === eventId) {
currentLoadEventId = null;
}
if (subscriptionId) {
nostrClient.unsubscribe(subscriptionId);
subscriptionId = null;
}
};
// Don't auto-load hierarchy - only load when "View thread" is clicked
// The effect just handles cleanup and state reset
});
// Handle keyboard events
@ -190,8 +322,26 @@ @@ -190,8 +322,26 @@
</div>
{:else}
<div class="thread-content">
{#if !loadFullThread}
<!-- Single event view - show just the event with "View thread" button -->
<div class="op-post">
<FeedPost
post={opEvent}
fullView={true}
preloadedReactions={reactionsByEventId.get(opEvent.id)}
/>
</div>
<div class="view-thread-prompt">
<button
onclick={loadFullThreadHierarchy}
class="view-thread-btn text-fog-accent dark:text-fog-dark-accent hover:underline"
style="font-size: 1em; padding: 0.5rem 1rem; margin: 1rem 0;"
>
View thread
</button>
</div>
{:else if hierarchyChain.length > 0}
<!-- Display full event hierarchy (root to leaf) -->
{#if hierarchyChain.length > 0}
{#each hierarchyChain as parentEvent, index (parentEvent.id)}
<div class="hierarchy-post">
{#if index > 0}
@ -199,20 +349,40 @@ @@ -199,20 +349,40 @@
<span class="hierarchy-label">Replying to:</span>
</div>
{/if}
<FeedPost post={parentEvent} />
<FeedPost
post={parentEvent}
fullView={true}
preloadedReactions={reactionsByEventId.get(parentEvent.id)}
/>
</div>
{/each}
{:else}
<!-- Fallback: just show the event -->
<div class="op-post">
<FeedPost post={opEvent} />
<FeedPost
post={opEvent}
fullView={true}
preloadedReactions={reactionsByEventId.get(opEvent.id)}
/>
</div>
{/if}
<!-- Display comments/replies -->
<!-- Display comments/replies only when full thread is loaded -->
{#if loadFullThread}
<div class="comments-section">
<CommentThread threadId={opEvent.id} event={opEvent} />
<CommentThread
threadId={opEvent.id}
event={opEvent}
preloadedReactions={reactionsByEventId}
onCommentsLoaded={(commentEventIds) => {
// When comments are loaded, also batch fetch reactions for them
if (commentEventIds.length > 0) {
batchLoadReactionsForEvents(commentEventIds);
}
}}
/>
</div>
{/if}
</div>
{/if}
</div>

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

@ -174,14 +174,25 @@ @@ -174,14 +174,25 @@
}
}
// Cache of checked reaction IDs to avoid repeated deletion checks
const checkedReactionIds = new Set<string>();
async function filterDeletedReactions(reactions: NostrEvent[]): Promise<NostrEvent[]> {
if (reactions.length === 0) return reactions;
// Filter out reactions we've already checked and found to be non-deleted
const uncheckedReactions = reactions.filter(r => !checkedReactionIds.has(r.id));
// If all reactions have been checked, return as-is (they're all non-deleted)
if (uncheckedReactions.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);
const reactionIds = uncheckedReactions.map(r => r.id);
// Limit to first 100 reactions to avoid massive queries
const limitedReactionIds = reactionIds.slice(0, 100);
@ -206,9 +217,13 @@ @@ -206,9 +217,13 @@
}
}
// Filter out deleted reactions - much simpler now
// Filter out deleted reactions and mark non-deleted ones as checked
const filtered = reactions.filter(reaction => {
const isDeleted = deletedReactionIds.has(reaction.id);
if (!isDeleted) {
// Cache that this reaction is not deleted
checkedReactionIds.add(reaction.id);
}
return !isDeleted;
});

41
src/routes/topics/[name]/+page.svelte

@ -1,6 +1,7 @@ @@ -1,6 +1,7 @@
<script lang="ts">
import Header from '../../../lib/components/layout/Header.svelte';
import FeedPost from '../../../lib/modules/feed/FeedPost.svelte';
import ThreadDrawer from '../../../lib/modules/feed/ThreadDrawer.svelte';
import { nostrClient } from '../../../lib/services/nostr/nostr-client.js';
import { relayManager } from '../../../lib/services/nostr/relay-manager.js';
import { config } from '../../../lib/services/nostr/config.js';
@ -13,6 +14,19 @@ @@ -13,6 +14,19 @@
let events = $state<NostrEvent[]>([]);
let loading = $state(true);
let topicName = $derived($page.params.name);
let loadingEvents = $state(false); // Guard to prevent concurrent loads
let drawerOpen = $state(false);
let drawerEvent = $state<NostrEvent | null>(null);
function openDrawer(event: NostrEvent) {
drawerEvent = event;
drawerOpen = true;
}
function closeDrawer() {
drawerOpen = false;
drawerEvent = null;
}
// Pagination: 2 pages of 100 events each (100 per filter from relays, cache can supplement)
const EVENTS_PER_PAGE = 100;
@ -37,24 +51,27 @@ @@ -37,24 +51,27 @@
$effect(() => {
if ($page.params.name) {
currentPage = 1;
events = []; // Clear events when topic changes
}
});
onMount(async () => {
await nostrClient.initialize();
if (topicName) {
await loadCachedTopicEvents();
await loadTopicEvents();
}
});
$effect(() => {
if ($page.params.name) {
if ($page.params.name && !loadingEvents) {
loadCachedTopicEvents();
loadTopicEvents();
}
});
async function loadCachedTopicEvents() {
if (!topicName) return;
if (!topicName || loadingEvents) return;
try {
// Load cached events for this topic (within 15 minutes)
@ -76,8 +93,15 @@ @@ -76,8 +93,15 @@
}
if (matchingEvents.length > 0) {
// Deduplicate
// Merge with existing events (don't replace)
const eventMap = new Map<string, NostrEvent>();
// Add existing events first
for (const event of events) {
eventMap.set(event.id, event);
}
// Add new cached events
for (const event of matchingEvents) {
eventMap.set(event.id, event);
}
@ -92,7 +116,9 @@ @@ -92,7 +116,9 @@
}
async function loadTopicEvents() {
if (!topicName) return;
if (!topicName || loadingEvents) return;
loadingEvents = true;
// Only show loading spinner if we don't have cached events
const hasCachedEvents = events.length > 0;
@ -151,9 +177,10 @@ @@ -151,9 +177,10 @@
events = Array.from(eventMap.values()).sort((a, b) => b.created_at - a.created_at);
} catch (error) {
console.error('Error loading topic events:', error);
events = [];
// Don't clear events on error - keep what we have
} finally {
loading = false;
loadingEvents = false;
}
}
</script>
@ -188,7 +215,7 @@ @@ -188,7 +215,7 @@
<div class="events-list">
{#each paginatedEvents as event (event.id)}
<div class="event-item">
<FeedPost post={event} />
<FeedPost post={event} onOpenEvent={openDrawer} />
</div>
{/each}
</div>
@ -230,6 +257,8 @@ @@ -230,6 +257,8 @@
</div>
</main>
<ThreadDrawer opEvent={drawerEvent} isOpen={drawerOpen} onClose={closeDrawer} />
<style>
.topic-content {
max-width: var(--content-width);

Loading…
Cancel
Save