diff --git a/src/lib/components/EventMenu.svelte b/src/lib/components/EventMenu.svelte index f94692d..04b3c80 100644 --- a/src/lib/components/EventMenu.svelte +++ b/src/lib/components/EventMenu.svelte @@ -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 @@ 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 { diff --git a/src/lib/components/content/ReplyContext.svelte b/src/lib/components/content/ReplyContext.svelte index df50edd..3106c1d 100644 --- a/src/lib/components/content/ReplyContext.svelte +++ b/src/lib/components/content/ReplyContext.svelte @@ -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 @@
- Replying to: {getParentPreview()} + Replying to: {#if loadingParent} - (loading...) + Loading... + {:else if parentEvent} + {getParentPreview()} + {:else} + Parent event not found {/if}
diff --git a/src/lib/modules/feed/FeedPage.svelte b/src/lib/modules/feed/FeedPage.svelte index bd10609..c130b3f 100644 --- a/src/lib/modules/feed/FeedPage.svelte +++ b/src/lib/modules/feed/FeedPage.svelte @@ -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 @@ let events = $state([]); let loading = $state(true); let relayError = $state(null); - let drawerOpen = $state(false); - let drawerEvent = $state(null); // Waiting room for new events let waitingRoomEvents = $state([]); @@ -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 @@ }; }); - // 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); - });
@@ -283,7 +262,7 @@
{#each events as event (event.id)} - + {/each}
@@ -297,9 +276,6 @@
- {#if drawerOpen && drawerEvent} - - {/if} {/if} diff --git a/src/lib/modules/feed/FeedPost.svelte b/src/lib/modules/feed/FeedPost.svelte index f502555..93dabd2 100644 --- a/src/lib/modules/feed/FeedPost.svelte +++ b/src/lib/modules/feed/FeedPost.svelte @@ -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 @@ 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 @@ 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 @@
{#if fullView} @@ -581,7 +554,6 @@ parentEvent={providedParentEvent || undefined} parentEventId={getReplyEventId() || undefined} targetId={providedParentEvent ? `event-${providedParentEvent.id}` : undefined} - onOpenEvent={onOpenEvent} /> {/if} @@ -590,33 +562,21 @@ quotedEvent={providedQuotedEvent} quotedEventId={getQuotedEventId() || undefined} targetId={providedQuotedEvent ? `event-${providedQuotedEvent.id}` : undefined} - onOpenEvent={onOpenEvent} /> {/if} - {#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'} -

- {title} -

- {/if} + {@const title = getTitle()} + {#if title && title !== 'Untitled'} +

+ {title} +

{/if}
-
-
- {#if onOpenEvent} - - {/if} +
+
{#if isLoggedIn && bookmarked} 🔖 {/if} @@ -640,6 +600,7 @@ {/if} {/if}
+
@@ -653,23 +614,6 @@ {:else}
-
-
- {#if onOpenEvent} - - {/if} - {#if isLoggedIn && bookmarked} - 🔖 - {/if} - -
-
{getRelativeTime()} @@ -687,18 +631,17 @@ {/if} {/if}
+
- {#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'} -

- {title} -

- {/if} + {@const title = getTitle()} + {#if title && title !== 'Untitled'} +

+ {title} +

{/if} -
+

{#each parseContentWithNIP21Links() as segment} {#if segment.type === 'text'} @@ -706,13 +649,14 @@ {:else if segment.type === 'profile' && segment.pubkey} {:else if segment.type === 'event' && segment.eventId} - + {: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 @@

{/if}
+ + {#if !fullView && shouldCollapse} +
+ +
+ {/if} + + {#if !fullView} + + {/if} {/if} -
- {getKindInfo(post.kind).number} - {getKindInfo(post.kind).description} -
+ {#if fullView} +
+ {getKindInfo(post.kind).number} + {getKindInfo(post.kind).description} +
+ {/if}
+{#if isLoggedIn} +
+ +
+ + {#if showReplyForm} +
+ { + showReplyForm = false; + }} + onCancel={() => { + showReplyForm = false; + }} + /> +
+ {/if} +{/if} + {#if mediaViewerUrl && mediaViewerOpen} {/if} @@ -789,12 +789,59 @@ 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 { background: var(--fog-dark-post, #1f2937); 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; @@ -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 @@ 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 @@ 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; diff --git a/src/lib/modules/feed/ThreadDrawer.svelte b/src/lib/modules/feed/ThreadDrawer.svelte index 7468f1b..d368ba2 100644 --- a/src/lib/modules/feed/ThreadDrawer.svelte +++ b/src/lib/modules/feed/ThreadDrawer.svelte @@ -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([ diff --git a/src/lib/services/nostr/event-hierarchy.ts b/src/lib/services/nostr/event-hierarchy.ts index 6c99d7a..adc45be 100644 --- a/src/lib/services/nostr/event-hierarchy.ts +++ b/src/lib/services/nostr/event-hierarchy.ts @@ -26,21 +26,28 @@ export async function buildEventHierarchy(event: NostrEvent): Promise 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 0 || replaceableEventsToFetch.size > 0) && depth < maxDepth) { // Check cache for event IDs first @@ -114,15 +122,32 @@ export async function buildEventHierarchy(event: NostrEvent): Promise 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 0) { @@ -164,13 +189,29 @@ export async function buildEventHierarchy(event: NostrEvent): Promise= 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++; } diff --git a/src/routes/topics/[name]/+page.svelte b/src/routes/topics/[name]/+page.svelte index f296856..3affbd0 100644 --- a/src/routes/topics/[name]/+page.svelte +++ b/src/routes/topics/[name]/+page.svelte @@ -1,7 +1,6 @@