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') }} > -
- -
-
{hostname}
-
{url}
+
+
+
{hostname}
+
+
{cleanedUrl}
) 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 {} } })