Browse Source

bug-fixes for 14.8

corrections for og data
imwald
Silberengel 3 months ago
parent
commit
2ef075910d
  1. 2
      package.json
  2. 136
      src/components/WebPreview/index.tsx
  3. 8
      src/hooks/useFetchWebMetadata.tsx
  4. 40
      src/pages/secondary/NotePage/index.tsx
  5. 34
      src/pages/secondary/ProfilePage/index.tsx
  6. 80
      src/services/web.service.ts

2
package.json

@ -1,6 +1,6 @@ @@ -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",

136
src/components/WebPreview/index.tsx

@ -13,6 +13,7 @@ import { useMemo } from 'react' @@ -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? @@ -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? @@ -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? @@ -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? @@ -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 (
<div
className={cn('p-3 clickable flex w-full border rounded-lg overflow-hidden gap-3', className)}
className={cn('p-3 clickable flex w-full border rounded-lg overflow-hidden gap-3 bg-gradient-to-r from-green-50/50 to-transparent dark:from-green-950/20', className)}
onClick={(e) => {
e.stopPropagation()
window.open(cleanedUrl, '_blank')
@ -171,7 +227,7 @@ export default function WebPreview({ url, className }: { url: string; className? @@ -171,7 +227,7 @@ export default function WebPreview({ url, className }: { url: string; className?
{eventImage && fetchedEvent && (
<Image
image={{ url: eventImage, pubkey: fetchedEvent.pubkey }}
className="w-20 h-20 rounded-lg flex-shrink-0 object-cover"
className="w-20 h-20 rounded-lg flex-shrink-0 object-cover border border-green-200 dark:border-green-800"
hideIfError
/>
)}
@ -188,12 +244,12 @@ export default function WebPreview({ url, className }: { url: string; className? @@ -188,12 +244,12 @@ export default function WebPreview({ url, className }: { url: string; className?
{isFetchingEvent ? 'Loading event...' : 'Event'}
</span>
)}
<ExternalLink className="w-3 h-3 text-muted-foreground flex-shrink-0 ml-auto" />
<ExternalLink className="w-3 h-3 text-green-600 dark:text-green-400 flex-shrink-0 ml-auto" />
</div>
{fetchedEvent && (
<>
{eventTitle && (
<div className="font-semibold text-sm line-clamp-2 mb-1">{eventTitle}</div>
<div className="font-semibold text-sm line-clamp-2 mb-1 text-green-900 dark:text-green-100">{eventTitle}</div>
)}
{isBookstrEvent && bookMetadata && (
<div className="text-xs text-muted-foreground space-x-2 mb-1">
@ -214,6 +270,13 @@ export default function WebPreview({ url, className }: { url: string; className? @@ -214,6 +270,13 @@ export default function WebPreview({ url, className }: { url: string; className?
)}
</>
)}
{identifierParts.length > 0 && (
<div className="text-xs text-muted-foreground space-x-2 mt-2 pt-2 border-t border-green-200 dark:border-green-800">
{identifierParts.map((part, idx) => (
<span key={idx} className="font-mono">{part}</span>
))}
</div>
)}
<div className="text-xs text-muted-foreground truncate mt-1">{hostname}</div>
</div>
</div>
@ -222,25 +285,62 @@ export default function WebPreview({ url, className }: { url: string; className? @@ -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 (
<div
className={cn('p-3 clickable flex w-full border rounded-lg overflow-hidden gap-3', className)}
className={cn('p-3 clickable flex w-full border rounded-lg overflow-hidden gap-3 bg-gradient-to-r from-green-50/50 to-transparent dark:from-green-950/20', className)}
onClick={(e) => {
e.stopPropagation()
window.open(cleanedUrl, '_blank')
}}
>
{fetchedProfile?.avatar && (
<Image
image={{ url: fetchedProfile.avatar, pubkey: fetchedProfile.pubkey }}
className="w-16 h-16 rounded-lg flex-shrink-0 object-cover border border-green-200 dark:border-green-800"
hideIfError
/>
)}
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2">
<div className="flex items-center gap-2 mb-1">
{fetchedProfile ? (
<Username userId={fetchedProfile.pubkey} />
<>
<Username userId={fetchedProfile.pubkey} />
{fetchedProfile.nip05 && (
<>
<span className="text-xs text-muted-foreground"></span>
<span className="text-xs text-green-600 dark:text-green-400">{fetchedProfile.nip05}</span>
</>
)}
</>
) : (
<span className="text-sm text-muted-foreground">
{isFetchingProfile ? 'Loading profile...' : 'Profile'}
</span>
)}
<ExternalLink className="w-3 h-3 text-muted-foreground flex-shrink-0" />
<ExternalLink className="w-3 h-3 text-green-600 dark:text-green-400 flex-shrink-0 ml-auto" />
</div>
{fetchedProfile?.about && (
<div className="text-xs text-muted-foreground line-clamp-2 mb-1 mt-1">{fetchedProfile.about}</div>
)}
{profileIdentifierParts.length > 0 && (
<div className="text-xs text-muted-foreground space-x-2 mt-2 pt-2 border-t border-green-200 dark:border-green-800">
{profileIdentifierParts.map((part, idx) => (
<span key={idx} className="font-mono">{part}</span>
))}
</div>
)}
<div className="text-xs text-muted-foreground truncate mt-1">{hostname}</div>
<div className="text-xs text-muted-foreground truncate">{url}</div>
</div>
@ -248,21 +348,21 @@ export default function WebPreview({ url, className }: { url: string; className? @@ -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 (
<div
className={cn('p-2 clickable flex w-full border rounded-lg overflow-hidden', className)}
className={cn('p-3 clickable flex w-full border rounded-lg overflow-hidden gap-3 bg-gradient-to-r from-green-50/50 to-transparent dark:from-green-950/20', className)}
onClick={(e) => {
e.stopPropagation()
window.open(cleanedUrl, '_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 className="flex-1 min-w-0">
<div className="flex items-center gap-2 mb-1">
<div className="text-sm font-semibold text-green-900 dark:text-green-100 truncate">{hostname}</div>
<ExternalLink className="w-3 h-3 text-green-600 dark:text-green-400 flex-shrink-0" />
</div>
<div className="text-xs text-muted-foreground break-all line-clamp-2">{cleanedUrl}</div>
</div>
</div>
)

8
src/hooks/useFetchWebMetadata.tsx

@ -1,6 +1,7 @@ @@ -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<TWebMetadata>({})
@ -10,13 +11,16 @@ export function useFetchWebMetadata(url: string) { @@ -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])

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

@ -295,30 +295,64 @@ const NotePage = forwardRef(({ id, index, hideTitlebar = false }: { id?: string; @@ -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"]')

34
src/pages/secondary/ProfilePage/index.tsx

@ -51,30 +51,52 @@ const ProfilePage = forwardRef(({ id, index, hideTitlebar = false }: { id?: stri @@ -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`

80
src/services/web.service.ts

@ -1,5 +1,6 @@ @@ -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 { @@ -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 { @@ -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 { @@ -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 {}
}
})

Loading…
Cancel
Save