diff --git a/src/lib/components/content/EmbeddedEventBlurb.svelte b/src/lib/components/content/EmbeddedEventBlurb.svelte new file mode 100644 index 0000000..9953389 --- /dev/null +++ b/src/lib/components/content/EmbeddedEventBlurb.svelte @@ -0,0 +1,277 @@ + + +
+
+ Referenced: + {#if loading} + Loading... + {:else if event} +
+ {getEventPreview()} + {getEventKindInfo()} +
+ {:else} + {error || 'Event not found'} + {/if} +
+ {#if event} +
+ +
+ {/if} +
+ + diff --git a/src/lib/components/content/MarkdownRenderer.svelte b/src/lib/components/content/MarkdownRenderer.svelte index f1e5c20..bffdb6f 100644 --- a/src/lib/components/content/MarkdownRenderer.svelte +++ b/src/lib/components/content/MarkdownRenderer.svelte @@ -16,7 +16,9 @@ import { getCachedMarkdown, cacheMarkdown } from '../../services/cache/markdown-cache.js'; import EmbeddedEvent from './EmbeddedEvent.svelte'; + import EmbeddedEventBlurb from './EmbeddedEventBlurb.svelte'; let mountingEmbeddedEvents = $state(false); // Guard for mounting + let mountingEmbeddedBlurbs = $state(false); // Guard for mounting blurbs interface Props { content: string; @@ -425,7 +427,7 @@ link.parsed.type === 'note' || link.parsed.type === 'nevent' || link.parsed.type === 'naddr' ); - // Replace event links with HTML div elements (for block-level display) + // Replace event links with HTML div elements for embedded blurbs (block-level display) // Process from end to start to preserve indices for (let i = eventLinks.length - 1; i >= 0; i--) { const link = eventLinks[i]; @@ -434,8 +436,8 @@ if (eventId && isValidNostrId(eventId)) { // Escape event ID to prevent XSS const escapedEventId = escapeHtml(eventId); - // Create a div element for embedded event cards (block-level) - const div = `
`; + // Create a div element for embedded event blurbs (block-level, styled differently) + const div = `
`; processed = processed.slice(0, link.start) + div + @@ -859,7 +861,65 @@ } } - // Mount EmbeddedEvent components after rendering + // Mount EmbeddedEventBlurb components after rendering (for nostr: links in content) + function mountEmbeddedBlurbs() { + if (!containerRef || mountingEmbeddedBlurbs) return; + + // Find all event blurb placeholders and mount EmbeddedEventBlurb components + const placeholders = containerRef.querySelectorAll('[data-nostr-event-blurb]:not([data-mounted])'); + + if (placeholders.length > 0) { + mountingEmbeddedBlurbs = true; + try { + // Validate event IDs before mounting to prevent invalid fetches + const validPlaceholders: Element[] = []; + placeholders.forEach((placeholder) => { + const eventId = placeholder.getAttribute('data-event-id'); + // Use strict validation to prevent invalid fetches + if (eventId && isValidNostrId(eventId)) { + validPlaceholders.push(placeholder); + } else if (eventId) { + // Invalid event ID - mark as mounted to prevent retries + placeholder.setAttribute('data-mounted', 'true'); + placeholder.textContent = ''; // Don't show invalid IDs + console.debug('Skipping invalid event ID in MarkdownRenderer:', eventId); + } + }); + + if (validPlaceholders.length > 0) { + console.debug(`Mounting ${validPlaceholders.length} EmbeddedEventBlurb components`); + + validPlaceholders.forEach((placeholder) => { + const eventId = placeholder.getAttribute('data-event-id'); + if (eventId) { + placeholder.setAttribute('data-mounted', 'true'); + + try { + // Clear and mount component + placeholder.innerHTML = ''; + // Mount EmbeddedEventBlurb component - it will decode and fetch the event + const instance = mountComponent(placeholder as HTMLElement, EmbeddedEventBlurb as any, { eventId }); + + if (!instance) { + console.warn('EmbeddedEventBlurb mount returned null', { eventId }); + // Fallback: show the event ID + placeholder.textContent = eventId.slice(0, 20) + '...'; + } + } catch (error) { + console.error('Error mounting EmbeddedEventBlurb:', error, { eventId }); + // Show fallback + placeholder.textContent = eventId.slice(0, 20) + '...'; + } + } + }); + } + } finally { + mountingEmbeddedBlurbs = false; + } + } + } + + // Mount EmbeddedEvent components after rendering (for other embedded events) function mountEmbeddedEvents() { if (!containerRef || mountingEmbeddedEvents) return; @@ -985,6 +1045,7 @@ }); mountProfileBadges(); + mountEmbeddedBlurbs(); mountEmbeddedEvents(); }, 150); @@ -1008,6 +1069,7 @@ } mutationDebounceTimeout = setTimeout(() => { mountProfileBadges(); + mountEmbeddedBlurbs(); mountEmbeddedEvents(); mutationDebounceTimeout = null; }, 300); // 300ms debounce diff --git a/src/lib/components/content/MediaAttachments.svelte b/src/lib/components/content/MediaAttachments.svelte index 01c4ed5..5c30c2e 100644 --- a/src/lib/components/content/MediaAttachments.svelte +++ b/src/lib/components/content/MediaAttachments.svelte @@ -4,9 +4,17 @@ interface Props { event: NostrEvent; forceRender?: boolean; // If true, always render media even if URL is in content (for media kinds) + onMediaClick?: (url: string, event: MouseEvent) => void; // Optional callback when media is clicked } - let { event, forceRender = false }: Props = $props(); + let { event, forceRender = false, onMediaClick }: Props = $props(); + + function handleMediaClick(e: MouseEvent, url: string) { + e.stopPropagation(); // Don't trigger parent click handlers + if (onMediaClick) { + onMediaClick(url, e); + } + } interface MediaItem { url: string; @@ -15,6 +23,7 @@ width?: number; height?: number; size?: number; + alt?: string; // Alt text for images source: 'image-tag' | 'imeta' | 'file-tag' | 'content'; } @@ -91,6 +100,7 @@ let mimeType: string | undefined; let width: number | undefined; let height: number | undefined; + let alt: string | undefined; for (let i = 1; i < tag.length; i++) { const item = tag[i]; @@ -102,6 +112,8 @@ width = parseInt(item.substring(2).trim(), 10); } else if (item.startsWith('y ')) { height = parseInt(item.substring(2).trim(), 10); + } else if (item.startsWith('alt ')) { + alt = item.substring(4).trim(); } } @@ -126,6 +138,7 @@ mimeType, width, height, + alt, source: 'imeta' }); seen.add(normalized); @@ -133,6 +146,13 @@ } } } + + // Also check for standalone alt tag (fallback if not in imeta) + const altTag = event.tags.find(t => t[0] === 'alt' && t[1]); + if (altTag && altTag[1] && media.length > 0 && !media[0].alt) { + // Apply alt text to first media item if it doesn't have one + media[0].alt = altTag[1]; + } // 3. file tags (NIP-94) for (const tag of event.tags) { @@ -208,13 +228,33 @@
{#if coverImage}
- + {#if onMediaClick} + + {:else} + {coverImage.alt + {/if} + {#if coverImage.alt} +
{coverImage.alt}
+ {/if}
{/if} @@ -223,13 +263,33 @@ {#each otherMedia as item} {#if item.type === 'image'}
- + {#if onMediaClick} + + {:else} + {item.alt + {/if} + {#if item.alt} +
{item.alt}
+ {/if}
{:else if item.type === 'video'}
@@ -237,10 +297,16 @@ src={item.url} controls preload="metadata" - class="max-w-full rounded" + class="max-w-full rounded {onMediaClick ? 'clickable-media' : ''}" style="max-height: 500px;" - autoplay={false} - muted={false} + autoplay={false} + muted={false} + onclick={(e) => { + // Only open viewer if clicking the video element itself, not controls + if (onMediaClick && (e.target === e.currentTarget || (e.target as HTMLElement).tagName === 'VIDEO')) { + handleMediaClick(e, item.url); + } + }} > Your browser does not support the video tag. @@ -248,13 +314,19 @@
{:else if item.type === 'audio'}
-
@@ -290,6 +362,18 @@ :global(.dark) .cover-image img { border-color: var(--fog-dark-border, #374151); } + + .image-alt-text { + margin-top: 0.5rem; + font-size: 0.875rem; + color: var(--fog-text-light, #52667a); + font-style: italic; + line-height: 1.4; + } + + :global(.dark) .image-alt-text { + color: var(--fog-dark-text-light, #a8b8d0); + } .media-gallery { display: grid; @@ -346,4 +430,24 @@ text-decoration: underline; } + .cover-image-button, + .media-image-button { + background: none; + border: none; + padding: 0; + margin: 0; + cursor: pointer; + display: block; + width: 100%; + } + + .clickable-media { + cursor: pointer; + transition: opacity 0.2s; + } + + .clickable-media:hover { + opacity: 0.9; + } + diff --git a/src/lib/components/content/MediaViewer.svelte b/src/lib/components/content/MediaViewer.svelte index 668081f..c4eaeaa 100644 --- a/src/lib/components/content/MediaViewer.svelte +++ b/src/lib/components/content/MediaViewer.svelte @@ -78,6 +78,18 @@ animation: fadeIn 0.2s ease-out; } + @media (max-width: 768px) { + .media-viewer-backdrop { + padding: 1rem; + } + } + + @media (max-width: 640px) { + .media-viewer-backdrop { + padding: 0.5rem; + } + } + @keyframes fadeIn { from { opacity: 0; } to { opacity: 1; } @@ -90,6 +102,21 @@ display: flex; align-items: center; justify-content: center; + width: 100%; + } + + @media (max-width: 768px) { + .media-viewer-content { + max-width: 95vw; + max-height: 95vh; + } + } + + @media (max-width: 640px) { + .media-viewer-content { + max-width: 100vw; + max-height: 100vh; + } } .media-viewer-close { @@ -111,6 +138,26 @@ transition: background 0.2s; } + @media (max-width: 768px) { + .media-viewer-close { + top: -2rem; + width: 2rem; + height: 2rem; + font-size: 1.5rem; + } + } + + @media (max-width: 640px) { + .media-viewer-close { + top: 0.5rem; + right: 0.5rem; + width: 2rem; + height: 2rem; + font-size: 1.5rem; + background: rgba(0, 0, 0, 0.6); + } + } + .media-viewer-close:hover { background: rgba(255, 255, 255, 0.3); } @@ -122,11 +169,32 @@ border-radius: 0.5rem; } + @media (max-width: 768px) { + .media-viewer-media { + max-height: 95vh; + border-radius: 0.25rem; + } + } + + @media (max-width: 640px) { + .media-viewer-media { + max-height: 100vh; + border-radius: 0; + } + } + .media-viewer-audio { width: 100%; max-width: 600px; } + @media (max-width: 640px) { + .media-viewer-audio { + max-width: 100%; + padding: 0 1rem; + } + } + .media-viewer-unknown { background: var(--fog-post, #ffffff); padding: 2rem; diff --git a/src/lib/components/content/MetadataCard.svelte b/src/lib/components/content/MetadataCard.svelte index 9399f83..011e5bf 100644 --- a/src/lib/components/content/MetadataCard.svelte +++ b/src/lib/components/content/MetadataCard.svelte @@ -38,11 +38,27 @@ const hasMetadata = $derived(description || summary || author || title); const hasContent = $derived(event.content && event.content.trim().length > 0); - const shouldShowMetadata = $derived(hasMetadata || !hasContent); // Show metadata if it exists OR if there's no content // Media kinds check (for filtering tags display) const MEDIA_KINDS: number[] = [KIND.PICTURE_NOTE, KIND.VIDEO_NOTE, KIND.SHORT_VIDEO_NOTE, KIND.VOICE_NOTE, KIND.VOICE_REPLY]; const isMediaKind = $derived(MEDIA_KINDS.includes(event.kind)); + + // Check if there are any tags to display (excluding filtered ones) + const hasDisplayableTags = $derived.by(() => { + if (hasContent || isMediaKind) return false; + for (const tag of event.tags) { + if (tag[0] !== 'image' && tag[0] !== 'description' && tag[0] !== 'summary' && tag[0] !== 'author' && tag[0] !== 'title' && tag[0] !== 'd' && tag[0] !== 'imeta' && tag[0] !== 'file' && tag[0] !== 'alt' && tag[0] !== 'x' && tag[0] !== 'm') { + // Check if tag has any non-empty values + if (tag.slice(1).some(value => value && value.trim())) { + return true; + } + } + } + return false; + }); + + // Only show metadata card if there's actually something to display + const shouldShowMetadata = $derived(hasMetadata || hasDisplayableTags); {#if shouldShowMetadata} diff --git a/src/lib/components/content/QuotedContext.svelte b/src/lib/components/content/QuotedContext.svelte index b4afa22..9b5e04a 100644 --- a/src/lib/components/content/QuotedContext.svelte +++ b/src/lib/components/content/QuotedContext.svelte @@ -3,7 +3,10 @@ import { nostrClient } from '../../services/nostr/nostr-client.js'; import { relayManager } from '../../services/nostr/relay-manager.js'; import { stripMarkdown } from '../../services/text-utils.js'; - import { KIND } from '../../types/kind-lookup.js'; + import { KIND, getKindInfo } from '../../types/kind-lookup.js'; + import { getEventLink } from '../../services/event-links.js'; + import { goto } from '$app/navigation'; + import Icon from '../ui/Icon.svelte'; interface Props { quotedEvent?: NostrEvent; // Optional - if not provided, will load by quotedEventId @@ -17,28 +20,42 @@ let loadedQuotedEvent = $state(null); let loadingQuoted = $state(false); + let loadAttempted = $state(false); // Track if we've already attempted to load to prevent infinite loops // Derive the effective quoted event: prefer provided, fall back to loaded let quotedEvent = $derived(providedQuotedEvent || loadedQuotedEvent); + // Track last quotedEventId to detect changes + let lastQuotedEventId = $state(undefined); + // Sync provided quoted event changes and load if needed $effect(() => { if (providedQuotedEvent) { - // If provided quoted event is available, use it + // If provided quoted event is available, use it and reset load attempt flag + loadAttempted = false; + lastQuotedEventId = undefined; return; } - // If no provided quoted event and we have an ID, try to load it - if (!loadedQuotedEvent && quotedEventId && !loadingQuoted) { + // If quotedEventId changed, reset the load attempt flag + if (quotedEventId !== lastQuotedEventId) { + loadAttempted = false; + loadedQuotedEvent = null; + lastQuotedEventId = quotedEventId; + } + + // If no provided quoted event and we have an ID, try to load it (only once per ID) + if (!loadedQuotedEvent && quotedEventId && !loadingQuoted && !loadAttempted) { loadQuotedEvent(); } }); async function loadQuotedEvent() { const eventId = quotedEventId || quotedEvent?.id; - if (!eventId || loadingQuoted) return; + if (!eventId || loadingQuoted || loadAttempted) return; loadingQuoted = true; + loadAttempted = true; // Mark that we've attempted to load try { const relays = relayManager.getFeedReadRelays(); const events = await nostrClient.fetchEvents( @@ -53,8 +70,10 @@ onQuotedLoaded(loadedQuotedEvent); } } + // If events.length === 0, we've attempted but found nothing - don't retry } catch (error) { console.error('Error loading quoted event:', error); + // On error, we've still attempted - don't retry to prevent loops } finally { loadingQuoted = false; } @@ -64,9 +83,46 @@ if (!quotedEvent) { return loadingQuoted ? 'Loading...' : 'Quoted event not found'; } - // Create preview from quoted event (first 100 chars, plaintext with markdown stripped) - const plaintext = stripMarkdown(quotedEvent.content); - return plaintext.slice(0, 100) + (plaintext.length > 100 ? '...' : ''); + + // If content exists, use it + if (quotedEvent.content && quotedEvent.content.trim()) { + let plaintext = stripMarkdown(quotedEvent.content); + // Remove nostr: links from preview (match full nostr: URI format, with optional spaces) + plaintext = plaintext.replace(/\s*nostr:((npub|note|nevent|naddr|nprofile)1[a-z0-9]+|[0-9a-f]{64})\s*/gi, ' ').trim(); + // Clean up multiple spaces + plaintext = plaintext.replace(/\s+/g, ' ').trim(); + return plaintext.slice(0, 200) + (plaintext.length > 200 ? '...' : ''); + } + + // Otherwise, check for title, summary, description, or alt tag (in that order) + const titleTag = quotedEvent.tags.find(t => t[0] === 'title' && t[1])?.[1]; + if (titleTag && titleTag.trim()) { + return titleTag.trim(); + } + + const summaryTag = quotedEvent.tags.find(t => t[0] === 'summary' && t[1])?.[1]; + if (summaryTag && summaryTag.trim()) { + return summaryTag.trim(); + } + + const descriptionTag = quotedEvent.tags.find(t => t[0] === 'description' && t[1])?.[1]; + if (descriptionTag && descriptionTag.trim()) { + return descriptionTag.trim(); + } + + const altTag = quotedEvent.tags.find(t => t[0] === 'alt' && t[1])?.[1]; + if (altTag && altTag.trim()) { + return altTag.trim(); + } + + // Fallback: show kind + return `Kind ${quotedEvent.kind}`; + } + + function getQuotedKindInfo(): string { + if (!quotedEvent) return ''; + const kindInfo = getKindInfo(quotedEvent.kind); + return `Kind ${kindInfo.number}: ${kindInfo.description}`; } @@ -74,20 +130,119 @@
- Quoting: {getQuotedPreview()} - {#if loadingQuoted} - (loading...) +
+ Quoting: + {#if loadingQuoted} + Loading... + {:else if quotedEvent} +
+ {getQuotedPreview()} + {getQuotedKindInfo()} +
+ {:else} + Quoted event not found + {/if} +
+ {#if quotedEvent} +
+ +
{/if}
diff --git a/src/lib/components/write/AdvancedEditor.svelte b/src/lib/components/write/AdvancedEditor.svelte index dacb9a9..940cea8 100644 --- a/src/lib/components/write/AdvancedEditor.svelte +++ b/src/lib/components/write/AdvancedEditor.svelte @@ -16,6 +16,7 @@ import EmojiPicker from '../content/EmojiPicker.svelte'; import MarkdownRenderer from '../content/MarkdownRenderer.svelte'; import MediaAttachments from '../content/MediaAttachments.svelte'; + import MediaViewer from '../content/MediaViewer.svelte'; import type { NostrEvent } from '../../types/nostr.js'; import Icon from '../ui/Icon.svelte'; @@ -46,6 +47,22 @@ let previewEvent = $state(null); let eventJson = $state('{}'); + // Media viewer state for preview + let mediaViewerOpen = $state(false); + let mediaViewerUrl = $state(null); + + function handleMediaUrlClick(url: string, e: MouseEvent) { + e.stopPropagation(); + e.preventDefault(); + mediaViewerUrl = url; + mediaViewerOpen = true; + } + + function closeMediaViewer() { + mediaViewerOpen = false; + mediaViewerUrl = null; + } + // Generate unique ID for file input const fileInputId = `advanced-editor-file-upload-${Math.random().toString(36).substring(7)}`; @@ -1058,7 +1075,7 @@
{/if} +{#if mediaViewerUrl && mediaViewerOpen} + +{/if} +