diff --git a/package.json b/package.json
index 8199d35..512f467 100644
--- a/package.json
+++ b/package.json
@@ -1,6 +1,6 @@
{
"name": "jumble-imwald",
- "version": "14.6",
+ "version": "14.8",
"description": "A user-friendly Nostr client focused on relay feed browsing and relay discovery, forked from Jumble",
"private": true,
"type": "module",
diff --git a/src/components/WebPreview/index.tsx b/src/components/WebPreview/index.tsx
index aa59c9d..abd0fa9 100644
--- a/src/components/WebPreview/index.tsx
+++ b/src/components/WebPreview/index.tsx
@@ -13,6 +13,7 @@ import { useMemo } from 'react'
import Image from '../Image'
import Username from '../Username'
import { cleanUrl } from '@/lib/url'
+import { tagNameEquals } from '@/lib/tag'
// Helper function to get event type name
function getEventTypeName(kind: number): string {
@@ -105,16 +106,44 @@ export default function WebPreview({ url, className }: { url: string; className?
return naddrMatch?.[1] || neventMatch?.[1] || noteMatch?.[1] || npubMatch?.[1] || nprofileMatch?.[1] || null
}, [cleanedUrl])
- // Determine nostr type
- const nostrType = useMemo(() => {
+ // Determine nostr type and extract details
+ const nostrDetails = useMemo(() => {
if (!nostrIdentifier) return null
try {
const decoded = nip19.decode(nostrIdentifier)
- return decoded.type
+ const details: {
+ type: string
+ hexId?: string
+ dTag?: string
+ kind?: number
+ pubkey?: string
+ identifier?: string
+ } = { type: decoded.type }
+
+ if (decoded.type === 'note') {
+ details.hexId = decoded.data
+ } else if (decoded.type === 'nevent') {
+ details.hexId = decoded.data.id
+ details.kind = decoded.data.kind
+ details.pubkey = decoded.data.author
+ } else if (decoded.type === 'naddr') {
+ details.kind = decoded.data.kind
+ details.pubkey = decoded.data.pubkey
+ details.identifier = decoded.data.identifier
+ details.dTag = decoded.data.identifier
+ } else if (decoded.type === 'npub') {
+ details.pubkey = decoded.data
+ } else if (decoded.type === 'nprofile') {
+ details.pubkey = decoded.data.pubkey
+ }
+
+ return details
} catch {
return null
}
}, [nostrIdentifier])
+
+ const nostrType = nostrDetails?.type || null
// Fetch profile for npub/nprofile
const profileId = nostrType === 'npub' || nostrType === 'nprofile' ? (nostrIdentifier || undefined) : undefined
@@ -123,6 +152,14 @@ export default function WebPreview({ url, className }: { url: string; className?
// Fetch event for naddr/nevent/note
const eventId = (nostrType === 'naddr' || nostrType === 'nevent' || nostrType === 'note') ? (nostrIdentifier || undefined) : undefined
const { event: fetchedEvent, isFetching: isFetchingEvent } = useFetchEvent(eventId)
+
+ // Extract d-tag from fetched event if available
+ const eventDTag = useMemo(() => {
+ if (fetchedEvent) {
+ return fetchedEvent.tags.find(tagNameEquals('d'))?.[1]
+ }
+ return nostrDetails?.dTag
+ }, [fetchedEvent, nostrDetails])
// Get content preview (first 500 chars, stripped of markdown) - ALWAYS call hooks before any returns
const contentPreview = useMemo(() => {
@@ -136,10 +173,12 @@ export default function WebPreview({ url, className }: { url: string; className?
return null
}
+ // Always try to fetch OG data for standalone hyperlinks (except internal jumble links)
// Check if we have any opengraph data (title, description, or image)
const hasOpengraphData = !isInternalJumbleLink && (title || description || image)
// If no opengraph metadata available, show enhanced fallback link card
+ // Note: We always attempt to fetch OG data via useFetchWebMetadata hook above
if (!hasOpengraphData) {
// Enhanced card for event URLs (always show if nostr identifier detected, even while loading)
if (nostrType === 'naddr' || nostrType === 'nevent' || nostrType === 'note') {
@@ -159,10 +198,27 @@ export default function WebPreview({ url, className }: { url: string; className?
.map(word => word.charAt(0).toUpperCase() + word.slice(1).toLowerCase())
.join(' ')
}
+
+ // Build identifier details
+ const identifierParts: string[] = []
+ if (nostrDetails?.hexId) {
+ identifierParts.push(`Hex: ${nostrDetails.hexId.substring(0, 16)}...`)
+ }
+ if (eventDTag) {
+ identifierParts.push(`d-tag: ${eventDTag}`)
+ } else if (nostrDetails?.dTag) {
+ identifierParts.push(`d-tag: ${nostrDetails.dTag}`)
+ }
+ if (nostrDetails?.kind) {
+ identifierParts.push(`kind: ${nostrDetails.kind}`)
+ }
+ if (nostrType) {
+ identifierParts.push(`Type: ${nostrType}`)
+ }
return (
{
e.stopPropagation()
window.open(cleanedUrl, '_blank')
@@ -171,7 +227,7 @@ export default function WebPreview({ url, className }: { url: string; className?
{eventImage && fetchedEvent && (
)}
@@ -188,12 +244,12 @@ export default function WebPreview({ url, className }: { url: string; className?
{isFetchingEvent ? 'Loading event...' : 'Event'}
)}
-
+
{fetchedEvent && (
<>
{eventTitle && (
- {eventTitle}
+ {eventTitle}
)}
{isBookstrEvent && bookMetadata && (
@@ -214,6 +270,13 @@ export default function WebPreview({ url, className }: { url: string; className?
)}
>
)}
+ {identifierParts.length > 0 && (
+
+ {identifierParts.map((part, idx) => (
+ {part}
+ ))}
+
+ )}
{hostname}
@@ -222,25 +285,62 @@ export default function WebPreview({ url, className }: { url: string; className?
// Enhanced card for profile URLs (loading state)
if (nostrType === 'npub' || nostrType === 'nprofile') {
+ // Build identifier details for profile
+ const profileIdentifierParts: string[] = []
+ if (nostrDetails?.pubkey) {
+ profileIdentifierParts.push(`Pubkey: ${nostrDetails.pubkey.substring(0, 16)}...`)
+ }
+ if (fetchedProfile?.nip05) {
+ profileIdentifierParts.push(`NIP-05: ${fetchedProfile.nip05}`)
+ }
+ if (nostrType) {
+ profileIdentifierParts.push(`Type: ${nostrType}`)
+ }
+
return (
{
e.stopPropagation()
window.open(cleanedUrl, '_blank')
}}
>
+ {fetchedProfile?.avatar && (
+
+ )}
-
+
{fetchedProfile ? (
-
+ <>
+
+ {fetchedProfile.nip05 && (
+ <>
+ •
+ {fetchedProfile.nip05}
+ >
+ )}
+ >
) : (
{isFetchingProfile ? 'Loading profile...' : 'Profile'}
)}
-
+
+ {fetchedProfile?.about && (
+
{fetchedProfile.about}
+ )}
+ {profileIdentifierParts.length > 0 && (
+
+ {profileIdentifierParts.map((part, idx) => (
+ {part}
+ ))}
+
+ )}
{hostname}
{url}
@@ -248,21 +348,21 @@ export default function WebPreview({ url, className }: { url: string; className?
)
}
- // Basic fallback for non-nostr URLs
+ // Basic fallback for non-nostr URLs - show site information
return (
{
e.stopPropagation()
window.open(cleanedUrl, '_blank')
}}
>
-
-
-
)
diff --git a/src/hooks/useFetchWebMetadata.tsx b/src/hooks/useFetchWebMetadata.tsx
index 9f97c93..e15a7c5 100644
--- a/src/hooks/useFetchWebMetadata.tsx
+++ b/src/hooks/useFetchWebMetadata.tsx
@@ -1,6 +1,7 @@
import { TWebMetadata } from '@/types'
import { useEffect, useState } from 'react'
import webService from '@/services/web.service'
+import logger from '@/lib/logger'
export function useFetchWebMetadata(url: string) {
const [metadata, setMetadata] = useState
({})
@@ -10,13 +11,16 @@ export function useFetchWebMetadata(url: string) {
return
}
+ logger.info('[useFetchWebMetadata] Fetching OG metadata', { url })
+
// Pass original URL - web service will handle proxy conversion
webService.fetchWebMetadata(url)
.then((metadata) => {
+ logger.info('[useFetchWebMetadata] Received metadata', { url, hasTitle: !!metadata.title, hasDescription: !!metadata.description, hasImage: !!metadata.image })
setMetadata(metadata)
})
- .catch(() => {
- // Silent fail
+ .catch((error) => {
+ logger.error('[useFetchWebMetadata] Failed to fetch metadata', { url, error })
})
}, [url])
diff --git a/src/pages/secondary/NotePage/index.tsx b/src/pages/secondary/NotePage/index.tsx
index 350c2c8..57ed272 100644
--- a/src/pages/secondary/NotePage/index.tsx
+++ b/src/pages/secondary/NotePage/index.tsx
@@ -295,30 +295,64 @@ const NotePage = forwardRef(({ id, index, hideTitlebar = false }: { id?: string;
ogDescription = ogDescription.replace('Event', `${eventTypeName} (kind ${finalEvent.kind})`)
}
- const image = eventMetadata?.image || (authorProfile?.avatar ? `https://jumble.imwald.eu/api/avatar/${authorProfile.pubkey}` : 'https://github.com/CodyTseng/jumble/blob/master/resources/og-image.png?raw=true')
+ // Prioritize event image, then author avatar, then default
+ // Use a beautiful green-themed image with profile data
+ let image = eventMetadata?.image
+ if (!image && authorProfile?.avatar) {
+ image = `https://jumble.imwald.eu/api/avatar/${authorProfile.pubkey}`
+ }
+ if (!image) {
+ // Use default OG image with green forest theme
+ image = 'https://github.com/CodyTseng/jumble/blob/master/resources/og-image.png?raw=true'
+ }
+
const tags = eventMetadata?.tags || []
// For articles, use article type; for other events, use website type
const isArticle = articleMetadata !== null
const ogType = isArticle ? 'article' : 'website'
- updateMetaTag('og:title', `${eventTitle} - Jumble Imwald Edition`)
+ // Enhanced title with profile info
+ const ogTitle = authorName
+ ? `${eventTitle} by @${authorName} - Jumble Imwald Edition 🌲`
+ : `${eventTitle} - Jumble Imwald Edition 🌲`
+
+ updateMetaTag('og:title', ogTitle)
updateMetaTag('og:description', ogDescription)
updateMetaTag('og:image', image)
+ updateMetaTag('og:image:width', '1200')
+ updateMetaTag('og:image:height', '630')
+ updateMetaTag('og:image:alt', `${eventTitle}${authorName ? ` by @${authorName}` : ''} on Jumble Imwald`)
updateMetaTag('og:type', ogType)
updateMetaTag('og:url', window.location.href)
updateMetaTag('og:site_name', 'Jumble - Imwald Edition 🌲')
+ // Add profile data - always include if available
+ if (authorProfile) {
+ if (authorProfile.username) {
+ updateMetaTag('profile:username', authorProfile.username)
+ }
+ if (authorProfile.nip05) {
+ updateMetaTag('profile:username', authorProfile.nip05)
+ }
+ }
+
// Add author for articles
if (isArticle && authorName) {
updateMetaTag('article:author', authorName)
+ if (authorProfile?.nip05) {
+ // Add author URL if NIP-05 is available
+ const authorUrl = `https://jumble.imwald.eu/profiles/${finalEvent.pubkey}`
+ updateMetaTag('article:author:url', authorUrl)
+ }
}
// Twitter card meta tags
updateMetaTag('twitter:card', 'summary_large_image')
- updateMetaTag('twitter:title', `${eventTitle} - Jumble Imwald Edition`)
+ updateMetaTag('twitter:title', ogTitle)
updateMetaTag('twitter:description', ogDescription.length > 200 ? ogDescription.substring(0, 197) + '...' : ogDescription)
updateMetaTag('twitter:image', image)
+ updateMetaTag('twitter:image:alt', `${eventTitle}${authorName ? ` by @${authorName}` : ''} on Jumble Imwald`)
// Remove old article:tag if it exists
const oldArticleTagMeta = document.querySelector('meta[property="article:tag"]')
diff --git a/src/pages/secondary/ProfilePage/index.tsx b/src/pages/secondary/ProfilePage/index.tsx
index f7e6e18..7433fae 100644
--- a/src/pages/secondary/ProfilePage/index.tsx
+++ b/src/pages/secondary/ProfilePage/index.tsx
@@ -51,30 +51,52 @@ const ProfilePage = forwardRef(({ id, index, hideTitlebar = false }: { id?: stri
// Build description matching fallback card: username, hostname, URL
const username = profile.username || ''
- const ogTitle = username || 'Profile'
+ const ogTitle = username ? `@${username} - Jumble Imwald Edition 🌲` : 'Profile - Jumble Imwald Edition 🌲'
// Truncate URL to 150 chars
const fullUrl = window.location.href
const truncatedUrl = fullUrl.length > 150 ? fullUrl.substring(0, 147) + '...' : fullUrl
+ // Build rich description with profile info
let ogDescription = username ? `@${username}` : 'Profile'
+ if (profile.nip05) {
+ ogDescription += ` • ${profile.nip05}`
+ }
+ if (profile.about) {
+ const aboutPreview = profile.about.length > 200 ? profile.about.substring(0, 197) + '...' : profile.about
+ ogDescription += ` | ${aboutPreview}`
+ }
ogDescription += ` | ${truncatedUrl}`
- // Use profile avatar or default image
- const image = profile.avatar ? `https://jumble.imwald.eu/api/avatar/${profile.pubkey}` : 'https://github.com/CodyTseng/jumble/blob/master/resources/og-image.png?raw=true'
+ // Use profile avatar or default image with green theme
+ const image = profile.avatar
+ ? `https://jumble.imwald.eu/api/avatar/${profile.pubkey}`
+ : 'https://github.com/CodyTseng/jumble/blob/master/resources/og-image.png?raw=true'
- updateMetaTag('og:title', `${ogTitle} - Jumble Imwald Edition`)
+ updateMetaTag('og:title', ogTitle)
updateMetaTag('og:description', ogDescription)
updateMetaTag('og:image', image)
+ updateMetaTag('og:image:width', '1200')
+ updateMetaTag('og:image:height', '630')
+ updateMetaTag('og:image:alt', `${username ? `@${username}` : 'Profile'} on Jumble Imwald`)
updateMetaTag('og:type', 'profile')
updateMetaTag('og:url', window.location.href)
updateMetaTag('og:site_name', 'Jumble - Imwald Edition 🌲')
+ // Add profile-specific meta tags
+ if (profile.username) {
+ updateMetaTag('profile:username', profile.username)
+ }
+ if (profile.nip05) {
+ updateMetaTag('profile:username', profile.nip05)
+ }
+
// Twitter card meta tags
- updateMetaTag('twitter:card', 'summary')
- updateMetaTag('twitter:title', `${ogTitle} - Jumble Imwald Edition`)
+ updateMetaTag('twitter:card', 'summary_large_image')
+ updateMetaTag('twitter:title', ogTitle)
updateMetaTag('twitter:description', ogDescription.length > 200 ? ogDescription.substring(0, 197) + '...' : ogDescription)
updateMetaTag('twitter:image', image)
+ updateMetaTag('twitter:image:alt', `${username ? `@${username}` : 'Profile'} on Jumble Imwald`)
// Update document title
document.title = `${ogTitle} - Jumble Imwald Edition`
diff --git a/src/services/web.service.ts b/src/services/web.service.ts
index 39f9b5e..e59bb89 100644
--- a/src/services/web.service.ts
+++ b/src/services/web.service.ts
@@ -1,5 +1,6 @@
import { TWebMetadata } from '@/types'
import DataLoader from 'dataloader'
+import logger from '@/lib/logger'
class WebService {
static instance: WebService
@@ -8,14 +9,24 @@ class WebService {
async (urls) => {
return await Promise.all(
urls.map(async (url) => {
+ logger.info('[WebService] Starting OG metadata fetch', { url, proxyServer: import.meta.env.VITE_PROXY_SERVER })
+
// Check if we should use proxy server to avoid CORS issues
+ // Uses the same proxy as wikistr (configured via VITE_PROXY_SERVER build arg)
+ // Since jumble and wikistr run on the same server, they share the same proxy endpoint
const proxyServer = import.meta.env.VITE_PROXY_SERVER
- const isProxyUrl = url.includes('/sites/')
+ const isProxyUrl = url.includes('/sites/') || url.includes('/sites/?url=')
// If proxy is configured and URL isn't already proxied, use proxy
+ // The proxy server expects the URL as a query parameter: /sites/?url=https://example.com
let fetchUrl = url
if (proxyServer && !isProxyUrl) {
- fetchUrl = `${proxyServer}/sites/${encodeURIComponent(url)}`
+ fetchUrl = `${proxyServer}/sites/?url=${encodeURIComponent(url)}`
+ logger.info('[WebService] Using proxy for OG fetch', { originalUrl: url, proxyUrl: fetchUrl })
+ } else if (!proxyServer) {
+ logger.warn('[WebService] No proxy server configured - VITE_PROXY_SERVER is undefined! Attempting direct fetch (will likely fail due to CORS)', { url })
+ } else {
+ logger.info('[WebService] URL already proxied, using as-is', { url, fetchUrl })
}
try {
@@ -25,28 +36,61 @@ class WebService {
const timeoutId = setTimeout(() => controller.abort(), 5000) // 5 second timeout for proxy
// Fetch with appropriate headers
+ // Note: credentials: 'omit' prevents sending cookies, which avoids SameSite warnings
const res = await fetch(fetchUrl, {
signal: controller.signal,
mode: 'cors',
credentials: 'omit',
headers: {
- 'Accept': 'text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8'
+ 'Accept': 'text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8',
+ 'User-Agent': 'Mozilla/5.0 (compatible; Jumble/1.0; +https://jumble.imwald.eu)'
}
})
clearTimeout(timeoutId)
if (!res.ok) {
+ logger.warn('[WebService] Fetch failed with non-OK status', { url, fetchUrl, status: res.status, statusText: res.statusText })
return {}
}
const html = await res.text()
+
+ // Check if we got a valid HTML response (not an error page or redirect)
+ if (html.length < 100) {
+ logger.warn('[WebService] Received suspiciously short HTML response', { url, fetchUrl, htmlLength: html.length })
+ }
+
+ // Log a snippet of the HTML to debug (first 500 chars)
+ logger.info('[WebService] Received HTML response', {
+ url,
+ fetchUrl,
+ htmlLength: html.length,
+ htmlSnippet: html.substring(0, 200)
+ })
+
const parser = new DOMParser()
const doc = parser.parseFromString(html, 'text/html')
+ // Check for OG tags
+ const ogTitleMeta = doc.querySelector('meta[property="og:title"]')
+ const ogDescMeta = doc.querySelector('meta[property="og:description"]')
+ const ogImageMeta = doc.querySelector('meta[property="og:image"]')
+ const titleTag = doc.querySelector('title')
+
+ logger.info('[WebService] Found meta tags', {
+ url,
+ hasOgTitle: !!ogTitleMeta,
+ hasOgDesc: !!ogDescMeta,
+ hasOgImage: !!ogImageMeta,
+ hasTitleTag: !!titleTag,
+ ogTitleContent: ogTitleMeta?.getAttribute('content')?.substring(0, 100),
+ titleTagContent: titleTag?.textContent?.substring(0, 100)
+ })
+
let title =
- doc.querySelector('meta[property="og:title"]')?.getAttribute('content') ||
- doc.querySelector('title')?.textContent
+ ogTitleMeta?.getAttribute('content') ||
+ titleTag?.textContent
// Filter out common redirect/loading titles (including variations with ellipsis)
if (title) {
@@ -64,9 +108,33 @@ class WebService {
const image = (doc.querySelector('meta[property="og:image"]') as HTMLMetaElement | null)
?.content
+ logger.info('[WebService] Extracted OG metadata', { url, title: title?.substring(0, 100), description: description?.substring(0, 100), hasImage: !!image })
+
+ // Filter out Jumble's default OG tags if we're fetching a different domain
+ // This prevents showing Jumble branding for other sites
+ try {
+ const urlObj = new URL(url)
+ const isJumbleDomain = urlObj.hostname === 'jumble.imwald.eu' || urlObj.hostname.includes('jumble')
+ const isJumbleDefaultTitle = title?.includes('Jumble - Imwald Edition') || title?.includes('Jumble Imwald Edition')
+ const isJumbleDefaultDesc = description?.includes('A user-friendly Nostr client focused on relay feed browsing')
+
+ // If we're fetching a non-jumble domain but got jumble's default OG tags, treat as no OG data
+ if (!isJumbleDomain && (isJumbleDefaultTitle || isJumbleDefaultDesc)) {
+ logger.warn('[WebService] Filtered out Jumble default OG tags for external domain - proxy may be returning wrong page', { url, hostname: urlObj.hostname, title, description: description?.substring(0, 100) })
+ return {}
+ }
+ } catch {
+ // If URL parsing fails, continue with what we have
+ }
+
return { title, description, image }
} catch (error) {
- // Silent fail - return empty metadata on any error
+ // Log errors for debugging
+ if (error instanceof DOMException && error.name === 'AbortError') {
+ logger.warn('[WebService] Fetch aborted (timeout)', { url, fetchUrl })
+ } else {
+ logger.error('[WebService] Failed to fetch OG metadata', { url, fetchUrl, error })
+ }
return {}
}
})