diff --git a/docker-compose.dev.yml b/docker-compose.dev.yml index e89c9f4..7d92ead 100644 --- a/docker-compose.dev.yml +++ b/docker-compose.dev.yml @@ -16,7 +16,7 @@ services: image: ghcr.io/danvergara/jumble-proxy-server:latest environment: - ALLOW_ORIGIN=${JUMBLE_SOCIAL_URL:-http://localhost:8089} - - JUMBLE_PROXY_GITHUB_TOKEN=${JUMBLE_PROXY_GITHUB_TOKEN} + - JUMBLE_PROXY_GITHUB_TOKEN=${JUMBLE_PROXY_GITHUB_TOKEN:-} - ENABLE_PPROF=true - PORT=8080 ports: diff --git a/docker-compose.yml b/docker-compose.yml index cd578c7..d63a2f1 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -1,5 +1,3 @@ -version: "3.8" - services: jumble: container_name: imwald-jumble @@ -18,7 +16,7 @@ services: image: ghcr.io/danvergara/jumble-proxy-server:latest environment: - ALLOW_ORIGIN=${JUMBLE_SOCIAL_URL:-http://localhost:8089} - - JUMBLE_PROXY_GITHUB_TOKEN=${JUMBLE_PROXY_GITHUB_TOKEN} + - JUMBLE_PROXY_GITHUB_TOKEN=${JUMBLE_PROXY_GITHUB_TOKEN:-} - ENABLE_PPROF=true - PORT=8080 ports: diff --git a/src/components/Content/index.tsx b/src/components/Content/index.tsx index 4b9cbf7..58d76d8 100644 --- a/src/components/Content/index.tsx +++ b/src/components/Content/index.tsx @@ -27,7 +27,6 @@ import { import Emoji from '../Emoji' import ImageGallery from '../ImageGallery' import MediaPlayer from '../MediaPlayer' -import WebPreview from '../WebPreview' import YoutubeEmbeddedPlayer from '../YoutubeEmbeddedPlayer' export default function Content({ @@ -47,7 +46,7 @@ export default function Content({ // Use unified media extraction service const extractedMedia = useMediaExtraction(event, _content) - const { nodes, lastNormalUrl, emojiInfos } = useMemo(() => { + const { nodes, emojiInfos } = useMemo(() => { if (!_content) return {} const nodes = parseContent(_content, [ @@ -62,11 +61,7 @@ export default function Content({ const emojiInfos = getEmojiInfosFromEmojiTags(event?.tags) - const lastNormalUrlNode = nodes.findLast((node) => node.type === 'url') - const lastNormalUrl = - typeof lastNormalUrlNode?.data === 'string' ? cleanUrl(lastNormalUrlNode.data) : undefined - - return { nodes, emojiInfos, lastNormalUrl } + return { nodes, emojiInfos } }, [_content, event]) if (!nodes || nodes.length === 0) { @@ -242,7 +237,7 @@ export default function Content({ /> ) } - // Regular URL, not an image or media + // Regular URL, not an image or media - show WebPreview return } if (node.type === 'invoice') { @@ -279,7 +274,6 @@ export default function Content({ } return null })} - {lastNormalUrl && } ) } diff --git a/src/components/Embedded/EmbeddedNormalUrl.tsx b/src/components/Embedded/EmbeddedNormalUrl.tsx index edd8781..c8927c9 100644 --- a/src/components/Embedded/EmbeddedNormalUrl.tsx +++ b/src/components/Embedded/EmbeddedNormalUrl.tsx @@ -1,18 +1,25 @@ -import { cleanUrl } from '@/lib/url' +import { cleanUrl, isImage, isMedia } from '@/lib/url' +import WebPreview from '../WebPreview' export function EmbeddedNormalUrl({ url }: { url: string }) { // Clean tracking parameters from URLs before displaying/linking const cleanedUrl = cleanUrl(url) - return ( - e.stopPropagation()} - rel="noreferrer" - > - {cleanedUrl} - - ) + // Don't show WebPreview for images or media - they're handled elsewhere + if (isImage(cleanedUrl) || isMedia(cleanedUrl)) { + return ( + e.stopPropagation()} + rel="noreferrer" + > + {cleanedUrl} + + ) + } + + // Show WebPreview for all regular URLs (including those with nostr identifiers) + return } diff --git a/src/components/Note/Highlight/index.tsx b/src/components/Note/Highlight/index.tsx index 34893e4..f5430c7 100644 --- a/src/components/Note/Highlight/index.tsx +++ b/src/components/Note/Highlight/index.tsx @@ -1,9 +1,7 @@ -import { useSmartNoteNavigation } from '@/PageManager' import { Event } from 'nostr-tools' -import { ExternalLink, Highlighter } from 'lucide-react' -import { useTranslation } from 'react-i18next' +import { Highlighter } from 'lucide-react' import { nip19 } from 'nostr-tools' -import { toNote } from '@/lib/link' +import HighlightSourcePreview from '@/components/UniversalContent/HighlightSourcePreview' export default function Highlight({ event, @@ -12,9 +10,6 @@ export default function Highlight({ event: Event className?: string }) { - const { t } = useTranslation() - const { navigateToNote } = useSmartNoteNavigation() - try { // Extract the source (e-tag, a-tag, or r-tag) with improved priority handling @@ -118,36 +113,10 @@ export default function Highlight({ )} - {/* Source link */} + {/* Source preview card */} {source && ( -
- {t('Source')}: - {source.type === 'url' ? ( - - {source.value.length > 50 ? source.value.substring(0, 50) + '...' : source.value} - - - ) : ( - { - e.stopPropagation() - const noteUrl = toNote(source.bech32) - console.log('Navigating to:', noteUrl, 'from source:', source) - navigateToNote(noteUrl) - }} - className="text-blue-500 hover:underline font-mono cursor-pointer" - > - {source.type === 'event' - ? `note1${source.bech32.substring(5, 13)}...` - : `naddr1${source.bech32.substring(6, 14)}...` - } - - )} +
+
)}
diff --git a/src/components/Note/MarkdownArticle/MarkdownArticle.tsx b/src/components/Note/MarkdownArticle/MarkdownArticle.tsx index 4a8b9c2..3b174a9 100644 --- a/src/components/Note/MarkdownArticle/MarkdownArticle.tsx +++ b/src/components/Note/MarkdownArticle/MarkdownArticle.tsx @@ -2,10 +2,11 @@ import { SecondaryPageLink, useSecondaryPage, useSmartHashtagNavigation } from ' import Image from '@/components/Image' import MediaPlayer from '@/components/MediaPlayer' import Wikilink from '@/components/UniversalContent/Wikilink' +import WebPreview from '@/components/WebPreview' import { getLongFormArticleMetadataFromEvent } from '@/lib/event-metadata' import { toNote, toNoteList, toProfile } from '@/lib/link' import { useMediaExtraction } from '@/hooks' -import { cleanUrl } from '@/lib/url' +import { cleanUrl, isImage, isMedia } from '@/lib/url' import { ExternalLink } from 'lucide-react' import { Event, kinds } from 'nostr-tools' import { ExtendedKind } from '@/constants' @@ -233,6 +234,20 @@ export default function MarkdownArticle({ ) } + // Check if this is a regular HTTP/HTTPS URL that should show WebPreview + const cleanedHref = cleanUrl(href) + const isRegularUrl = href.startsWith('http://') || href.startsWith('https://') + const shouldShowPreview = isRegularUrl && !isImage(cleanedHref) && !isMedia(cleanedHref) + + // For regular URLs, wrap in a component that shows WebPreview + if (shouldShowPreview) { + return ( + + + + ) + } + return ( { + const { nodes, emojiInfos } = useMemo(() => { if (!_content) return {} const nodes = parseContent(_content, [ @@ -86,11 +85,7 @@ export default function EnhancedContent({ const emojiInfos = getEmojiInfosFromEmojiTags(event?.tags) - const lastNormalUrlNode = nodes.findLast((node) => node.type === 'url') - const lastNormalUrl = - typeof lastNormalUrlNode?.data === 'string' ? cleanUrl(lastNormalUrlNode.data) : undefined - - return { nodes, emojiInfos, lastNormalUrl } + return { nodes, emojiInfos } }, [_content, event]) if (!nodes || nodes.length === 0) { @@ -269,7 +264,7 @@ export default function EnhancedContent({ /> ) } - // Regular URL, not an image or media + // Regular URL, not an image or media - show WebPreview return } if (node.type === 'invoice') { @@ -306,7 +301,6 @@ export default function EnhancedContent({ } return null })} - {lastNormalUrl && } ) } diff --git a/src/components/UniversalContent/HighlightSourcePreview.tsx b/src/components/UniversalContent/HighlightSourcePreview.tsx index 8195dcf..a2f6025 100644 --- a/src/components/UniversalContent/HighlightSourcePreview.tsx +++ b/src/components/UniversalContent/HighlightSourcePreview.tsx @@ -18,6 +18,7 @@ interface HighlightSourcePreviewProps { } export default function HighlightSourcePreview({ source, className }: HighlightSourcePreviewProps) { + // Always call hooks first, before any conditional returns const alexandriaUrl = useMemo(() => { if (source.type === 'url') { return source.value @@ -25,61 +26,82 @@ export default function HighlightSourcePreview({ source, className }: HighlightS return `https://next-alexandria.gitcitadel.eu/events?id=${source.bech32}` }, [source]) + // Determine what to render without early returns + let content: JSX.Element | null = null + if (source.type === 'event') { // For events, try to decode and show as embedded note try { const decoded = nip19.decode(source.bech32) if (decoded.type === 'nevent' || decoded.type === 'note') { - return ( -
- -
+ content = ( + ) } } catch (error) { console.warn('Failed to decode nostr event:', error) } - } - - if (source.type === 'addressable') { + + // If decoding failed, show as Alexandria link + if (!content) { + content = ( +
+ + + nevent: {source.value.slice(0, 20)}... + + + +
+ ) + } + } else if (source.type === 'addressable') { // For addressable events, try to decode and show as embedded note try { const decoded = nip19.decode(source.bech32) if (decoded.type === 'naddr') { - return ( -
- -
+ content = ( + ) } } catch (error) { console.warn('Failed to decode nostr addressable event:', error) } - } - - // Fallback: show as Alexandria link or WebPreview for URLs - if (source.type === 'url') { - return ( -
- -
+ + // If decoding failed, show as Alexandria link + if (!content) { + content = ( +
+ + + naddr: {source.value.slice(0, 20)}... + + + +
+ ) + } + } else if (source.type === 'url') { + // For URLs, show WebPreview + content = ( + ) } - // For nostr events that couldn't be embedded, show as Alexandria link + // Render content in a wrapper div return ( -
- - - {source.type === 'event' ? 'nevent' : 'naddr'}: {source.value.slice(0, 20)}... - - - +
+ {content}
) } diff --git a/src/components/UserAvatar/index.tsx b/src/components/UserAvatar/index.tsx index 7df4949..47937aa 100644 --- a/src/components/UserAvatar/index.tsx +++ b/src/components/UserAvatar/index.tsx @@ -5,7 +5,8 @@ import { generateImageByPubkey } from '@/lib/pubkey' import { toProfile } from '@/lib/link' import { cn } from '@/lib/utils' import { useSmartProfileNavigation } from '@/PageManager' -import { useMemo } from 'react' +import { nip19 } from 'nostr-tools' +import { useMemo, useState, useEffect } from 'react' const UserAvatarSizeCnMap = { large: 'w-24 h-24', @@ -69,25 +70,111 @@ export function SimpleUserAvatar({ className?: string }) { const { profile } = useFetchProfile(userId) + // Always generate default avatar from userId/pubkey, even if profile isn't loaded yet + const pubkey = useMemo(() => { + if (!userId) return '' + try { + // Try to extract pubkey from userId (handles npub, nprofile, or hex pubkey) + if (userId.length === 64 && /^[0-9a-f]+$/i.test(userId)) { + return userId + } + // Try to decode npub/nprofile to get pubkey + try { + const decoded = nip19.decode(userId) + if (decoded.type === 'npub') { + return decoded.data + } else if (decoded.type === 'nprofile') { + return decoded.data.pubkey + } + } catch { + // Not a valid npub/nprofile, continue + } + // Use profile pubkey if available + if (profile?.pubkey) { + return profile.pubkey + } + return '' + } catch { + return '' + } + }, [userId, profile?.pubkey]) + const defaultAvatar = useMemo( - () => (profile?.pubkey ? generateImageByPubkey(profile.pubkey) : ''), - [profile] + () => (pubkey ? generateImageByPubkey(pubkey) : ''), + [pubkey] ) - if (!profile) { + // Use profile avatar if available, otherwise use default avatar + const avatarSrc = profile?.avatar || defaultAvatar || '' + + // All hooks must be called before any early returns + const [imgError, setImgError] = useState(false) + const [currentSrc, setCurrentSrc] = useState(avatarSrc) + const [imageLoaded, setImageLoaded] = useState(false) + + // Reset error state when src changes + useEffect(() => { + setImgError(false) + setImageLoaded(false) + setCurrentSrc(avatarSrc) + }, [avatarSrc]) + + const handleImageError = () => { + if (profile?.avatar && defaultAvatar && currentSrc === profile.avatar) { + // Try default avatar if profile avatar fails + setCurrentSrc(defaultAvatar) + setImgError(false) + } else { + // Both failed, show placeholder + setImgError(true) + setImageLoaded(true) + } + } + + const handleImageLoad = () => { + setImageLoaded(true) + setImgError(false) + } + + // If we have a pubkey (from decoding npub/nprofile or profile), show avatar even without profile + // Otherwise show skeleton while loading + if (!profile && !pubkey) { return ( ) } - const { avatar, pubkey } = profile - + // Use pubkey from decoded userId if profile isn't loaded yet + const displayPubkey = profile?.pubkey || pubkey || '' + + // Render image directly instead of using Radix UI Avatar for better reliability return ( - - - - {pubkey} - - +
+ {!imgError && currentSrc ? ( + <> + {!imageLoaded && ( +
+ )} + {displayPubkey} + + ) : ( + // Show initials or placeholder when image fails +
+ {displayPubkey ? displayPubkey.slice(0, 2).toUpperCase() : ''} +
+ )} +
) } \ No newline at end of file diff --git a/src/components/WebPreview/index.tsx b/src/components/WebPreview/index.tsx index 950fb95..eb084e3 100644 --- a/src/components/WebPreview/index.tsx +++ b/src/components/WebPreview/index.tsx @@ -1,9 +1,80 @@ import { useFetchWebMetadata } from '@/hooks/useFetchWebMetadata' +import { useFetchEvent } from '@/hooks/useFetchEvent' +import { useFetchProfile } from '@/hooks/useFetchProfile' +import { ExtendedKind } from '@/constants' +import { getLongFormArticleMetadataFromEvent } from '@/lib/event-metadata' import { cn } from '@/lib/utils' import { useContentPolicy } from '@/providers/ContentPolicyProvider' import { useScreenSize } from '@/providers/ScreenSizeProvider' +import { ExternalLink } from 'lucide-react' +import { nip19, kinds } from 'nostr-tools' import { useMemo } from 'react' import Image from '../Image' +import { SimpleUserAvatar } from '../UserAvatar' +import Username from '../Username' + +// Helper function to get event type name +function getEventTypeName(kind: number): string { + switch (kind) { + case kinds.ShortTextNote: + return 'Text Post' + case kinds.LongFormArticle: + return 'Longform Article' + case ExtendedKind.PICTURE: + return 'Picture' + case ExtendedKind.VIDEO: + return 'Video' + case ExtendedKind.SHORT_VIDEO: + return 'Short Video' + case ExtendedKind.POLL: + return 'Poll' + case ExtendedKind.COMMENT: + return 'Comment' + case ExtendedKind.VOICE: + return 'Voice Post' + case ExtendedKind.VOICE_COMMENT: + return 'Voice Comment' + case kinds.Highlights: + return 'Highlight' + case ExtendedKind.PUBLICATION: + return 'Publication' + case ExtendedKind.PUBLICATION_CONTENT: + return 'Publication Content' + case ExtendedKind.WIKI_ARTICLE: + return 'Wiki Article' + case ExtendedKind.WIKI_ARTICLE_MARKDOWN: + return 'Wiki Article' + case ExtendedKind.DISCUSSION: + return 'Discussion' + default: + return `Event (kind ${kind})` + } +} + +// Helper function to extract and strip markdown/asciidoc for preview +function stripMarkdown(content: string): string { + let text = content + // Remove markdown headers + text = text.replace(/^#{1,6}\s+/gm, '') + // Remove markdown bold/italic + text = text.replace(/\*\*([^*]+)\*\*/g, '$1') + text = text.replace(/\*([^*]+)\*/g, '$1') + // Remove markdown links + text = text.replace(/\[([^\]]+)\]\([^)]+\)/g, '$1') + // Remove asciidoc headers + text = text.replace(/^=+\s+/gm, '') + // Remove asciidoc bold/italic + text = text.replace(/\*\*([^*]+)\*\*/g, '$1') + text = text.replace(/_([^_]+)_/g, '$1') + // Remove code blocks + text = text.replace(/```[\s\S]*?```/g, '') + text = text.replace(/`([^`]+)`/g, '$1') + // Remove HTML tags + text = text.replace(/<[^>]+>/g, '') + // Clean up whitespace + text = text.replace(/\n{3,}/g, '\n\n') + return text.trim() +} export default function WebPreview({ url, className }: { url: string; className?: string }) { const { autoLoadMedia } = useContentPolicy() @@ -18,12 +89,164 @@ export default function WebPreview({ url, className }: { url: string; className? } }, [url]) + // Extract nostr identifier from URL + const nostrIdentifier = useMemo(() => { + const naddrMatch = url.match(/(naddr1[a-z0-9]+)/i) + const neventMatch = url.match(/(nevent1[a-z0-9]+)/i) + const noteMatch = url.match(/(note1[a-z0-9]{58})/i) + const npubMatch = url.match(/(npub1[a-z0-9]{58})/i) + const nprofileMatch = url.match(/(nprofile1[a-z0-9]+)/i) + + return naddrMatch?.[1] || neventMatch?.[1] || noteMatch?.[1] || npubMatch?.[1] || nprofileMatch?.[1] || null + }, [url]) + + // Determine nostr type + const nostrType = useMemo(() => { + if (!nostrIdentifier) return null + try { + const decoded = nip19.decode(nostrIdentifier) + return decoded.type + } catch { + return null + } + }, [nostrIdentifier]) + + // Fetch profile for npub/nprofile + const profileId = nostrType === 'npub' || nostrType === 'nprofile' ? (nostrIdentifier || undefined) : undefined + const { profile: fetchedProfile, isFetching: isFetchingProfile } = useFetchProfile(profileId) + + // Fetch event for naddr/nevent/note + const eventId = (nostrType === 'naddr' || nostrType === 'nevent' || nostrType === 'note') ? (nostrIdentifier || undefined) : undefined + const { event: fetchedEvent, isFetching: isFetchingEvent } = useFetchEvent(eventId) + + // Get content preview (first 500 chars, stripped of markdown) - ALWAYS call hooks before any returns + const contentPreview = useMemo(() => { + if (!fetchedEvent?.content) return '' + const stripped = stripMarkdown(fetchedEvent.content) + return stripped.length > 500 ? stripped.substring(0, 500) + '...' : stripped + }, [fetchedEvent]) + + // Early return after ALL hooks are called if (!autoLoadMedia) { return null } - if (!title) { - return null + // Check if we have any opengraph data (title, description, or image) + const hasOpengraphData = title || description || image + + // If no opengraph metadata available, show enhanced fallback link card + if (!hasOpengraphData) { + // Enhanced card for event URLs (always show if nostr identifier detected, even while loading) + if (nostrType === 'naddr' || nostrType === 'nevent' || nostrType === 'note') { + const eventMetadata = fetchedEvent ? getLongFormArticleMetadataFromEvent(fetchedEvent) : null + const eventTypeName = fetchedEvent ? getEventTypeName(fetchedEvent.kind) : null + const eventTitle = eventMetadata?.title || eventTypeName + const eventSummary = eventMetadata?.summary || description + const eventImage = eventMetadata?.image + + return ( +
{ + e.stopPropagation() + window.open(url, '_blank') + }} + > + {eventImage && fetchedEvent && ( + + )} +
+
+ {fetchedEvent ? ( + <> + + + + {eventTypeName} + + ) : ( + + {isFetchingEvent ? 'Loading event...' : 'Event'} + + )} + +
+ {fetchedEvent && ( + <> + {eventTitle && ( +
{eventTitle}
+ )} + {eventSummary && ( +
{eventSummary}
+ )} + {contentPreview && ( +
+ {contentPreview} +
+ )} + + )} +
{hostname}
+
+
+ ) + } + + // Enhanced card for profile URLs (loading state) + if (nostrType === 'npub' || nostrType === 'nprofile') { + return ( +
{ + e.stopPropagation() + window.open(url, '_blank') + }} + > + {fetchedProfile ? ( + + ) : ( +
+ )} +
+
+ {fetchedProfile ? ( + + ) : ( + + {isFetchingProfile ? 'Loading profile...' : 'Profile'} + + )} + +
+
{hostname}
+
{url}
+
+
+ ) + } + + // Basic fallback for non-nostr URLs + return ( +
{ + e.stopPropagation() + window.open(url, '_blank') + }} + > +
+ +
+
{hostname}
+
{url}
+
+
+
+ ) } if (isSmallScreen && image) { @@ -38,7 +261,8 @@ export default function WebPreview({ url, className }: { url: string; className?
{hostname}
-
{title}
+ {title &&
{title}
} + {!title && description &&
{description}
}
) @@ -61,8 +285,12 @@ export default function WebPreview({ url, className }: { url: string; className? )}
{hostname}
-
{title}
-
{description}
+ {title &&
{title}
} + {description && ( +
+ {description} +
+ )}
) diff --git a/src/hooks/useFetchWebMetadata.tsx b/src/hooks/useFetchWebMetadata.tsx index dff352d..b91fe4f 100644 --- a/src/hooks/useFetchWebMetadata.tsx +++ b/src/hooks/useFetchWebMetadata.tsx @@ -4,12 +4,9 @@ import webService from '@/services/web.service' export function useFetchWebMetadata(url: string) { const [metadata, setMetadata] = useState({}) - const proxyServer = import.meta.env.VITE_PROXY_SERVER - if (proxyServer) { - url = `${proxyServer}/sites/${encodeURIComponent(url)}` - } useEffect(() => { + // Pass original URL - web service will handle proxy conversion webService.fetchWebMetadata(url).then((metadata) => setMetadata(metadata)) }, [url]) diff --git a/src/layouts/SecondaryPageLayout/index.tsx b/src/layouts/SecondaryPageLayout/index.tsx index 741056c..975c709 100644 --- a/src/layouts/SecondaryPageLayout/index.tsx +++ b/src/layouts/SecondaryPageLayout/index.tsx @@ -119,6 +119,8 @@ export function SecondaryPageTitlebar({ hideBottomBorder?: boolean titlebar?: React.ReactNode }): JSX.Element { + const { isSmallScreen } = useScreenSize() + if (titlebar) { return ( @@ -134,12 +136,29 @@ export function SecondaryPageTitlebar({ {hideBackButton ? (
{title} + + Im Wald 🌲 +
) : (
{title}
)} + {isSmallScreen && ( +
+ + Im Wald 🌲 + +
+ )} + {!isSmallScreen && !hideBackButton && ( +
+ + Im Wald 🌲 + +
+ )}
{controls}
) diff --git a/src/lib/nostr-parser.tsx b/src/lib/nostr-parser.tsx index 41b6a7a..95303d8 100644 --- a/src/lib/nostr-parser.tsx +++ b/src/lib/nostr-parser.tsx @@ -5,6 +5,7 @@ import { nip19 } from 'nostr-tools' import { EmbeddedMention, EmbeddedNote } from '@/components/Embedded' import ImageGallery from '@/components/ImageGallery' +import WebPreview from '@/components/WebPreview' import { cleanUrl, isImage, isMedia } from '@/lib/url' import { getImetaInfosFromEvent } from '@/lib/event' import { TImetaInfo } from '@/types' @@ -478,16 +479,13 @@ export function renderNostrContent(parsedContent: ParsedNostrContent, className? } if (element.type === 'url' && element.url) { + // Use WebPreview for URLs to show OpenGraph cards return ( - - {element.content} - + url={element.url} + className="mt-2" + /> ) } diff --git a/src/pages/secondary/NotePage/index.tsx b/src/pages/secondary/NotePage/index.tsx index d65e4e0..a503b77 100644 --- a/src/pages/secondary/NotePage/index.tsx +++ b/src/pages/secondary/NotePage/index.tsx @@ -11,11 +11,13 @@ import { Skeleton } from '@/components/ui/skeleton' import { useFetchEvent } from '@/hooks' import SecondaryPageLayout from '@/layouts/SecondaryPageLayout' import { getParentBech32Id, getParentETag, getRootBech32Id } from '@/lib/event' +import { getLongFormArticleMetadataFromEvent } from '@/lib/event-metadata' import { toNote, toNoteList } from '@/lib/link' import { tagNameEquals } from '@/lib/tag' import { cn } from '@/lib/utils' import { Ellipsis } from 'lucide-react' import type { Event } from 'nostr-tools' +import { kinds } from 'nostr-tools' import { forwardRef, useEffect, useMemo, useState } from 'react' import { useTranslation } from 'react-i18next' import NotFound from './NotFound' @@ -41,6 +43,14 @@ const NotePage = forwardRef(({ id, index, hideTitlebar = false }: { id?: string; return 'Note: Text Post' case 30023: // kinds.LongFormArticle return 'Note: Longform Article' + case 30040: // ExtendedKind.PUBLICATION + return 'Note: Publication' + case 30041: // ExtendedKind.PUBLICATION_CONTENT + return 'Note: Publication Content' + case 30817: // ExtendedKind.WIKI_ARTICLE_MARKDOWN + return 'Note: Wiki Article' + case 30818: // ExtendedKind.WIKI_ARTICLE + return 'Note: Wiki Article' case 20: // ExtendedKind.PICTURE return 'Note: Picture' case 21: // ExtendedKind.VIDEO @@ -72,6 +82,22 @@ const NotePage = forwardRef(({ id, index, hideTitlebar = false }: { id?: string; } } + // Get article metadata for OpenGraph tags + const articleMetadata = useMemo(() => { + if (!finalEvent) return null + const articleKinds = [ + kinds.LongFormArticle, // 30023 + ExtendedKind.PUBLICATION, // 30040 + ExtendedKind.PUBLICATION_CONTENT, // 30041 + ExtendedKind.WIKI_ARTICLE_MARKDOWN, // 30817 + ExtendedKind.WIKI_ARTICLE // 30818 + ] + if (articleKinds.includes(finalEvent.kind)) { + return getLongFormArticleMetadataFromEvent(finalEvent) + } + return null + }, [finalEvent]) + // Store title in sessionStorage for primary note view when hideTitlebar is true // This must be called before any early returns to follow Rules of Hooks useEffect(() => { @@ -83,6 +109,84 @@ const NotePage = forwardRef(({ id, index, hideTitlebar = false }: { id?: string; } }, [hideTitlebar, finalEvent]) + // Helper function to update or create meta tags + function updateMetaTag(property: string, content: string) { + // Remove property prefix if present (e.g., 'og:title' or 'property="og:title"') + const prop = property.startsWith('og:') || property.startsWith('article:') ? property : property.replace(/^property="|"$/, '') + + let meta = document.querySelector(`meta[property="${prop}"]`) + if (!meta) { + meta = document.createElement('meta') + meta.setAttribute('property', prop) + document.head.appendChild(meta) + } + meta.setAttribute('content', content) + } + + // Update OpenGraph metadata for articles + useEffect(() => { + if (!articleMetadata || !finalEvent) { + // Reset to default meta tags + updateMetaTag('og:title', 'Jumble') + updateMetaTag('og:description', 'A user-friendly Nostr client focused on relay feed browsing and relay discovery') + updateMetaTag('og:image', 'https://github.com/CodyTseng/jumble/blob/master/resources/og-image.png?raw=true') + updateMetaTag('og:type', 'website') + updateMetaTag('og:url', window.location.href) + + // Remove article:tag if it exists + const articleTagMeta = document.querySelector('meta[property="article:tag"]') + if (articleTagMeta) { + articleTagMeta.remove() + } + + return + } + + // Set article-specific OpenGraph metadata + const title = articleMetadata.title || 'Article' + const description = articleMetadata.summary || '' + const image = articleMetadata.image || 'https://github.com/CodyTseng/jumble/blob/master/resources/og-image.png?raw=true' + const tags = articleMetadata.tags || [] + + updateMetaTag('og:title', title) + updateMetaTag('og:description', description) + updateMetaTag('og:image', image) + updateMetaTag('og:type', 'article') + updateMetaTag('og:url', window.location.href) + + // Remove old article:tag if it exists + const oldArticleTagMeta = document.querySelector('meta[property="article:tag"]') + if (oldArticleTagMeta) { + oldArticleTagMeta.remove() + } + + // Add article-specific tags (one meta tag per tag) + tags.forEach(tag => { + const tagMeta = document.createElement('meta') + tagMeta.setAttribute('property', 'article:tag') + tagMeta.setAttribute('content', tag) + document.head.appendChild(tagMeta) + }) + + // Update document title + document.title = `${title} - Jumble` + + // Cleanup function + return () => { + // Reset to default on unmount + updateMetaTag('og:title', 'Jumble') + updateMetaTag('og:description', 'A user-friendly Nostr client focused on relay feed browsing and relay discovery') + updateMetaTag('og:image', 'https://github.com/CodyTseng/jumble/blob/master/resources/og-image.png?raw=true') + updateMetaTag('og:type', 'website') + updateMetaTag('og:url', window.location.href) + + // Remove article:tag meta tags + document.querySelectorAll('meta[property="article:tag"]').forEach(meta => meta.remove()) + + document.title = 'Jumble' + } + }, [articleMetadata, finalEvent]) + if (!event && isFetching) { return ( diff --git a/src/services/web.service.ts b/src/services/web.service.ts index 482e063..c71d742 100644 --- a/src/services/web.service.ts +++ b/src/services/web.service.ts @@ -9,27 +9,28 @@ class WebService { return await Promise.all( urls.map(async (url) => { try { - // Skip metadata fetching for known problematic domains to reduce CORS errors - const problematicDomains = [ - 'imdb.com', - 'alby.com', - 'github.com', - 'mycelium.social', - 'void.cat' - ] + // Check if we should use proxy server to avoid CORS issues + const proxyServer = import.meta.env.VITE_PROXY_SERVER + const isProxyUrl = url.includes('/sites/') - if (problematicDomains.some(domain => url.includes(domain))) { - return {} + // If proxy is configured and URL isn't already proxied, use proxy + let fetchUrl = url + if (proxyServer && !isProxyUrl) { + fetchUrl = `${proxyServer}/sites/${encodeURIComponent(url)}` } - // Add timeout and better error handling for CORS issues + // Add timeout and better error handling const controller = new AbortController() - const timeoutId = setTimeout(() => controller.abort(), 3000) // 3 second timeout (reduced from 5s) + const timeoutId = setTimeout(() => controller.abort(), 5000) // 5 second timeout for proxy - const res = await fetch(url, { + // Fetch with appropriate headers + const res = await fetch(fetchUrl, { signal: controller.signal, mode: 'cors', - credentials: 'omit' + credentials: 'omit', + headers: { + 'Accept': 'text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8' + } }) clearTimeout(timeoutId)