Browse Source

bug-fixes

master
Silberengel 1 month ago
parent
commit
4f071895da
  1. 70
      src/lib/components/EventMenu.svelte
  2. 10
      src/lib/components/content/ReplyContext.svelte
  3. 26
      src/lib/modules/feed/FeedPage.svelte
  4. 371
      src/lib/modules/feed/FeedPost.svelte
  5. 6
      src/lib/modules/feed/ThreadDrawer.svelte
  6. 55
      src/lib/services/nostr/event-hierarchy.ts
  7. 16
      src/routes/topics/[name]/+page.svelte

70
src/lib/components/EventMenu.svelte

@ -94,34 +94,71 @@ @@ -94,34 +94,71 @@
if (!menuButtonElement || !menuDropdownElement) return;
const buttonRect = menuButtonElement.getBoundingClientRect();
const viewportWidth = window.innerWidth;
const viewportHeight = window.innerHeight;
const padding = 8; // Padding from viewport edges
// Calculate position - align right edge of dropdown with right edge of button
// Position below button by default
const top = buttonRect.bottom + 4;
const right = window.innerWidth - buttonRect.right;
// Get dropdown dimensions (estimate or use actual if available)
let dropdownWidth = 200; // min-width from CSS
let dropdownHeight = 300; // Estimate, will be updated
menuPosition = { top, right };
// Position below button by default, aligned to right edge
let top = buttonRect.bottom + 4;
let right = viewportWidth - buttonRect.right;
// Get actual dimensions after rendering
requestAnimationFrame(() => {
if (!menuDropdownElement) return;
const dropdownRect = menuDropdownElement.getBoundingClientRect();
dropdownWidth = dropdownRect.width;
dropdownHeight = dropdownRect.height;
// Calculate left position from right
const left = viewportWidth - right - dropdownWidth;
// Adjust if menu would go off screen at bottom
if (dropdownRect.bottom > window.innerHeight) {
// Position above button if there's not enough space below
// Check and adjust for viewport boundaries
let adjustedTop = top;
let adjustedRight = right;
// Check bottom overflow
if (top + dropdownHeight + padding > viewportHeight) {
// Try positioning above button
const spaceAbove = buttonRect.top;
const spaceBelow = window.innerHeight - buttonRect.bottom;
if (spaceAbove > spaceBelow) {
menuPosition.top = buttonRect.top - dropdownRect.height - 4;
const spaceBelow = viewportHeight - buttonRect.bottom;
if (spaceAbove >= dropdownHeight + padding || spaceAbove > spaceBelow) {
adjustedTop = buttonRect.top - dropdownHeight - 4;
} else {
// Not enough space above, position at bottom of viewport
adjustedTop = viewportHeight - dropdownHeight - padding;
}
}
// Adjust if menu would go off screen to the left
if (dropdownRect.left < 0) {
menuPosition.right = window.innerWidth - buttonRect.left;
// Check top overflow
if (adjustedTop < padding) {
adjustedTop = padding;
}
// Check right overflow (menu goes off right edge)
if (left < padding) {
adjustedRight = viewportWidth - padding - dropdownWidth;
}
// Check left overflow (menu goes off left edge)
const adjustedLeft = viewportWidth - adjustedRight - dropdownWidth;
if (adjustedLeft < padding) {
adjustedRight = viewportWidth - padding - dropdownWidth;
}
// Ensure menu doesn't go off right edge
if (adjustedRight < padding) {
adjustedRight = padding;
}
menuPosition = { top: adjustedTop, right: adjustedRight };
});
// Set initial position
menuPosition = { top, right };
}
function closeMenu() {
@ -495,8 +532,11 @@ @@ -495,8 +532,11 @@
border-radius: 6px;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
min-width: 200px;
max-width: calc(100vw - 16px);
max-height: calc(100vh - 16px);
z-index: 1000;
overflow: visible;
overflow-y: auto;
overflow-x: hidden;
}
:global(.dark) .menu-dropdown {

10
src/lib/components/content/ReplyContext.svelte

@ -73,7 +73,7 @@ @@ -73,7 +73,7 @@
function getParentPreview(): string {
if (!parentEvent) {
return loadingParent ? 'Loading...' : 'Parent event not found';
return loadingParent ? '' : 'Parent event not found';
}
// Create preview from parent (first 100 chars, plaintext with markdown stripped)
const plaintext = stripMarkdown(parentEvent.content);
@ -85,9 +85,13 @@ @@ -85,9 +85,13 @@
<div
class="reply-context mb-2 p-2 bg-fog-highlight dark:bg-fog-dark-highlight rounded text-xs text-fog-text-light dark:text-fog-dark-text-light"
>
<span class="font-semibold">Replying to:</span> {getParentPreview()}
<span class="font-semibold">Replying to:</span>
{#if loadingParent}
<span class="text-xs opacity-70"> (loading...)</span>
<span class="opacity-70">Loading...</span>
{:else if parentEvent}
{getParentPreview()}
{:else}
<span class="opacity-70">Parent event not found</span>
{/if}
</div>

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

@ -3,7 +3,6 @@ @@ -3,7 +3,6 @@
import { relayManager } from '../../services/nostr/relay-manager.js';
import { config } from '../../services/nostr/config.js';
import FeedPost from './FeedPost.svelte';
import ThreadDrawer from './ThreadDrawer.svelte';
import type { NostrEvent } from '../../types/nostr.js';
import { onMount } from 'svelte';
import { KIND, getFeedKinds, getKindInfo } from '../../types/kind-lookup.js';
@ -19,8 +18,6 @@ @@ -19,8 +18,6 @@
let events = $state<NostrEvent[]>([]);
let loading = $state(true);
let relayError = $state<string | null>(null);
let drawerOpen = $state(false);
let drawerEvent = $state<NostrEvent | null>(null);
// Waiting room for new events
let waitingRoomEvents = $state<NostrEvent[]>([]);
@ -35,16 +32,6 @@ @@ -35,16 +32,6 @@
let isMounted = $state(true);
let initialLoadComplete = $state(false);
function openDrawer(event: NostrEvent) {
drawerEvent = event;
drawerOpen = true;
}
function closeDrawer() {
drawerOpen = false;
drawerEvent = null;
}
// Load waiting room events into feed
function loadWaitingRoomEvents() {
if (waitingRoomEvents.length === 0) return;
@ -243,14 +230,6 @@ @@ -243,14 +230,6 @@
};
});
// 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">
@ -283,7 +262,7 @@ @@ -283,7 +262,7 @@
<div class="feed-posts">
{#each events as event (event.id)}
<FeedPost post={event} onOpenEvent={openDrawer} />
<FeedPost post={event} />
{/each}
</div>
@ -297,9 +276,6 @@ @@ -297,9 +276,6 @@
</button>
</div>
{#if drawerOpen && drawerEvent}
<ThreadDrawer opEvent={drawerEvent} isOpen={drawerOpen} onClose={closeDrawer} />
{/if}
{/if}
</div>

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

@ -8,6 +8,7 @@ @@ -8,6 +8,7 @@
import QuotedContext from '../../components/content/QuotedContext.svelte';
import FeedReactionButtons from '../reactions/FeedReactionButtons.svelte';
import MediaViewer from '../../components/content/MediaViewer.svelte';
import CommentForm from '../comments/CommentForm.svelte';
import type { NostrEvent } from '../../types/nostr.js';
import { getKindInfo, KIND } from '../../types/kind-lookup.js';
import { stripMarkdown } from '../../services/text-utils.js';
@ -15,26 +16,62 @@ @@ -15,26 +16,62 @@
import { sessionManager } from '../../services/auth/session-manager.js';
import { findNIP21Links, parseNIP21 } from '../../services/nostr/nip21-parser.js';
import { nip19 } from 'nostr-tools';
import { nostrClient } from '../../services/nostr/nostr-client.js';
import { relayManager } from '../../services/nostr/relay-manager.js';
import { config } from '../../services/nostr/config.js';
import { onMount } from 'svelte';
interface Props {
post: NostrEvent;
fullView?: boolean; // If true, show full content (markdown, media, profile pics, reactions) - for drawer
fullView?: boolean; // If true, show full content (markdown, media, profile pics, reactions)
preloadedReactions?: NostrEvent[]; // Pre-loaded reactions to avoid duplicate fetches
parentEvent?: NostrEvent; // Optional parent event if already loaded
quotedEvent?: NostrEvent; // Optional quoted event if already loaded
onOpenEvent?: (event: NostrEvent) => void; // Callback to open event in drawer
}
let { post, fullView = false, preloadedReactions, parentEvent: providedParentEvent, quotedEvent: providedQuotedEvent, onOpenEvent }: Props = $props();
let { post, fullView = false, preloadedReactions, parentEvent: providedParentEvent, quotedEvent: providedQuotedEvent }: Props = $props();
// Check if this event is bookmarked (async, so we use state)
// Only check if user is logged in
let bookmarked = $state(false);
const isLoggedIn = $derived(sessionManager.isLoggedIn());
// Collapse state for feed view
let isExpanded = $state(false);
let shouldCollapse = $state(false);
let cardElement: HTMLElement | null = $state(null);
let contentElement: HTMLElement | null = $state(null);
// Reply state
let showReplyForm = $state(false);
// Check if card should be collapsed (only in feed view)
$effect(() => {
if (fullView) {
shouldCollapse = false;
return;
}
// Wait for content to render, then check height
const checkHeight = () => {
if (!cardElement) return;
// Measure the full card height (using scrollHeight to get full content height)
const cardHeight = cardElement.scrollHeight;
shouldCollapse = cardHeight > 500;
};
// Check after content is rendered
const timeoutId = setTimeout(() => {
requestAnimationFrame(() => {
requestAnimationFrame(checkHeight);
});
}, 200);
return () => clearTimeout(timeoutId);
});
function toggleExpand() {
isExpanded = !isExpanded;
}
$effect(() => {
if (isLoggedIn) {
isBookmarked(post.id).then(b => {
@ -337,95 +374,29 @@ @@ -337,95 +374,29 @@
return finalSegments.length > 0 ? finalSegments : segments;
}
// Handle clicking on event links - fetch and open in side panel
async function handleEventLinkClick(e: MouseEvent, eventId: string) {
e.preventDefault();
e.stopPropagation();
console.log('handleEventLinkClick called with eventId:', eventId, 'onOpenEvent:', !!onOpenEvent);
if (!onOpenEvent) {
console.warn('onOpenEvent callback not provided');
return;
}
function getEventUrl(eventId: string): string {
// Decode bech32 or use hex directly to get the event ID
try {
// Ensure nostr client is initialized
await nostrClient.initialize();
// Decode bech32 if needed, or use hex ID directly
let actualEventId: string | null = null;
let kind: number | undefined;
let pubkey: string | undefined;
let dTag: string | undefined;
// Check if it's a bech32 string
if (/^(note|nevent|naddr)1[a-z0-9]+$/i.test(eventId)) {
try {
const decoded = nip19.decode(eventId);
if (decoded.type === 'note') {
actualEventId = String(decoded.data);
} else if (decoded.type === 'nevent') {
if (decoded.data && typeof decoded.data === 'object' && 'id' in decoded.data) {
actualEventId = String(decoded.data.id);
}
} else if (decoded.type === 'naddr') {
if (decoded.data && typeof decoded.data === 'object') {
kind = decoded.data.kind;
pubkey = String(decoded.data.pubkey);
dTag = decoded.data.identifier;
}
}
} catch (error) {
console.error('Error decoding bech32:', error, 'eventId:', eventId);
return;
}
} else if (/^[0-9a-f]{64}$/i.test(eventId)) {
// It's already a hex ID
actualEventId = eventId.toLowerCase();
if (eventId.length === 64 && /^[a-f0-9]{64}$/i.test(eventId)) {
// Already hex - use it directly
return `/event/${eventId}`;
} else {
console.error('Invalid event ID format:', eventId);
return;
}
// Fetch the event
const relays = relayManager.getFeedReadRelays();
let event: NostrEvent | null = null;
if (actualEventId) {
// Fetch by event ID
const events = await nostrClient.fetchEvents(
[{ ids: [actualEventId] }],
relays,
{ timeout: config.singleRelayTimeout }
);
event = events[0] || null;
if (!event) {
console.warn('Event not found for ID:', actualEventId);
}
} else if (kind !== undefined && pubkey && dTag) {
// Fetch naddr by kind+pubkey+d
const events = await nostrClient.fetchEvents(
[{ kinds: [kind], authors: [pubkey], '#d': [dTag] }],
relays,
{ timeout: config.singleRelayTimeout }
);
event = events[0] || null;
if (!event) {
console.warn('Event not found for naddr:', { kind, pubkey, dTag });
// Try to decode bech32
const decoded = nip19.decode(eventId);
if (decoded.type === 'note' || decoded.type === 'nevent') {
const id = decoded.type === 'note' ? String(decoded.data) : (decoded.data && typeof decoded.data === 'object' && 'id' in decoded.data ? String(decoded.data.id) : eventId);
return `/event/${id}`;
} else if (decoded.type === 'naddr') {
// For naddr, use the bech32 string in the URL
return `/event/${eventId}`;
} else {
return `/event/${eventId}`;
}
}
if (event && onOpenEvent) {
console.log('handleEventLinkClick: Opening event in side panel', event.id, event.kind);
onOpenEvent(event);
} else if (!event) {
console.warn('Could not fetch event to open in side panel');
} else if (!onOpenEvent) {
console.warn('onOpenEvent callback not available');
}
} catch (error) {
console.error('Error fetching event for link:', error, 'eventId:', eventId);
} catch (decodeError) {
// If decoding fails, use the ID as-is
return `/event/${eventId}`;
}
}
@ -569,10 +540,12 @@ @@ -569,10 +540,12 @@
</script>
<article
bind:this={cardElement}
class="Feed-post"
data-post-id={post.id}
id="event-{post.id}"
data-event-id={post.id}
class:collapsed={!fullView && shouldCollapse && !isExpanded}
>
{#if fullView}
<!-- Full view: show complete content with markdown, media, profile pics, reactions -->
@ -581,7 +554,6 @@ @@ -581,7 +554,6 @@
parentEvent={providedParentEvent || undefined}
parentEventId={getReplyEventId() || undefined}
targetId={providedParentEvent ? `event-${providedParentEvent.id}` : undefined}
onOpenEvent={onOpenEvent}
/>
{/if}
@ -590,33 +562,21 @@ @@ -590,33 +562,21 @@
quotedEvent={providedQuotedEvent}
quotedEventId={getQuotedEventId() || undefined}
targetId={providedQuotedEvent ? `event-${providedQuotedEvent.id}` : undefined}
onOpenEvent={onOpenEvent}
/>
{/if}
<MetadataCard event={post} hideTitle={true} hideImageIfInMedia={true} />
{#if post.kind === 30040 || post.kind === 30041 || post.kind === 1 || post.kind === 30817 || post.kind === KIND.DISCUSSION_THREAD}
{@const title = getTitle()}
{#if title && title !== 'Untitled'}
<h2 class="post-title font-bold mb-4 text-fog-text dark:text-fog-dark-text" style="font-size: 1.5em;">
{title}
</h2>
{/if}
{@const title = getTitle()}
{#if title && title !== 'Untitled'}
<h2 class="post-title font-bold mb-4 text-fog-text dark:text-fog-dark-text" style="font-size: 1.5em;">
{title}
</h2>
{/if}
<div class="post-header flex flex-col gap-2 mb-2">
<div class="flex items-center gap-2 flex-wrap">
<div class="ml-auto flex items-center gap-2 flex-shrink-0">
{#if onOpenEvent}
<button
onclick={(e) => { e.stopPropagation(); onOpenEvent(post); }}
class="view-thread-btn text-fog-accent dark:text-fog-dark-accent hover:underline"
style="font-size: 0.875em;"
>
View thread
</button>
{/if}
<div class="flex items-center justify-end gap-2 flex-nowrap">
<div class="post-header-actions flex items-center gap-2 flex-shrink-0">
{#if isLoggedIn && bookmarked}
<span class="bookmark-indicator bookmarked" title="Bookmarked">🔖</span>
{/if}
@ -640,6 +600,7 @@ @@ -640,6 +600,7 @@
{/if}
{/if}
</div>
<hr class="post-header-divider" />
</div>
<div class="post-content mb-2">
@ -653,23 +614,6 @@ @@ -653,23 +614,6 @@
{:else}
<!-- Feed view: plaintext only, no profile pics, media as URLs -->
<div class="post-header flex flex-col gap-2 mb-2">
<div class="flex items-center gap-2 flex-wrap">
<div class="ml-auto flex items-center gap-2 flex-shrink-0">
{#if onOpenEvent}
<button
onclick={(e) => { e.stopPropagation(); onOpenEvent(post); }}
class="view-thread-btn text-fog-accent dark:text-fog-dark-accent hover:underline"
style="font-size: 0.875em;"
>
View thread
</button>
{/if}
{#if isLoggedIn && bookmarked}
<span class="bookmark-indicator bookmarked" title="Bookmarked">🔖</span>
{/if}
<EventMenu event={post} showContentActions={true} />
</div>
</div>
<div class="flex items-center gap-2 flex-wrap">
<ProfileBadge pubkey={post.pubkey} inline={true} />
<span class="text-fog-text-light dark:text-fog-dark-text-light flex-shrink-0" style="font-size: 0.75em;">{getRelativeTime()}</span>
@ -687,18 +631,17 @@ @@ -687,18 +631,17 @@
{/if}
{/if}
</div>
<hr class="post-header-divider" />
</div>
{#if post.kind === 30040 || post.kind === 30041 || post.kind === 1 || post.kind === 30817 || post.kind === KIND.DISCUSSION_THREAD}
{@const title = getTitle()}
{#if title && title !== 'Untitled'}
<h2 class="post-title font-bold mb-2 text-fog-text dark:text-fog-dark-text" style="font-size: 1.5em;">
{title}
</h2>
{/if}
{@const title = getTitle()}
{#if title && title !== 'Untitled'}
<h2 class="post-title font-bold mb-2 text-fog-text dark:text-fog-dark-text" style="font-size: 1.5em;">
{title}
</h2>
{/if}
<div class="post-content mb-2">
<div bind:this={contentElement} class="post-content mb-2" class:collapsed-content={!fullView && shouldCollapse && !isExpanded}>
<p class="text-fog-text dark:text-fog-dark-text whitespace-pre-wrap word-wrap">
{#each parseContentWithNIP21Links() as segment}
{#if segment.type === 'text'}
@ -706,13 +649,14 @@ @@ -706,13 +649,14 @@
{:else if segment.type === 'profile' && segment.pubkey}
<ProfileBadge pubkey={segment.pubkey} inline={true} />
{:else if segment.type === 'event' && segment.eventId}
<button
type="button"
class="nostr-event-link text-fog-accent dark:text-fog-dark-accent hover:underline bg-transparent border-none p-0 cursor-pointer"
onclick={(e) => handleEventLinkClick(e, segment.eventId!)}
<a
href={getEventUrl(segment.eventId)}
target="_blank"
rel="noopener noreferrer"
class="nostr-event-link text-fog-accent dark:text-fog-dark-accent hover:underline"
>
{segment.content}
</button>
</a>
{:else if segment.type === 'url' && segment.url}
{@const isMediaUrl = /\.(jpg|jpeg|png|gif|webp|svg|bmp|mp4|webm|ogg|mov|avi|mkv|mp3|wav|ogg|flac|aac|m4a)(\?|#|$)/i.test(segment.url)}
{#if isMediaUrl}
@ -770,14 +714,70 @@ @@ -770,14 +714,70 @@
</div>
{/if}
</div>
{#if !fullView && shouldCollapse}
<div class="show-more-container">
<button
onclick={toggleExpand}
class="show-more-btn text-fog-accent dark:text-fog-dark-accent hover:underline"
style="font-size: 0.875em;"
>
{isExpanded ? 'Show Less' : 'Show More'}
</button>
</div>
{/if}
{#if !fullView}
<div class="feed-card-footer flex items-center justify-between">
<div class="feed-card-actions flex items-center gap-2">
{#if isLoggedIn && bookmarked}
<span class="bookmark-indicator bookmarked" title="Bookmarked">🔖</span>
{/if}
<EventMenu event={post} showContentActions={true} />
</div>
<div class="kind-badge feed-card-kind-badge">
<span class="kind-number">{getKindInfo(post.kind).number}</span>
<span class="kind-description">{getKindInfo(post.kind).description}</span>
</div>
</div>
{/if}
{/if}
<div class="kind-badge">
<span class="kind-number">{getKindInfo(post.kind).number}</span>
<span class="kind-description">{getKindInfo(post.kind).description}</span>
</div>
{#if fullView}
<div class="kind-badge">
<span class="kind-number">{getKindInfo(post.kind).number}</span>
<span class="kind-description">{getKindInfo(post.kind).description}</span>
</div>
{/if}
</article>
{#if isLoggedIn}
<div class="reply-section mb-2">
<button
onclick={() => showReplyForm = !showReplyForm}
class="reply-btn text-fog-accent dark:text-fog-dark-accent hover:underline"
style="font-size: 0.875em;"
>
Reply
</button>
</div>
{#if showReplyForm}
<div class="reply-form-container mb-4">
<CommentForm
threadId={post.id}
rootEvent={post}
onPublished={() => {
showReplyForm = false;
}}
onCancel={() => {
showReplyForm = false;
}}
/>
</div>
{/if}
{/if}
{#if mediaViewerUrl && mediaViewerOpen}
<MediaViewer url={mediaViewerUrl} isOpen={mediaViewerOpen} onClose={closeMediaViewer} />
{/if}
@ -789,6 +789,19 @@ @@ -789,6 +789,19 @@
background: var(--fog-post, #ffffff);
border: 1px solid var(--fog-border, #e5e7eb);
border-radius: 0.25rem;
position: relative;
}
.Feed-post.collapsed {
max-height: 500px;
overflow: hidden;
display: flex;
flex-direction: column;
}
.Feed-post.collapsed .post-content.collapsed-content {
flex: 1;
min-height: 0;
}
:global(.dark) .Feed-post {
@ -796,6 +809,40 @@ @@ -796,6 +809,40 @@
border-color: var(--fog-dark-border, #374151);
}
.post-content.collapsed-content {
max-height: 350px;
overflow: hidden;
position: relative;
}
.post-content.collapsed-content::after {
content: '';
position: absolute;
bottom: 0;
left: 0;
right: 0;
height: 60px;
background: linear-gradient(to bottom, transparent, var(--fog-post, #ffffff));
pointer-events: none;
}
:global(.dark) .post-content.collapsed-content::after {
background: linear-gradient(to bottom, transparent, var(--fog-dark-post, #1f2937));
}
.show-more-container {
text-align: center;
padding: 0.5rem 0;
}
.show-more-btn {
background: none;
border: none;
cursor: pointer;
padding: 0.25rem 0.5rem;
font-weight: 500;
}
.post-content {
line-height: 1.6;
word-wrap: break-word;
@ -839,6 +886,23 @@ @@ -839,6 +886,23 @@
border-top-color: var(--fog-dark-border, #374151);
}
.feed-card-footer {
margin-top: 0.5rem;
padding-top: 0.5rem;
border-top: 1px solid var(--fog-border, #e5e7eb);
flex-shrink: 0;
}
:global(.dark) .feed-card-footer {
border-top-color: var(--fog-dark-border, #374151);
}
.feed-card-actions {
display: flex;
align-items: center;
gap: 0.5rem;
}
.kind-badge {
position: absolute;
bottom: 0.5rem;
@ -852,6 +916,10 @@ @@ -852,6 +916,10 @@
color: var(--fog-text-light, #9ca3af);
}
.feed-card-kind-badge {
position: static;
}
:global(.dark) .kind-badge {
color: var(--fog-dark-text-light, #6b7280);
}
@ -876,6 +944,21 @@ @@ -876,6 +944,21 @@
line-height: 1.5;
}
.post-header-actions {
flex-shrink: 0;
}
.post-header-divider {
margin: 0 0 0.5rem 0;
border: none;
border-top: 1px solid var(--fog-border, #e5e7eb);
width: 100%;
}
:global(.dark) .post-header-divider {
border-top-color: var(--fog-dark-border, #374151);
}
.post-header :global(.profile-badge) {
display: inline-flex;
align-items: center;

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

@ -61,9 +61,11 @@ @@ -61,9 +61,11 @@
console.log('loadHierarchy: Starting load for event', eventId);
try {
// Add timeout to prevent hanging
// Add timeout to prevent hanging - use longer timeout for hierarchy building
// Hierarchy building can take time on slow connections as it fetches parent events recursively
// Use singleRelayTimeout (15s) as hierarchy building is similar in complexity
const timeoutPromise = new Promise((_, reject) => {
setTimeout(() => reject(new Error('buildEventHierarchy timeout')), config.standardTimeout);
setTimeout(() => reject(new Error('buildEventHierarchy timeout - loading thread hierarchy took too long')), config.singleRelayTimeout);
});
const hierarchy = await Promise.race([

55
src/lib/services/nostr/event-hierarchy.ts

@ -26,21 +26,28 @@ export async function buildEventHierarchy(event: NostrEvent): Promise<EventHiera @@ -26,21 +26,28 @@ export async function buildEventHierarchy(event: NostrEvent): Promise<EventHiera
// Helper to get parent reference from an event
function getParentReference(evt: NostrEvent): { type: 'e' | 'q' | 'a'; value: string } | null {
// Check for e-tag (reply to event) - prioritize this
const eTag = evt.tags.find(t => t[0] === 'e' && t[1]);
// Only consider valid e-tags (must be hex string, 64 chars)
const eTag = evt.tags.find(t => t[0] === 'e' && t[1] && /^[a-f0-9]{64}$/i.test(t[1]));
if (eTag && eTag[1]) {
return { type: 'e', value: eTag[1] };
}
// Check for q-tag (quoted event)
const qTag = evt.tags.find(t => t[0] === 'q' && t[1]);
// Only consider valid q-tags (must be hex string, 64 chars)
const qTag = evt.tags.find(t => t[0] === 'q' && t[1] && /^[a-f0-9]{64}$/i.test(t[1]));
if (qTag && qTag[1]) {
return { type: 'q', value: qTag[1] };
}
// Check for a-tag (reply to replaceable event)
const aTag = evt.tags.find(t => t[0] === 'a' && t[1]);
// Format: kind:pubkey:d-tag
const aTag = evt.tags.find(t => t[0] === 'a' && t[1] && t[1].includes(':'));
if (aTag && aTag[1]) {
return { type: 'a', value: aTag[1] };
const parts = aTag[1].split(':');
// Validate a-tag format
if (parts.length === 3 && /^[a-f0-9]{64}$/i.test(parts[1])) {
return { type: 'a', value: aTag[1] };
}
}
return null;
@ -90,6 +97,7 @@ export async function buildEventHierarchy(event: NostrEvent): Promise<EventHiera @@ -90,6 +97,7 @@ export async function buildEventHierarchy(event: NostrEvent): Promise<EventHiera
// Iteratively fetch events and discover more parents
const relays = relayManager.getProfileReadRelays();
let depth = 0;
let lastIterationSize = eventIdsToFetch.size + replaceableEventsToFetch.size;
while ((eventIdsToFetch.size > 0 || replaceableEventsToFetch.size > 0) && depth < maxDepth) {
// Check cache for event IDs first
@ -114,15 +122,32 @@ export async function buildEventHierarchy(event: NostrEvent): Promise<EventHiera @@ -114,15 +122,32 @@ export async function buildEventHierarchy(event: NostrEvent): Promise<EventHiera
const fetchedEvents = await nostrClient.fetchEvents(
[{ ids: uncachedIds, limit: uncachedIds.length }],
relays,
{ useCache: true, cacheResults: true }
{ useCache: true, cacheResults: true, timeout: 5000 }
);
const fetchedIds = new Set(fetchedEvents.map(e => e.id));
for (const fetchedEvent of fetchedEvents) {
eventsMap.set(fetchedEvent.id, fetchedEvent);
collectParentIds(fetchedEvent, depth + 1);
}
// Remove all fetched IDs from the set (including those not found)
for (const id of uncachedIds) {
if (fetchedIds.has(id) || eventsMap.has(id)) {
eventIdsToFetch.delete(id);
} else {
// Event not found - remove it to prevent infinite loop
eventIdsToFetch.delete(id);
visitedIds.add(id); // Mark as visited so we don't try again
}
}
} catch (error) {
console.warn('Error batch fetching events:', error);
// Remove all uncached IDs on error to prevent infinite loop
for (const id of uncachedIds) {
eventIdsToFetch.delete(id);
visitedIds.add(id);
}
}
}
@ -150,7 +175,7 @@ export async function buildEventHierarchy(event: NostrEvent): Promise<EventHiera @@ -150,7 +175,7 @@ export async function buildEventHierarchy(event: NostrEvent): Promise<EventHiera
const fetchedEvents = await nostrClient.fetchEvents(
[{ kinds: [kind], authors: [pubkey], '#d': [dTag], limit: 1 }],
relays,
{ useCache: true, cacheResults: true }
{ useCache: true, cacheResults: true, timeout: 5000 }
);
if (fetchedEvents.length > 0) {
@ -164,13 +189,29 @@ export async function buildEventHierarchy(event: NostrEvent): Promise<EventHiera @@ -164,13 +189,29 @@ export async function buildEventHierarchy(event: NostrEvent): Promise<EventHiera
}
}
// Remove fetched IDs from the set
// Remove any remaining IDs that are in the events map
for (const id of Array.from(eventIdsToFetch)) {
if (eventsMap.has(id)) {
eventIdsToFetch.delete(id);
}
}
// Early exit if no progress is being made (prevents infinite loops)
const currentSize = eventIdsToFetch.size + replaceableEventsToFetch.size;
if (currentSize === 0) {
break; // No more events to fetch
}
// Check if we're stuck (same size as last iteration means no progress)
if (currentSize >= lastIterationSize && depth > 0) {
// We're not making progress - likely missing events or circular references
// Remove remaining IDs to break the loop
eventIdsToFetch.clear();
replaceableEventsToFetch.clear();
break;
}
lastIterationSize = currentSize;
depth++;
}

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

@ -1,7 +1,6 @@ @@ -1,7 +1,6 @@
<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';
@ -15,18 +14,6 @@ @@ -15,18 +14,6 @@
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;
@ -215,7 +202,7 @@ @@ -215,7 +202,7 @@
<div class="events-list">
{#each paginatedEvents as event (event.id)}
<div class="event-item">
<FeedPost post={event} onOpenEvent={openDrawer} />
<FeedPost post={event} />
</div>
{/each}
</div>
@ -257,7 +244,6 @@ @@ -257,7 +244,6 @@
</div>
</main>
<ThreadDrawer opEvent={drawerEvent} isOpen={drawerOpen} onClose={closeDrawer} />
<style>
.topic-content {

Loading…
Cancel
Save