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. 373
      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 @@
if (!menuButtonElement || !menuDropdownElement) return; if (!menuButtonElement || !menuDropdownElement) return;
const buttonRect = menuButtonElement.getBoundingClientRect(); 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 // Get dropdown dimensions (estimate or use actual if available)
// Position below button by default let dropdownWidth = 200; // min-width from CSS
const top = buttonRect.bottom + 4; let dropdownHeight = 300; // Estimate, will be updated
const right = window.innerWidth - buttonRect.right;
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 // Get actual dimensions after rendering
requestAnimationFrame(() => { requestAnimationFrame(() => {
if (!menuDropdownElement) return; if (!menuDropdownElement) return;
const dropdownRect = menuDropdownElement.getBoundingClientRect(); 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 // Check and adjust for viewport boundaries
if (dropdownRect.bottom > window.innerHeight) { let adjustedTop = top;
// Position above button if there's not enough space below let adjustedRight = right;
// Check bottom overflow
if (top + dropdownHeight + padding > viewportHeight) {
// Try positioning above button
const spaceAbove = buttonRect.top; const spaceAbove = buttonRect.top;
const spaceBelow = window.innerHeight - buttonRect.bottom; const spaceBelow = viewportHeight - buttonRect.bottom;
if (spaceAbove > spaceBelow) { if (spaceAbove >= dropdownHeight + padding || spaceAbove > spaceBelow) {
menuPosition.top = buttonRect.top - dropdownRect.height - 4; 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 // Check top overflow
if (dropdownRect.left < 0) { if (adjustedTop < padding) {
menuPosition.right = window.innerWidth - buttonRect.left; 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() { function closeMenu() {
@ -495,8 +532,11 @@
border-radius: 6px; border-radius: 6px;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15); box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
min-width: 200px; min-width: 200px;
max-width: calc(100vw - 16px);
max-height: calc(100vh - 16px);
z-index: 1000; z-index: 1000;
overflow: visible; overflow-y: auto;
overflow-x: hidden;
} }
:global(.dark) .menu-dropdown { :global(.dark) .menu-dropdown {

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

@ -73,7 +73,7 @@
function getParentPreview(): string { function getParentPreview(): string {
if (!parentEvent) { 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) // Create preview from parent (first 100 chars, plaintext with markdown stripped)
const plaintext = stripMarkdown(parentEvent.content); const plaintext = stripMarkdown(parentEvent.content);
@ -85,9 +85,13 @@
<div <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" 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} {#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} {/if}
</div> </div>

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

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

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

@ -8,6 +8,7 @@
import QuotedContext from '../../components/content/QuotedContext.svelte'; import QuotedContext from '../../components/content/QuotedContext.svelte';
import FeedReactionButtons from '../reactions/FeedReactionButtons.svelte'; import FeedReactionButtons from '../reactions/FeedReactionButtons.svelte';
import MediaViewer from '../../components/content/MediaViewer.svelte'; import MediaViewer from '../../components/content/MediaViewer.svelte';
import CommentForm from '../comments/CommentForm.svelte';
import type { NostrEvent } from '../../types/nostr.js'; import type { NostrEvent } from '../../types/nostr.js';
import { getKindInfo, KIND } from '../../types/kind-lookup.js'; import { getKindInfo, KIND } from '../../types/kind-lookup.js';
import { stripMarkdown } from '../../services/text-utils.js'; import { stripMarkdown } from '../../services/text-utils.js';
@ -15,26 +16,62 @@
import { sessionManager } from '../../services/auth/session-manager.js'; import { sessionManager } from '../../services/auth/session-manager.js';
import { findNIP21Links, parseNIP21 } from '../../services/nostr/nip21-parser.js'; import { findNIP21Links, parseNIP21 } from '../../services/nostr/nip21-parser.js';
import { nip19 } from 'nostr-tools'; import { nip19 } from 'nostr-tools';
import { nostrClient } from '../../services/nostr/nostr-client.js'; import { onMount } from 'svelte';
import { relayManager } from '../../services/nostr/relay-manager.js';
import { config } from '../../services/nostr/config.js';
interface Props { interface Props {
post: NostrEvent; 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 preloadedReactions?: NostrEvent[]; // Pre-loaded reactions to avoid duplicate fetches
parentEvent?: NostrEvent; // Optional parent event if already loaded parentEvent?: NostrEvent; // Optional parent event if already loaded
quotedEvent?: NostrEvent; // Optional quoted 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) // Check if this event is bookmarked (async, so we use state)
// Only check if user is logged in // Only check if user is logged in
let bookmarked = $state(false); let bookmarked = $state(false);
const isLoggedIn = $derived(sessionManager.isLoggedIn()); 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(() => { $effect(() => {
if (isLoggedIn) { if (isLoggedIn) {
isBookmarked(post.id).then(b => { isBookmarked(post.id).then(b => {
@ -337,95 +374,29 @@
return finalSegments.length > 0 ? finalSegments : segments; return finalSegments.length > 0 ? finalSegments : segments;
} }
// Handle clicking on event links - fetch and open in side panel
async function handleEventLinkClick(e: MouseEvent, eventId: string) { function getEventUrl(eventId: string): string {
e.preventDefault(); // Decode bech32 or use hex directly to get the event ID
e.stopPropagation();
console.log('handleEventLinkClick called with eventId:', eventId, 'onOpenEvent:', !!onOpenEvent);
if (!onOpenEvent) {
console.warn('onOpenEvent callback not provided');
return;
}
try { try {
// Ensure nostr client is initialized if (eventId.length === 64 && /^[a-f0-9]{64}$/i.test(eventId)) {
await nostrClient.initialize(); // Already hex - use it directly
return `/event/${eventId}`;
// 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();
} else { } else {
console.error('Invalid event ID format:', eventId); // Try to decode bech32
return; 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);
// Fetch the event return `/event/${id}`;
const relays = relayManager.getFeedReadRelays(); } else if (decoded.type === 'naddr') {
let event: NostrEvent | null = null; // For naddr, use the bech32 string in the URL
return `/event/${eventId}`;
if (actualEventId) { } else {
// Fetch by event ID return `/event/${eventId}`;
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 });
} }
} }
} catch (decodeError) {
if (event && onOpenEvent) { // If decoding fails, use the ID as-is
console.log('handleEventLinkClick: Opening event in side panel', event.id, event.kind); return `/event/${eventId}`;
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);
} }
} }
@ -569,10 +540,12 @@
</script> </script>
<article <article
bind:this={cardElement}
class="Feed-post" class="Feed-post"
data-post-id={post.id} data-post-id={post.id}
id="event-{post.id}" id="event-{post.id}"
data-event-id={post.id} data-event-id={post.id}
class:collapsed={!fullView && shouldCollapse && !isExpanded}
> >
{#if fullView} {#if fullView}
<!-- Full view: show complete content with markdown, media, profile pics, reactions --> <!-- Full view: show complete content with markdown, media, profile pics, reactions -->
@ -581,7 +554,6 @@
parentEvent={providedParentEvent || undefined} parentEvent={providedParentEvent || undefined}
parentEventId={getReplyEventId() || undefined} parentEventId={getReplyEventId() || undefined}
targetId={providedParentEvent ? `event-${providedParentEvent.id}` : undefined} targetId={providedParentEvent ? `event-${providedParentEvent.id}` : undefined}
onOpenEvent={onOpenEvent}
/> />
{/if} {/if}
@ -590,33 +562,21 @@
quotedEvent={providedQuotedEvent} quotedEvent={providedQuotedEvent}
quotedEventId={getQuotedEventId() || undefined} quotedEventId={getQuotedEventId() || undefined}
targetId={providedQuotedEvent ? `event-${providedQuotedEvent.id}` : undefined} targetId={providedQuotedEvent ? `event-${providedQuotedEvent.id}` : undefined}
onOpenEvent={onOpenEvent}
/> />
{/if} {/if}
<MetadataCard event={post} hideTitle={true} hideImageIfInMedia={true} /> <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()}
{@const title = getTitle()} {#if title && title !== 'Untitled'}
{#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;">
<h2 class="post-title font-bold mb-4 text-fog-text dark:text-fog-dark-text" style="font-size: 1.5em;"> {title}
{title} </h2>
</h2>
{/if}
{/if} {/if}
<div class="post-header flex flex-col gap-2 mb-2"> <div class="post-header flex flex-col gap-2 mb-2">
<div class="flex items-center gap-2 flex-wrap"> <div class="flex items-center justify-end gap-2 flex-nowrap">
<div class="ml-auto flex items-center gap-2 flex-shrink-0"> <div class="post-header-actions 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} {#if isLoggedIn && bookmarked}
<span class="bookmark-indicator bookmarked" title="Bookmarked">🔖</span> <span class="bookmark-indicator bookmarked" title="Bookmarked">🔖</span>
{/if} {/if}
@ -640,6 +600,7 @@
{/if} {/if}
{/if} {/if}
</div> </div>
<hr class="post-header-divider" />
</div> </div>
<div class="post-content mb-2"> <div class="post-content mb-2">
@ -653,23 +614,6 @@
{:else} {:else}
<!-- Feed view: plaintext only, no profile pics, media as URLs --> <!-- Feed view: plaintext only, no profile pics, media as URLs -->
<div class="post-header flex flex-col gap-2 mb-2"> <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"> <div class="flex items-center gap-2 flex-wrap">
<ProfileBadge pubkey={post.pubkey} inline={true} /> <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> <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 @@
{/if} {/if}
{/if} {/if}
</div> </div>
<hr class="post-header-divider" />
</div> </div>
{#if post.kind === 30040 || post.kind === 30041 || post.kind === 1 || post.kind === 30817 || post.kind === KIND.DISCUSSION_THREAD} {@const title = getTitle()}
{@const title = getTitle()} {#if title && title !== 'Untitled'}
{#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;">
<h2 class="post-title font-bold mb-2 text-fog-text dark:text-fog-dark-text" style="font-size: 1.5em;"> {title}
{title} </h2>
</h2>
{/if}
{/if} {/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"> <p class="text-fog-text dark:text-fog-dark-text whitespace-pre-wrap word-wrap">
{#each parseContentWithNIP21Links() as segment} {#each parseContentWithNIP21Links() as segment}
{#if segment.type === 'text'} {#if segment.type === 'text'}
@ -706,13 +649,14 @@
{:else if segment.type === 'profile' && segment.pubkey} {:else if segment.type === 'profile' && segment.pubkey}
<ProfileBadge pubkey={segment.pubkey} inline={true} /> <ProfileBadge pubkey={segment.pubkey} inline={true} />
{:else if segment.type === 'event' && segment.eventId} {:else if segment.type === 'event' && segment.eventId}
<button <a
type="button" href={getEventUrl(segment.eventId)}
class="nostr-event-link text-fog-accent dark:text-fog-dark-accent hover:underline bg-transparent border-none p-0 cursor-pointer" target="_blank"
onclick={(e) => handleEventLinkClick(e, segment.eventId!)} rel="noopener noreferrer"
class="nostr-event-link text-fog-accent dark:text-fog-dark-accent hover:underline"
> >
{segment.content} {segment.content}
</button> </a>
{:else if segment.type === 'url' && segment.url} {: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)} {@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} {#if isMediaUrl}
@ -770,14 +714,70 @@
</div> </div>
{/if} {/if}
</div> </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} {/if}
<div class="kind-badge"> {#if fullView}
<span class="kind-number">{getKindInfo(post.kind).number}</span> <div class="kind-badge">
<span class="kind-description">{getKindInfo(post.kind).description}</span> <span class="kind-number">{getKindInfo(post.kind).number}</span>
</div> <span class="kind-description">{getKindInfo(post.kind).description}</span>
</div>
{/if}
</article> </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} {#if mediaViewerUrl && mediaViewerOpen}
<MediaViewer url={mediaViewerUrl} isOpen={mediaViewerOpen} onClose={closeMediaViewer} /> <MediaViewer url={mediaViewerUrl} isOpen={mediaViewerOpen} onClose={closeMediaViewer} />
{/if} {/if}
@ -789,12 +789,59 @@
background: var(--fog-post, #ffffff); background: var(--fog-post, #ffffff);
border: 1px solid var(--fog-border, #e5e7eb); border: 1px solid var(--fog-border, #e5e7eb);
border-radius: 0.25rem; 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 { :global(.dark) .Feed-post {
background: var(--fog-dark-post, #1f2937); background: var(--fog-dark-post, #1f2937);
border-color: var(--fog-dark-border, #374151); 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 { .post-content {
line-height: 1.6; line-height: 1.6;
@ -839,6 +886,23 @@
border-top-color: var(--fog-dark-border, #374151); 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 { .kind-badge {
position: absolute; position: absolute;
bottom: 0.5rem; bottom: 0.5rem;
@ -852,6 +916,10 @@
color: var(--fog-text-light, #9ca3af); color: var(--fog-text-light, #9ca3af);
} }
.feed-card-kind-badge {
position: static;
}
:global(.dark) .kind-badge { :global(.dark) .kind-badge {
color: var(--fog-dark-text-light, #6b7280); color: var(--fog-dark-text-light, #6b7280);
} }
@ -876,6 +944,21 @@
line-height: 1.5; 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) { .post-header :global(.profile-badge) {
display: inline-flex; display: inline-flex;
align-items: center; align-items: center;

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

@ -61,9 +61,11 @@
console.log('loadHierarchy: Starting load for event', eventId); console.log('loadHierarchy: Starting load for event', eventId);
try { 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) => { 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([ 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
// Helper to get parent reference from an event // Helper to get parent reference from an event
function getParentReference(evt: NostrEvent): { type: 'e' | 'q' | 'a'; value: string } | null { function getParentReference(evt: NostrEvent): { type: 'e' | 'q' | 'a'; value: string } | null {
// Check for e-tag (reply to event) - prioritize this // 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]) { if (eTag && eTag[1]) {
return { type: 'e', value: eTag[1] }; return { type: 'e', value: eTag[1] };
} }
// Check for q-tag (quoted event) // 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]) { if (qTag && qTag[1]) {
return { type: 'q', value: qTag[1] }; return { type: 'q', value: qTag[1] };
} }
// Check for a-tag (reply to replaceable event) // 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]) { 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; return null;
@ -90,6 +97,7 @@ export async function buildEventHierarchy(event: NostrEvent): Promise<EventHiera
// Iteratively fetch events and discover more parents // Iteratively fetch events and discover more parents
const relays = relayManager.getProfileReadRelays(); const relays = relayManager.getProfileReadRelays();
let depth = 0; let depth = 0;
let lastIterationSize = eventIdsToFetch.size + replaceableEventsToFetch.size;
while ((eventIdsToFetch.size > 0 || replaceableEventsToFetch.size > 0) && depth < maxDepth) { while ((eventIdsToFetch.size > 0 || replaceableEventsToFetch.size > 0) && depth < maxDepth) {
// Check cache for event IDs first // Check cache for event IDs first
@ -114,15 +122,32 @@ export async function buildEventHierarchy(event: NostrEvent): Promise<EventHiera
const fetchedEvents = await nostrClient.fetchEvents( const fetchedEvents = await nostrClient.fetchEvents(
[{ ids: uncachedIds, limit: uncachedIds.length }], [{ ids: uncachedIds, limit: uncachedIds.length }],
relays, 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) { for (const fetchedEvent of fetchedEvents) {
eventsMap.set(fetchedEvent.id, fetchedEvent); eventsMap.set(fetchedEvent.id, fetchedEvent);
collectParentIds(fetchedEvent, depth + 1); 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) { } catch (error) {
console.warn('Error batch fetching events:', 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
const fetchedEvents = await nostrClient.fetchEvents( const fetchedEvents = await nostrClient.fetchEvents(
[{ kinds: [kind], authors: [pubkey], '#d': [dTag], limit: 1 }], [{ kinds: [kind], authors: [pubkey], '#d': [dTag], limit: 1 }],
relays, relays,
{ useCache: true, cacheResults: true } { useCache: true, cacheResults: true, timeout: 5000 }
); );
if (fetchedEvents.length > 0) { if (fetchedEvents.length > 0) {
@ -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)) { for (const id of Array.from(eventIdsToFetch)) {
if (eventsMap.has(id)) { if (eventsMap.has(id)) {
eventIdsToFetch.delete(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++; depth++;
} }

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

@ -1,7 +1,6 @@
<script lang="ts"> <script lang="ts">
import Header from '../../../lib/components/layout/Header.svelte'; import Header from '../../../lib/components/layout/Header.svelte';
import FeedPost from '../../../lib/modules/feed/FeedPost.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 { nostrClient } from '../../../lib/services/nostr/nostr-client.js';
import { relayManager } from '../../../lib/services/nostr/relay-manager.js'; import { relayManager } from '../../../lib/services/nostr/relay-manager.js';
import { config } from '../../../lib/services/nostr/config.js'; import { config } from '../../../lib/services/nostr/config.js';
@ -15,18 +14,6 @@
let loading = $state(true); let loading = $state(true);
let topicName = $derived($page.params.name); let topicName = $derived($page.params.name);
let loadingEvents = $state(false); // Guard to prevent concurrent loads 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) // Pagination: 2 pages of 100 events each (100 per filter from relays, cache can supplement)
const EVENTS_PER_PAGE = 100; const EVENTS_PER_PAGE = 100;
@ -215,7 +202,7 @@
<div class="events-list"> <div class="events-list">
{#each paginatedEvents as event (event.id)} {#each paginatedEvents as event (event.id)}
<div class="event-item"> <div class="event-item">
<FeedPost post={event} onOpenEvent={openDrawer} /> <FeedPost post={event} />
</div> </div>
{/each} {/each}
</div> </div>
@ -257,7 +244,6 @@
</div> </div>
</main> </main>
<ThreadDrawer opEvent={drawerEvent} isOpen={drawerOpen} onClose={closeDrawer} />
<style> <style>
.topic-content { .topic-content {

Loading…
Cancel
Save