Browse Source

Opengraph expanded

fallback cards
imwald
Silberengel 4 months ago
parent
commit
079812d1f4
  1. 2
      docker-compose.dev.yml
  2. 4
      docker-compose.yml
  3. 12
      src/components/Content/index.tsx
  4. 9
      src/components/Embedded/EmbeddedNormalUrl.tsx
  5. 41
      src/components/Note/Highlight/index.tsx
  6. 17
      src/components/Note/MarkdownArticle/MarkdownArticle.tsx
  7. 12
      src/components/UniversalContent/EnhancedContent.tsx
  8. 64
      src/components/UniversalContent/HighlightSourcePreview.tsx
  9. 109
      src/components/UserAvatar/index.tsx
  10. 238
      src/components/WebPreview/index.tsx
  11. 5
      src/hooks/useFetchWebMetadata.tsx
  12. 19
      src/layouts/SecondaryPageLayout/index.tsx
  13. 14
      src/lib/nostr-parser.tsx
  14. 104
      src/pages/secondary/NotePage/index.tsx
  15. 29
      src/services/web.service.ts

2
docker-compose.dev.yml

@ -16,7 +16,7 @@ services: @@ -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:

4
docker-compose.yml

@ -1,5 +1,3 @@ @@ -1,5 +1,3 @@
version: "3.8"
services:
jumble:
container_name: imwald-jumble
@ -18,7 +16,7 @@ services: @@ -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:

12
src/components/Content/index.tsx

@ -27,7 +27,6 @@ import { @@ -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({ @@ -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({ @@ -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({ @@ -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 <EmbeddedNormalUrl url={node.data} key={index} />
}
if (node.type === 'invoice') {
@ -279,7 +274,6 @@ export default function Content({ @@ -279,7 +274,6 @@ export default function Content({
}
return null
})}
{lastNormalUrl && <WebPreview className="mt-2" url={lastNormalUrl} />}
</div>
)
}

9
src/components/Embedded/EmbeddedNormalUrl.tsx

@ -1,9 +1,12 @@ @@ -1,9 +1,12 @@
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)
// Don't show WebPreview for images or media - they're handled elsewhere
if (isImage(cleanedUrl) || isMedia(cleanedUrl)) {
return (
<a
className="text-primary hover:underline"
@ -15,4 +18,8 @@ export function EmbeddedNormalUrl({ url }: { url: string }) { @@ -15,4 +18,8 @@ export function EmbeddedNormalUrl({ url }: { url: string }) {
{cleanedUrl}
</a>
)
}
// Show WebPreview for all regular URLs (including those with nostr identifiers)
return <WebPreview url={cleanedUrl} className="mt-2" />
}

41
src/components/Note/Highlight/index.tsx

@ -1,9 +1,7 @@ @@ -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({ @@ -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({ @@ -118,36 +113,10 @@ export default function Highlight({
</div>
)}
{/* Source link */}
{/* Source preview card */}
{source && (
<div className="text-xs text-muted-foreground flex items-center gap-2">
<span>{t('Source')}:</span>
{source.type === 'url' ? (
<a
href={source.value}
target="_blank"
rel="noopener noreferrer"
className="text-blue-500 hover:underline flex items-center gap-1"
>
{source.value.length > 50 ? source.value.substring(0, 50) + '...' : source.value}
<ExternalLink className="w-3 h-3" />
</a>
) : (
<span
onClick={(e) => {
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)}...`
}
</span>
)}
<div className="mt-3">
<HighlightSourcePreview source={source} className="w-full" />
</div>
)}
</div>

17
src/components/Note/MarkdownArticle/MarkdownArticle.tsx

@ -2,10 +2,11 @@ import { SecondaryPageLink, useSecondaryPage, useSmartHashtagNavigation } from ' @@ -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({ @@ -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 (
<span className="inline-block mt-2">
<WebPreview url={cleanedHref} className="w-full max-w-md" />
</span>
)
}
return (
<a
{...props}

12
src/components/UniversalContent/EnhancedContent.tsx

@ -32,7 +32,6 @@ import { @@ -32,7 +32,6 @@ import {
import Emoji from '../Emoji'
import ImageGallery from '../ImageGallery'
import MediaPlayer from '../MediaPlayer'
import WebPreview from '../WebPreview'
import YoutubeEmbeddedPlayer from '../YoutubeEmbeddedPlayer'
import ParsedContent from './ParsedContent'
@ -71,7 +70,7 @@ export default function EnhancedContent({ @@ -71,7 +70,7 @@ export default function EnhancedContent({
}
// Fallback to original parsing logic
const { nodes, lastNormalUrl, emojiInfos } = useMemo(() => {
const { nodes, emojiInfos } = useMemo(() => {
if (!_content) return {}
const nodes = parseContent(_content, [
@ -86,11 +85,7 @@ export default function EnhancedContent({ @@ -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({ @@ -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 <EmbeddedNormalUrl url={node.data} key={index} />
}
if (node.type === 'invoice') {
@ -306,7 +301,6 @@ export default function EnhancedContent({ @@ -306,7 +301,6 @@ export default function EnhancedContent({
}
return null
})}
{lastNormalUrl && <WebPreview className="mt-2" url={lastNormalUrl} />}
</div>
)
}

64
src/components/UniversalContent/HighlightSourcePreview.tsx

@ -18,6 +18,7 @@ interface HighlightSourcePreviewProps { @@ -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,49 +26,56 @@ export default function HighlightSourcePreview({ source, className }: HighlightS @@ -25,49 +26,56 @@ 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 (
<div className={className}>
content = (
<EmbeddedNote noteId={source.value} className="w-full" />
</div>
)
}
} catch (error) {
console.warn('Failed to decode nostr event:', error)
}
}
if (source.type === 'addressable') {
// If decoding failed, show as Alexandria link
if (!content) {
content = (
<div className={`p-3 border rounded-lg bg-muted/50 ${className}`}>
<a
href={alexandriaUrl}
target="_blank"
rel="noreferrer noopener"
className="flex items-center gap-2 text-blue-600 hover:text-blue-800 break-words"
>
<span className="font-mono text-sm">
nevent: {source.value.slice(0, 20)}...
</span>
<ExternalLink className="w-3 h-3 flex-shrink-0" />
</a>
</div>
)
}
} 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 (
<div className={className}>
content = (
<EmbeddedNote noteId={source.bech32} className="w-full" />
</div>
)
}
} 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 (
<div className={className}>
<WebPreview url={source.value} className="w-full" />
</div>
)
}
// For nostr events that couldn't be embedded, show as Alexandria link
return (
// If decoding failed, show as Alexandria link
if (!content) {
content = (
<div className={`p-3 border rounded-lg bg-muted/50 ${className}`}>
<a
href={alexandriaUrl}
@ -76,10 +84,24 @@ export default function HighlightSourcePreview({ source, className }: HighlightS @@ -76,10 +84,24 @@ export default function HighlightSourcePreview({ source, className }: HighlightS
className="flex items-center gap-2 text-blue-600 hover:text-blue-800 break-words"
>
<span className="font-mono text-sm">
{source.type === 'event' ? 'nevent' : 'naddr'}: {source.value.slice(0, 20)}...
naddr: {source.value.slice(0, 20)}...
</span>
<ExternalLink className="w-3 h-3 flex-shrink-0" />
</a>
</div>
)
}
} else if (source.type === 'url') {
// For URLs, show WebPreview
content = (
<WebPreview url={source.value} className="w-full" />
)
}
// Render content in a wrapper div
return (
<div className={className}>
{content}
</div>
)
}

109
src/components/UserAvatar/index.tsx

@ -5,7 +5,8 @@ import { generateImageByPubkey } from '@/lib/pubkey' @@ -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({ @@ -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 (
<Skeleton className={cn('shrink-0', UserAvatarSizeCnMap[size], 'rounded-full', className)} />
)
}
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 (
<Avatar className={cn('shrink-0', UserAvatarSizeCnMap[size], className)}>
<AvatarImage src={avatar} className="object-cover object-center" />
<AvatarFallback>
<img src={defaultAvatar} alt={pubkey} />
</AvatarFallback>
</Avatar>
<div
className={cn('shrink-0 relative overflow-hidden rounded-full bg-muted', UserAvatarSizeCnMap[size], className)}
>
{!imgError && currentSrc ? (
<>
{!imageLoaded && (
<div className="absolute inset-0 bg-muted animate-pulse" />
)}
<img
src={currentSrc}
alt={displayPubkey}
className={cn(
'h-full w-full object-cover object-center transition-opacity duration-200',
imageLoaded ? 'opacity-100' : 'opacity-0'
)}
onError={handleImageError}
onLoad={handleImageLoad}
loading="lazy"
/>
</>
) : (
// Show initials or placeholder when image fails
<div className="h-full w-full flex items-center justify-center text-xs font-medium text-muted-foreground">
{displayPubkey ? displayPubkey.slice(0, 2).toUpperCase() : ''}
</div>
)}
</div>
)
}

238
src/components/WebPreview/index.tsx

@ -1,9 +1,80 @@ @@ -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,14 +89,166 @@ export default function WebPreview({ url, className }: { url: string; className? @@ -18,14 +89,166 @@ export default function WebPreview({ url, className }: { url: string; className?
}
}, [url])
if (!autoLoadMedia) {
// 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])
if (!title) {
// Early return after ALL hooks are called
if (!autoLoadMedia) {
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 (
<div
className={cn('p-3 clickable flex w-full border rounded-lg overflow-hidden gap-3', className)}
onClick={(e) => {
e.stopPropagation()
window.open(url, '_blank')
}}
>
{eventImage && fetchedEvent && (
<Image
image={{ url: eventImage, pubkey: fetchedEvent.pubkey }}
className="w-20 h-20 rounded-lg flex-shrink-0 object-cover"
hideIfError
/>
)}
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2 mb-1">
{fetchedEvent ? (
<>
<SimpleUserAvatar userId={fetchedEvent.pubkey} size="xSmall" />
<Username userId={fetchedEvent.pubkey} className="text-xs" />
<span className="text-xs text-muted-foreground"></span>
<span className="text-xs text-muted-foreground">{eventTypeName}</span>
</>
) : (
<span className="text-xs text-muted-foreground">
{isFetchingEvent ? 'Loading event...' : 'Event'}
</span>
)}
<ExternalLink className="w-3 h-3 text-muted-foreground flex-shrink-0 ml-auto" />
</div>
{fetchedEvent && (
<>
{eventTitle && (
<div className="font-semibold text-sm line-clamp-2 mb-1">{eventTitle}</div>
)}
{eventSummary && (
<div className="text-xs text-muted-foreground line-clamp-2 mb-1">{eventSummary}</div>
)}
{contentPreview && (
<div className="text-xs text-muted-foreground line-clamp-3 whitespace-pre-wrap break-words">
{contentPreview}
</div>
)}
</>
)}
<div className="text-xs text-muted-foreground truncate mt-1">{hostname}</div>
</div>
</div>
)
}
// Enhanced card for profile URLs (loading state)
if (nostrType === 'npub' || nostrType === 'nprofile') {
return (
<div
className={cn('p-3 clickable flex w-full border rounded-lg overflow-hidden gap-3', className)}
onClick={(e) => {
e.stopPropagation()
window.open(url, '_blank')
}}
>
{fetchedProfile ? (
<SimpleUserAvatar userId={fetchedProfile.pubkey} size="small" />
) : (
<div className="w-7 h-7 rounded-full bg-muted flex-shrink-0" />
)}
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2">
{fetchedProfile ? (
<Username userId={fetchedProfile.pubkey} />
) : (
<span className="text-sm text-muted-foreground">
{isFetchingProfile ? 'Loading profile...' : 'Profile'}
</span>
)}
<ExternalLink className="w-3 h-3 text-muted-foreground flex-shrink-0" />
</div>
<div className="text-xs text-muted-foreground truncate mt-1">{hostname}</div>
<div className="text-xs text-muted-foreground truncate">{url}</div>
</div>
</div>
)
}
// Basic fallback for non-nostr URLs
return (
<div
className={cn('p-2 clickable flex w-full border rounded-lg overflow-hidden', className)}
onClick={(e) => {
e.stopPropagation()
window.open(url, '_blank')
}}
>
<div className="flex-1 w-0 flex items-center gap-2">
<ExternalLink className="w-4 h-4 text-muted-foreground flex-shrink-0" />
<div className="flex-1 min-w-0">
<div className="text-xs text-muted-foreground truncate">{hostname}</div>
<div className="text-sm font-medium truncate">{url}</div>
</div>
</div>
</div>
)
}
if (isSmallScreen && image) {
return (
<div
@ -38,7 +261,8 @@ export default function WebPreview({ url, className }: { url: string; className? @@ -38,7 +261,8 @@ export default function WebPreview({ url, className }: { url: string; className?
<Image image={{ url: image }} className="w-full max-w-[400px] h-44 rounded-none" hideIfError />
<div className="bg-muted p-2 w-full">
<div className="text-xs text-muted-foreground">{hostname}</div>
<div className="font-semibold line-clamp-1">{title}</div>
{title && <div className="font-semibold line-clamp-1">{title}</div>}
{!title && description && <div className="font-semibold line-clamp-1">{description}</div>}
</div>
</div>
)
@ -61,8 +285,12 @@ export default function WebPreview({ url, className }: { url: string; className? @@ -61,8 +285,12 @@ export default function WebPreview({ url, className }: { url: string; className?
)}
<div className="flex-1 w-0 p-2">
<div className="text-xs text-muted-foreground">{hostname}</div>
<div className="font-semibold line-clamp-2">{title}</div>
<div className="text-xs text-muted-foreground line-clamp-5">{description}</div>
{title && <div className="font-semibold line-clamp-2">{title}</div>}
{description && (
<div className={cn("line-clamp-5", title ? "text-xs text-muted-foreground" : "text-sm font-semibold")}>
{description}
</div>
)}
</div>
</div>
)

5
src/hooks/useFetchWebMetadata.tsx

@ -4,12 +4,9 @@ import webService from '@/services/web.service' @@ -4,12 +4,9 @@ import webService from '@/services/web.service'
export function useFetchWebMetadata(url: string) {
const [metadata, setMetadata] = useState<TWebMetadata>({})
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])

19
src/layouts/SecondaryPageLayout/index.tsx

@ -119,6 +119,8 @@ export function SecondaryPageTitlebar({ @@ -119,6 +119,8 @@ export function SecondaryPageTitlebar({
hideBottomBorder?: boolean
titlebar?: React.ReactNode
}): JSX.Element {
const { isSmallScreen } = useScreenSize()
if (titlebar) {
return (
<Titlebar className="p-1" hideBottomBorder={hideBottomBorder}>
@ -134,12 +136,29 @@ export function SecondaryPageTitlebar({ @@ -134,12 +136,29 @@ export function SecondaryPageTitlebar({
{hideBackButton ? (
<div className="flex gap-2 items-center pl-3 w-fit truncate text-lg font-semibold">
{title}
<span className="text-green-600 dark:text-green-500 font-semibold text-sm ml-2">
Im Wald 🌲
</span>
</div>
) : (
<div className="flex items-center flex-1 w-0">
<BackButton>{title}</BackButton>
</div>
)}
{isSmallScreen && (
<div className="absolute left-1/2 transform -translate-x-1/2 z-10">
<span className="text-green-600 dark:text-green-500 font-semibold text-sm">
Im Wald 🌲
</span>
</div>
)}
{!isSmallScreen && !hideBackButton && (
<div className="absolute left-1/2 transform -translate-x-1/2 z-10 pointer-events-none">
<span className="text-green-600 dark:text-green-500 font-semibold text-sm">
Im Wald 🌲
</span>
</div>
)}
<div className="flex-shrink-0">{controls}</div>
</Titlebar>
)

14
src/lib/nostr-parser.tsx

@ -5,6 +5,7 @@ @@ -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? @@ -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 (
<a
<WebPreview
key={index}
href={element.url}
target="_blank"
rel="noopener noreferrer"
className="text-primary hover:text-primary/80 hover:underline break-words"
>
{element.content}
</a>
url={element.url}
className="mt-2"
/>
)
}

104
src/pages/secondary/NotePage/index.tsx

@ -11,11 +11,13 @@ import { Skeleton } from '@/components/ui/skeleton' @@ -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; @@ -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; @@ -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; @@ -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 (
<SecondaryPageLayout ref={ref} index={index} title={hideTitlebar ? undefined : t('Note')}>

29
src/services/web.service.ts

@ -9,27 +9,28 @@ class WebService { @@ -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)

Loading…
Cancel
Save