diff --git a/Dockerfile b/Dockerfile
index c9bc158..e3c4bd8 100644
--- a/Dockerfile
+++ b/Dockerfile
@@ -26,8 +26,16 @@ RUN printf "server {\n\
server_name localhost;\n\
root /usr/share/nginx/html;\n\
index index.html;\n\
+\n\
+ # Detect social media scrapers and other bots\n\
+ set \$is_scraper 0;\n\
+ if (\$http_user_agent ~* \"facebookexternalhit|Twitterbot|LinkedInBot|Slackbot|WhatsApp|Applebot|Googlebot|bingbot|YandexBot|Baiduspider|Slurp|DuckDuckBot|Baiduspider|YandexBot|Sogou|Exabot|facebot|ia_archiver\") {\n\
+ set \$is_scraper 1;\n\
+ }\n\
\n\
location / {\n\
+ # For scrapers, serve index.html (they'll see static meta tags)\n\
+ # Note: To get dynamic meta tags, you need SSR or a meta tag service\n\
try_files \$uri \$uri/ /index.html;\n\
}\n\
\n\
diff --git a/index.html b/index.html
index f6ddee4..5826595 100644
--- a/index.html
+++ b/index.html
@@ -4,7 +4,7 @@
-
{
e.stopPropagation()
window.open(url, '_blank')
@@ -279,18 +283,22 @@ export default function WebPreview({ url, className }: { url: string; className?
{image && (
)}
-
{hostname}
- {title &&
{title}
}
+
+ {title &&
{title}
}
{description && (
-
+
{description}
)}
+
{url}
)
diff --git a/src/pages/secondary/NotePage/index.tsx b/src/pages/secondary/NotePage/index.tsx
index a503b77..350c2c8 100644
--- a/src/pages/secondary/NotePage/index.tsx
+++ b/src/pages/secondary/NotePage/index.tsx
@@ -8,7 +8,7 @@ import UserAvatar from '@/components/UserAvatar'
import { Card } from '@/components/ui/card'
import { Separator } from '@/components/ui/separator'
import { Skeleton } from '@/components/ui/skeleton'
-import { useFetchEvent } from '@/hooks'
+import { useFetchEvent, useFetchProfile } from '@/hooks'
import SecondaryPageLayout from '@/layouts/SecondaryPageLayout'
import { getParentBech32Id, getParentETag, getRootBech32Id } from '@/lib/event'
import { getLongFormArticleMetadataFromEvent } from '@/lib/event-metadata'
@@ -22,6 +22,69 @@ import { forwardRef, useEffect, useMemo, useState } from 'react'
import { useTranslation } from 'react-i18next'
import NotFound from './NotFound'
+// Helper function to get event type name (matching WebPreview)
+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 (matching WebPreview)
+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()
+}
+
const NotePage = forwardRef(({ id, index, hideTitlebar = false }: { id?: string; index?: number; hideTitlebar?: boolean }, ref) => {
const { t } = useTranslation()
const { event, isFetching } = useFetchEvent(id)
@@ -36,6 +99,9 @@ const NotePage = forwardRef(({ id, index, hideTitlebar = false }: { id?: string;
)
const { isFetching: isFetchingRootEvent, event: rootEvent } = useFetchEvent(rootEventId)
const { isFetching: isFetchingParentEvent, event: parentEvent } = useFetchEvent(parentEventId)
+
+ // Fetch profile for author (for OpenGraph metadata)
+ const { profile: authorProfile } = useFetchProfile(finalEvent?.pubkey)
const getNoteTypeTitle = (kind: number): string => {
switch (kind) {
@@ -114,24 +180,41 @@ const NotePage = forwardRef(({ id, index, hideTitlebar = false }: { id?: 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}"]`)
+ // Handle Twitter card tags (they use name attribute, not property)
+ const isTwitterTag = prop.startsWith('twitter:')
+ const selector = isTwitterTag ? `meta[name="${prop}"]` : `meta[property="${prop}"]`
+
+ let meta = document.querySelector(selector)
if (!meta) {
meta = document.createElement('meta')
- meta.setAttribute('property', prop)
+ if (isTwitterTag) {
+ meta.setAttribute('name', prop)
+ } else {
+ meta.setAttribute('property', prop)
+ }
document.head.appendChild(meta)
}
meta.setAttribute('content', content)
}
- // Update OpenGraph metadata for articles
+ // Update OpenGraph metadata to match fallback cards
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')
+ if (!finalEvent) {
+ // Reset to default meta tags with richer information
+ const defaultUrl = window.location.href
+ const truncatedDefaultUrl = defaultUrl.length > 150 ? defaultUrl.substring(0, 147) + '...' : defaultUrl
+ updateMetaTag('og:title', 'Jumble - Imwald Edition 🌲')
+ updateMetaTag('og:description', `${truncatedDefaultUrl} - A user-friendly Nostr client focused on relay feed browsing and relay discovery. The Imwald edition focuses on publications and articles.`)
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)
+ updateMetaTag('og:site_name', 'Jumble - Imwald Edition 🌲')
+
+ // Twitter card meta tags
+ updateMetaTag('twitter:card', 'summary_large_image')
+ updateMetaTag('twitter:title', 'Jumble - Imwald Edition 🌲')
+ updateMetaTag('twitter:description', `${truncatedDefaultUrl} - A user-friendly Nostr client focused on relay feed browsing and relay discovery. The Imwald edition focuses on publications and articles.`)
+ updateMetaTag('twitter:image', 'https://github.com/CodyTseng/jumble/blob/master/resources/og-image.png?raw=true')
// Remove article:tag if it exists
const articleTagMeta = document.querySelector('meta[property="article:tag"]')
@@ -142,17 +225,100 @@ const NotePage = forwardRef(({ id, index, hideTitlebar = false }: { id?: string;
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 || []
+ // Get event metadata matching fallback card format
+ const eventMetadata = getLongFormArticleMetadataFromEvent(finalEvent)
+ const eventTypeName = getEventTypeName(finalEvent.kind)
+ const eventTitle = eventMetadata?.title || eventTypeName
+ const eventSummary = eventMetadata?.summary || ''
+
+ // Generate content preview (matching fallback card)
+ let contentPreview = ''
+ if (finalEvent.content) {
+ const stripped = stripMarkdown(finalEvent.content)
+ contentPreview = stripped.length > 500 ? stripped.substring(0, 500) + '...' : stripped
+ }
+
+ // Build description matching fallback card: username • event type, title, summary, content preview, URL
+ // Always show note-specific info, even if profile isn't loaded yet
+ const authorName = authorProfile?.username || ''
+ const parts: string[] = []
+
+ // Always include event type (this is note-specific)
+ if (eventTypeName) {
+ parts.push(eventTypeName)
+ }
+ if (authorName) {
+ parts.push(`@${authorName}`)
+ }
+
+ let ogDescription = ''
+ if (parts.length > 0) {
+ ogDescription = parts.join(' • ')
+ } else {
+ // Fallback if nothing available yet
+ ogDescription = 'Event'
+ }
+
+ // Always show title if available (note-specific)
+ if (eventTitle && eventTitle !== eventTypeName) {
+ ogDescription += (ogDescription ? ' | ' : '') + eventTitle
+ }
+
+ // Show summary if available (note-specific)
+ if (eventSummary) {
+ ogDescription += (ogDescription ? ' - ' : '') + eventSummary
+ }
+
+ // Truncate URL to 150 chars before adding it
+ const fullUrl = window.location.href
+ const truncatedUrl = fullUrl.length > 150 ? fullUrl.substring(0, 147) + '...' : fullUrl
+
+ // Calculate remaining space for content preview (max 300 chars total, leave room for URL)
+ const maxDescLength = 300
+ const urlPart = ` | ${truncatedUrl}`
+ const remainingLength = maxDescLength - (ogDescription.length + urlPart.length)
+
+ // Always try to include content preview if available (this is note-specific!)
+ if (contentPreview && remainingLength > 20) {
+ const truncatedContent = contentPreview.length > remainingLength
+ ? contentPreview.substring(0, remainingLength - 3) + '...'
+ : contentPreview
+ ogDescription += (ogDescription ? ' ' : '') + truncatedContent
+ }
+
+ // Add truncated URL at the end
+ ogDescription += (ogDescription ? urlPart : truncatedUrl)
+
+ // Ensure we have note-specific content - if description is still too generic, add more event info
+ if (!authorName && !eventSummary && !contentPreview && ogDescription.includes('Event') && !ogDescription.includes('|')) {
+ // Add at least the event kind or some identifier to make it note-specific
+ 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')
+ 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', title)
- updateMetaTag('og:description', description)
+ updateMetaTag('og:title', `${eventTitle} - Jumble Imwald Edition`)
+ updateMetaTag('og:description', ogDescription)
updateMetaTag('og:image', image)
- updateMetaTag('og:type', 'article')
+ updateMetaTag('og:type', ogType)
updateMetaTag('og:url', window.location.href)
+ updateMetaTag('og:site_name', 'Jumble - Imwald Edition 🌲')
+
+ // Add author for articles
+ if (isArticle && authorName) {
+ updateMetaTag('article:author', authorName)
+ }
+
+ // Twitter card meta tags
+ updateMetaTag('twitter:card', 'summary_large_image')
+ updateMetaTag('twitter:title', `${eventTitle} - Jumble Imwald Edition`)
+ updateMetaTag('twitter:description', ogDescription.length > 200 ? ogDescription.substring(0, 197) + '...' : ogDescription)
+ updateMetaTag('twitter:image', image)
// Remove old article:tag if it exists
const oldArticleTagMeta = document.querySelector('meta[property="article:tag"]')
@@ -161,31 +327,40 @@ const NotePage = forwardRef(({ id, index, hideTitlebar = false }: { id?: string;
}
// 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)
- })
+ if (isArticle) {
+ 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`
+ document.title = `${eventTitle} - Jumble Imwald Edition`
// 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')
+ // Reset to default on unmount with richer information
+ const cleanupUrl = window.location.href
+ const truncatedCleanupUrl = cleanupUrl.length > 150 ? cleanupUrl.substring(0, 147) + '...' : cleanupUrl
+ updateMetaTag('og:title', 'Jumble - Imwald Edition 🌲')
+ updateMetaTag('og:description', `${truncatedCleanupUrl} - A user-friendly Nostr client focused on relay feed browsing and relay discovery. The Imwald edition focuses on publications and articles.`)
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)
+ updateMetaTag('og:site_name', 'Jumble - Imwald Edition 🌲')
// Remove article:tag meta tags
document.querySelectorAll('meta[property="article:tag"]').forEach(meta => meta.remove())
+ const authorMeta = document.querySelector('meta[property="article:author"]')
+ if (authorMeta) {
+ authorMeta.remove()
+ }
- document.title = 'Jumble'
+ document.title = 'Jumble - Imwald Edition 🌲'
}
- }, [articleMetadata, finalEvent])
+ }, [finalEvent, articleMetadata, authorProfile])
if (!event && isFetching) {
return (
diff --git a/src/pages/secondary/ProfilePage/index.tsx b/src/pages/secondary/ProfilePage/index.tsx
index ad4b816..f7e6e18 100644
--- a/src/pages/secondary/ProfilePage/index.tsx
+++ b/src/pages/secondary/ProfilePage/index.tsx
@@ -1,10 +1,98 @@
import Profile from '@/components/Profile'
import { useFetchProfile } from '@/hooks'
import SecondaryPageLayout from '@/layouts/SecondaryPageLayout'
-import { forwardRef } from 'react'
+import { forwardRef, useEffect } from 'react'
+
+// Helper function to update or create meta tags
+function updateMetaTag(property: string, content: string) {
+ const prop = property.startsWith('og:') || property.startsWith('article:') ? property : property.replace(/^property="|"$/, '')
+
+ // Handle Twitter card tags (they use name attribute, not property)
+ const isTwitterTag = prop.startsWith('twitter:')
+ const selector = isTwitterTag ? `meta[name="${prop}"]` : `meta[property="${prop}"]`
+
+ let meta = document.querySelector(selector)
+ if (!meta) {
+ meta = document.createElement('meta')
+ if (isTwitterTag) {
+ meta.setAttribute('name', prop)
+ } else {
+ meta.setAttribute('property', prop)
+ }
+ document.head.appendChild(meta)
+ }
+ meta.setAttribute('content', content)
+}
const ProfilePage = forwardRef(({ id, index, hideTitlebar = false }: { id?: string; index?: number; hideTitlebar?: boolean }, ref) => {
const { profile } = useFetchProfile(id)
+
+ // Update OpenGraph metadata to match fallback card format for profiles
+ useEffect(() => {
+ if (!profile) {
+ // Reset to default meta tags
+ const defaultUrl = window.location.href
+ const truncatedDefaultUrl = defaultUrl.length > 150 ? defaultUrl.substring(0, 147) + '...' : defaultUrl
+ updateMetaTag('og:title', 'Jumble - Imwald Edition 🌲')
+ updateMetaTag('og:description', `${truncatedDefaultUrl} - A user-friendly Nostr client focused on relay feed browsing and relay discovery. The Imwald edition focuses on publications and articles.`)
+ updateMetaTag('og:image', 'https://github.com/CodyTseng/jumble/blob/master/resources/og-image.png?raw=true')
+ updateMetaTag('og:type', 'profile')
+ updateMetaTag('og:url', window.location.href)
+ updateMetaTag('og:site_name', 'Jumble - Imwald Edition 🌲')
+
+ // Twitter card meta tags
+ updateMetaTag('twitter:card', 'summary')
+ updateMetaTag('twitter:title', 'Jumble - Imwald Edition 🌲')
+ updateMetaTag('twitter:description', `${truncatedDefaultUrl} - Profile`)
+ updateMetaTag('twitter:image', 'https://github.com/CodyTseng/jumble/blob/master/resources/og-image.png?raw=true')
+
+ return
+ }
+
+ // Build description matching fallback card: username, hostname, URL
+ const username = profile.username || ''
+ const ogTitle = username || 'Profile'
+
+ // Truncate URL to 150 chars
+ const fullUrl = window.location.href
+ const truncatedUrl = fullUrl.length > 150 ? fullUrl.substring(0, 147) + '...' : fullUrl
+
+ let ogDescription = username ? `@${username}` : 'Profile'
+ 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'
+
+ updateMetaTag('og:title', `${ogTitle} - Jumble Imwald Edition`)
+ updateMetaTag('og:description', ogDescription)
+ updateMetaTag('og:image', image)
+ updateMetaTag('og:type', 'profile')
+ updateMetaTag('og:url', window.location.href)
+ updateMetaTag('og:site_name', 'Jumble - Imwald Edition 🌲')
+
+ // Twitter card meta tags
+ updateMetaTag('twitter:card', 'summary')
+ updateMetaTag('twitter:title', `${ogTitle} - Jumble Imwald Edition`)
+ updateMetaTag('twitter:description', ogDescription.length > 200 ? ogDescription.substring(0, 197) + '...' : ogDescription)
+ updateMetaTag('twitter:image', image)
+
+ // Update document title
+ document.title = `${ogTitle} - Jumble Imwald Edition`
+
+ // Cleanup function
+ return () => {
+ // Reset to default on unmount
+ const cleanupUrl = window.location.href
+ const truncatedCleanupUrl = cleanupUrl.length > 150 ? cleanupUrl.substring(0, 147) + '...' : cleanupUrl
+ updateMetaTag('og:title', 'Jumble - Imwald Edition 🌲')
+ updateMetaTag('og:description', `${truncatedCleanupUrl} - A user-friendly Nostr client focused on relay feed browsing and relay discovery. The Imwald edition focuses on publications and articles.`)
+ 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)
+ updateMetaTag('og:site_name', 'Jumble - Imwald Edition 🌲')
+ document.title = 'Jumble - Imwald Edition 🌲'
+ }
+ }, [profile])
return (