Browse Source

update opengraph

imwald
Silberengel 4 weeks ago
parent
commit
d6a164b6d3
  1. 3
      .gitignore
  2. 22
      index.html
  3. 32
      package-lock.json
  4. 4
      package.json
  5. 93
      public/fonts/OFL-PlayfairDisplay.txt
  6. BIN
      public/fonts/PlayfairDisplay-wght.ttf
  7. BIN
      public/og-image.png
  8. 37
      public/og-image.svg
  9. 68
      scripts/generate-og-png.mjs
  10. 24
      src/PageManager.tsx
  11. 75
      src/components/WebPreview/index.tsx
  12. 271
      src/lib/document-meta.ts
  13. 120
      src/pages/secondary/NotePage/index.tsx
  14. 102
      src/pages/secondary/ProfilePage/index.tsx

3
.gitignore vendored

@ -29,5 +29,8 @@ dev-dist
.vercel .vercel
# Ephemeral SVG with inlined font for OG PNG rasterization
public/.og-image.raster.svg
.venv-i18n .venv-i18n
scripts/i18n-overrides/.gaps scripts/i18n-overrides/.gaps

22
index.html

@ -13,7 +13,7 @@
<title>Imwald</title> <title>Imwald</title>
<meta <meta
name="description" name="description"
content="Imwald — a user-friendly Nostr client focused on relay feed browsing, publications, and relay discovery" content="Imwald — a user-friendly Nostr client focused on relay feed browsing, publications, and relay discovery."
/> />
<meta <meta
name="keywords" name="keywords"
@ -57,18 +57,18 @@
align-items: center; align-items: center;
justify-content: center; justify-content: center;
gap: 1rem; gap: 1rem;
font-family: system-ui, sans-serif; font-family: 'Playfair Display', Georgia, serif;
font-size: 0.95rem; font-size: 1rem;
color: #737373; color: #3d5346;
background: #fafafa; background: #f4f7f4;
" "
> >
<div <div
style=" style="
width: 2rem; width: 2rem;
height: 2rem; height: 2rem;
border: 3px solid #e5e5e5; border: 3px solid #c5d4c8;
border-top-color: #404040; border-top-color: #2f6f4f;
border-radius: 50%; border-radius: 50%;
animation: imwald-spin 0.7s linear infinite; animation: imwald-spin 0.7s linear infinite;
" "
@ -84,12 +84,12 @@
} }
@media (prefers-color-scheme: dark) { @media (prefers-color-scheme: dark) {
#imwald-boot-splash { #imwald-boot-splash {
color: #a3a3a3; color: #b8c9bf;
background: #171717; background: #121e18;
} }
#imwald-boot-splash div[aria-hidden] { #imwald-boot-splash div[aria-hidden] {
border-color: #404040; border-color: #2a3d32;
border-top-color: #d4d4d4; border-top-color: #5a9e7a;
} }
} }
</style> </style>

32
package-lock.json generated

@ -100,6 +100,7 @@
"eslint-plugin-react-refresh": "^0.4.16", "eslint-plugin-react-refresh": "^0.4.16",
"globals": "^15.13.0", "globals": "^15.13.0",
"jsdom": "^27.1.0", "jsdom": "^27.1.0",
"opentype.js": "^1.3.4",
"postcss": "^8.4.49", "postcss": "^8.4.49",
"prettier": "3.4.2", "prettier": "3.4.2",
"tailwindcss": "^3.4.17", "tailwindcss": "^3.4.17",
@ -11888,6 +11889,23 @@
"url": "https://github.com/sponsors/sindresorhus" "url": "https://github.com/sponsors/sindresorhus"
} }
}, },
"node_modules/opentype.js": {
"version": "1.3.4",
"resolved": "https://registry.npmjs.org/opentype.js/-/opentype.js-1.3.4.tgz",
"integrity": "sha512-d2JE9RP/6uagpQAVtJoF0pJJA/fgai89Cc50Yp0EJHk+eLp6QQ7gBoblsnubRULNY132I0J1QKMJ+JTbMqz4sw==",
"dev": true,
"license": "MIT",
"dependencies": {
"string.prototype.codepointat": "^0.2.1",
"tiny-inflate": "^1.0.3"
},
"bin": {
"ot": "bin/ot"
},
"engines": {
"node": ">= 8.0.0"
}
},
"node_modules/optionator": { "node_modules/optionator": {
"version": "0.9.4", "version": "0.9.4",
"resolved": "https://registry.npmjs.org/optionator/-/optionator-0.9.4.tgz", "resolved": "https://registry.npmjs.org/optionator/-/optionator-0.9.4.tgz",
@ -14046,6 +14064,13 @@
"devOptional": true, "devOptional": true,
"license": "MIT" "license": "MIT"
}, },
"node_modules/string.prototype.codepointat": {
"version": "0.2.1",
"resolved": "https://registry.npmjs.org/string.prototype.codepointat/-/string.prototype.codepointat-0.2.1.tgz",
"integrity": "sha512-2cBVCj6I4IOvEnjgO/hWqXjqBGsY+zwPmHl12Srk9IXSZ56Jwwmy+66XO5Iut/oQVR7t5ihYdLB0GMa4alEUcg==",
"dev": true,
"license": "MIT"
},
"node_modules/string.prototype.matchall": { "node_modules/string.prototype.matchall": {
"version": "4.0.12", "version": "4.0.12",
"resolved": "https://registry.npmjs.org/string.prototype.matchall/-/string.prototype.matchall-4.0.12.tgz", "resolved": "https://registry.npmjs.org/string.prototype.matchall/-/string.prototype.matchall-4.0.12.tgz",
@ -14520,6 +14545,13 @@
"semver": "bin/semver" "semver": "bin/semver"
} }
}, },
"node_modules/tiny-inflate": {
"version": "1.0.3",
"resolved": "https://registry.npmjs.org/tiny-inflate/-/tiny-inflate-1.0.3.tgz",
"integrity": "sha512-pkY1fj1cKHb2seWDy0B16HeWyczlJA9/WW3u3c4z/NiWDsO3DOU5D7nhTLE9CF0yXv/QZFY7sEJmj24dK+Rrqw==",
"dev": true,
"license": "MIT"
},
"node_modules/tinybench": { "node_modules/tinybench": {
"version": "2.9.0", "version": "2.9.0",
"resolved": "https://registry.npmjs.org/tinybench/-/tinybench-2.9.0.tgz", "resolved": "https://registry.npmjs.org/tinybench/-/tinybench-2.9.0.tgz",

4
package.json

@ -27,7 +27,8 @@
"i18n:translate-de": "PYTHONUNBUFFERED=1 .venv-i18n/bin/python scripts/auto_translate_i18n.py de", "i18n:translate-de": "PYTHONUNBUFFERED=1 .venv-i18n/bin/python scripts/auto_translate_i18n.py de",
"electron:dev": "concurrently -k -n vite,electron -c blue,green \"vite --host\" \"wait-on http://127.0.0.1:5173 && cross-env NODE_ENV=development electron .\"", "electron:dev": "concurrently -k -n vite,electron -c blue,green \"vite --host\" \"wait-on http://127.0.0.1:5173 && cross-env NODE_ENV=development electron .\"",
"build:electron": "tsc -b && vite build --base ./", "build:electron": "tsc -b && vite build --base ./",
"electron:pack": "npm run build:electron && electron-builder" "electron:pack": "npm run build:electron && electron-builder",
"og:image": "node scripts/generate-og-png.mjs"
}, },
"dependencies": { "dependencies": {
"@asciidoctor/core": "^3.0.4", "@asciidoctor/core": "^3.0.4",
@ -121,6 +122,7 @@
"eslint-plugin-react-refresh": "^0.4.16", "eslint-plugin-react-refresh": "^0.4.16",
"globals": "^15.13.0", "globals": "^15.13.0",
"jsdom": "^27.1.0", "jsdom": "^27.1.0",
"opentype.js": "^1.3.4",
"postcss": "^8.4.49", "postcss": "^8.4.49",
"prettier": "3.4.2", "prettier": "3.4.2",
"tailwindcss": "^3.4.17", "tailwindcss": "^3.4.17",

93
public/fonts/OFL-PlayfairDisplay.txt

@ -0,0 +1,93 @@
Copyright 2017 The Playfair Display Project Authors (https://github.com/clauseggers/Playfair-Display), with Reserved Font Name "Playfair Display"
This Font Software is licensed under the SIL Open Font License, Version 1.1.
This license is copied below, and is also available with a FAQ at:
http://scripts.sil.org/OFL
-----------------------------------------------------------
SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007
-----------------------------------------------------------
PREAMBLE
The goals of the Open Font License (OFL) are to stimulate worldwide
development of collaborative font projects, to support the font creation
efforts of academic and linguistic communities, and to provide a free and
open framework in which fonts may be shared and improved in partnership
with others.
The OFL allows the licensed fonts to be used, studied, modified and
redistributed freely as long as they are not sold by themselves. The
fonts, including any derivative works, can be bundled, embedded,
redistributed and/or sold with any software provided that any reserved
names are not used by derivative works. The fonts and derivatives,
however, cannot be released under any other type of license. The
requirement for fonts to remain under this license does not apply
to any document created using the fonts or their derivatives.
DEFINITIONS
"Font Software" refers to the set of files released by the Copyright
Holder(s) under this license and clearly marked as such. This may
include source files, build scripts and documentation.
"Reserved Font Name" refers to any names specified as such after the
copyright statement(s).
"Original Version" refers to the collection of Font Software components as
distributed by the Copyright Holder(s).
"Modified Version" refers to any derivative made by adding to, deleting,
or substituting -- in part or in whole -- any of the components of the
Original Version, by changing formats or by porting the Font Software to a
new environment.
"Author" refers to any designer, engineer, programmer, technical
writer or other person who contributed to the Font Software.
PERMISSION & CONDITIONS
Permission is hereby granted, free of charge, to any person obtaining
a copy of the Font Software, to use, study, copy, merge, embed, modify,
redistribute, and sell modified and unmodified copies of the Font
Software, subject to the following conditions:
1) Neither the Font Software nor any of its individual components,
in Original or Modified Versions, may be sold by itself.
2) Original or Modified Versions of the Font Software may be bundled,
redistributed and/or sold with any software, provided that each copy
contains the above copyright notice and this license. These can be
included either as stand-alone text files, human-readable headers or
in the appropriate machine-readable metadata fields within text or
binary files as long as those fields can be easily viewed by the user.
3) No Modified Version of the Font Software may use the Reserved Font
Name(s) unless explicit written permission is granted by the corresponding
Copyright Holder. This restriction only applies to the primary font name as
presented to the users.
4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font
Software shall not be used to promote, endorse or advertise any
Modified Version, except to acknowledge the contribution(s) of the
Copyright Holder(s) and the Author(s) or with their explicit written
permission.
5) The Font Software, modified or unmodified, in part or in whole,
must be distributed entirely under this license, and must not be
distributed under any other license. The requirement for fonts to
remain under this license does not apply to any document created
using the Font Software.
TERMINATION
This license becomes null and void if any of the above conditions are
not met.
DISCLAIMER
THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT
OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE
COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL
DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM
OTHER DEALINGS IN THE FONT SOFTWARE.

BIN
public/fonts/PlayfairDisplay-wght.ttf

Binary file not shown.

BIN
public/og-image.png

Binary file not shown.

Before

Width:  |  Height:  |  Size: 794 KiB

After

Width:  |  Height:  |  Size: 82 KiB

37
public/og-image.svg

@ -0,0 +1,37 @@
<svg xmlns="http://www.w3.org/2000/svg" width="1200" height="630" viewBox="0 0 1200 630">
<defs>
<!-- Aligned with app dark theme (see index.css .dark + theme-color) -->
<linearGradient id="imwald-og-bg" x1="0%" y1="0%" x2="100%" y2="100%">
<stop offset="0%" stop-color="#121e18"/>
<stop offset="50%" stop-color="#182820"/>
<stop offset="100%" stop-color="#1a2e24"/>
</linearGradient>
<linearGradient id="imwald-og-accent" x1="0%" y1="0%" x2="100%" y2="0%">
<stop offset="0%" stop-color="#4a9e72"/>
<stop offset="100%" stop-color="#5eb88a"/>
</linearGradient>
</defs>
<rect width="1200" height="630" fill="url(#imwald-og-bg)"/>
<path
d="M0 520 Q 300 460 600 500 T 1200 480 L 1200 630 L 0 630 Z"
fill="#3d8a63"
opacity="0.22"
/>
<text
id="og-imwald"
x="72"
y="300"
font-family="'Playfair Display', Georgia, 'Times New Roman', serif"
font-size="108"
font-weight="700"
fill="#d4ebe0"
>Imwald</text>
<text
x="74"
y="368"
font-family="ui-sans-serif, system-ui, -apple-system, sans-serif"
font-size="30"
fill="#b0bbb4"
>Nostr reader &amp; writer</text>
<rect x="72" y="404" width="140" height="6" rx="3" fill="url(#imwald-og-accent)"/>
</svg>

After

Width:  |  Height:  |  Size: 1.2 KiB

68
scripts/generate-og-png.mjs

@ -0,0 +1,68 @@
/**
* Rasterize public/og-image.svg public/og-image.png with a true Playfair Display wordmark.
* ImageMagick/Inkscape copy the SVG to /tmp, so @font-face + file URLs often never load;
* we outline "Imwald" with opentype.js so the PNG is font-independent.
*
* Wordmark fill + weight are read from the `#og-imwald` <text> in the SVG so PNG matches
* the green-tinged off-white you see in the browser (rasterizers often look harsher than live text).
*/
import { execFileSync } from 'node:child_process'
import { readFileSync, writeFileSync, unlinkSync } from 'node:fs'
import { dirname, join } from 'node:path'
import { fileURLToPath } from 'node:url'
import opentype from 'opentype.js'
const root = join(dirname(fileURLToPath(import.meta.url)), '..')
const svgPath = join(root, 'public/og-image.svg')
const fontPath = join(root, 'public/fonts/PlayfairDisplay-wght.ttf')
const outPng = join(root, 'public/og-image.png')
const tmpSvg = join(root, 'public/.og-image.raster.svg')
function parseOgImwaldFromSvg(svg) {
const defaults = { fill: '#d4ebe0', weight: 700, letterSpacing: 0.018 }
const idPos = svg.indexOf('id="og-imwald"')
if (idPos < 0) return defaults
const textOpen = svg.lastIndexOf('<text', idPos)
const textClose = svg.indexOf('</text>', idPos)
if (textOpen < 0 || textClose < 0) return defaults
const t = svg.slice(textOpen, textClose + '</text>'.length)
const fill = t.match(/\bfill="([^"]+)"/)?.[1] ?? defaults.fill
const weight = parseInt(t.match(/font-weight="(\d+)"/)?.[1] ?? String(defaults.weight), 10)
return {
fill,
weight: Number.isFinite(weight) ? weight : defaults.weight,
letterSpacing: defaults.letterSpacing
}
}
let svg = readFileSync(svgPath, 'utf8')
const { fill: imwaldFill, weight: imwaldWght, letterSpacing } = parseOgImwaldFromSvg(svg)
const font = opentype.loadSync(fontPath)
const pathObj = font.getPath('Imwald', 72, 300, 108, {
variation: { wght: imwaldWght },
letterSpacing
})
let pathTag = pathObj.toSVG(2)
if (!pathTag.includes('fill=')) {
pathTag = pathTag.replace('<path ', `<path fill="${imwaldFill}" `)
}
svg = svg.replace(/<text[^>]*id="og-imwald"[^>]*>[\s\S]*?<\/text>/, pathTag)
writeFileSync(tmpSvg, svg, 'utf8')
try {
execFileSync('convert', ['-background', 'none', '-density', '150', tmpSvg, outPng], {
stdio: 'inherit',
cwd: root
})
} finally {
try {
unlinkSync(tmpSvg)
} catch {
/* ignore */
}
}
console.info('[og:image] wrote', outPng)

24
src/PageManager.tsx

@ -41,6 +41,11 @@ import {
usePrimaryPageOptional, usePrimaryPageOptional,
type PrimaryPageContextValue type PrimaryPageContextValue
} from '@/contexts/primary-page-context' } from '@/contexts/primary-page-context'
import {
applyRouteDocumentMeta,
isNoteDetailPathname,
isProfileDetailPathname
} from '@/lib/document-meta'
import { normalizeUrl } from './lib/url' import { normalizeUrl } from './lib/url'
import modalManager from './services/modal-manager.service' import modalManager from './services/modal-manager.service'
import { decodeRssArticlePathSegment, encodeRssArticlePathSegment } from '@/lib/rss-article' import { decodeRssArticlePathSegment, encodeRssArticlePathSegment } from '@/lib/rss-article'
@ -1761,6 +1766,25 @@ export function PageManager({ maxStackSize = 5 }: { maxStackSize?: number }) {
} }
}, [secondaryStack.length, currentPrimaryPage]) }, [secondaryStack.length, currentPrimaryPage])
// Route-level OG / document title for pages that do not set their own (NotePage, ProfilePage handle note/profile).
useEffect(() => {
if (typeof window === 'undefined') return
if (primaryNoteView !== null) return
const top = secondaryStack[secondaryStack.length - 1]
let path = window.location.pathname
if (top?.url) {
try {
path = new URL(top.url, window.location.origin).pathname
} catch {
/* keep window pathname */
}
}
if (isNoteDetailPathname(path) || isProfileDetailPathname(path)) return
applyRouteDocumentMeta(path, currentPrimaryPage)
}, [secondaryStack, currentPrimaryPage, primaryNoteView])
const navigatePrimaryPage = (page: TPrimaryPageName, props?: any) => { const navigatePrimaryPage = (page: TPrimaryPageName, props?: any) => {
// Clear any primary note view when navigating to a new primary page // Clear any primary note view when navigating to a new primary page

75
src/components/WebPreview/index.tsx

@ -13,6 +13,7 @@ import { nip19, kinds } from 'nostr-tools'
import { useMemo, useEffect, useState } from 'react' import { useMemo, useEffect, useState } from 'react'
import Image from '../Image' import Image from '../Image'
import Username from '../Username' import Username from '../Username'
import { resolveImwaldRouteSocialCopy } from '@/lib/document-meta'
import { cleanUrl, isSafeMediaUrl } from '@/lib/url' import { cleanUrl, isSafeMediaUrl } from '@/lib/url'
import { tagNameEquals } from '@/lib/tag' import { tagNameEquals } from '@/lib/tag'
import { queryService } from '@/services/client.service' import { queryService } from '@/services/client.service'
@ -519,11 +520,14 @@ export default function WebPreview({ url, className }: { url: string; className?
// Render all images on left side, crop wider ones // Render all images on left side, crop wider ones
return ( return (
<div <div
className={cn('p-3 flex w-full border rounded-lg overflow-hidden gap-0 bg-gradient-to-r from-green-50/50 to-transparent dark:from-green-950/20 max-w-full', className)} className={cn(
'p-3 flex w-full border border-border rounded-lg overflow-hidden gap-0 bg-card bg-gradient-to-r from-primary/[0.07] to-transparent dark:from-primary/15 max-w-full',
className
)}
> >
{displayImage && isSafeMediaUrl(displayImage) && ( {displayImage && isSafeMediaUrl(displayImage) && (
<div className={cn( <div className={cn(
"flex-shrink-0 bg-gradient-to-r from-green-50/50 to-transparent dark:from-green-950/20 -my-3 -ml-3 -mr-0 flex items-center justify-center rounded-l-lg overflow-hidden", 'flex-shrink-0 bg-gradient-to-r from-primary/[0.07] to-transparent dark:from-primary/15 -my-3 -ml-3 -mr-0 flex items-center justify-center rounded-l-lg overflow-hidden',
imageAspectRatio !== null && imageAspectRatio > 1 ? "w-24 sm:w-32 md:w-52 lg:w-[416px] max-w-[120px] sm:max-w-[160px] md:max-w-[208px] lg:max-w-none" : "w-20 sm:w-28 md:w-40 lg:w-52 max-w-[80px] sm:max-w-[112px] md:max-w-[160px] lg:max-w-none" imageAspectRatio !== null && imageAspectRatio > 1 ? "w-24 sm:w-32 md:w-52 lg:w-[416px] max-w-[120px] sm:max-w-[160px] md:max-w-[208px] lg:max-w-none" : "w-20 sm:w-28 md:w-40 lg:w-52 max-w-[80px] sm:max-w-[112px] md:max-w-[160px] lg:max-w-none"
)}> )}>
<Image <Image
@ -565,14 +569,16 @@ export default function WebPreview({ url, className }: { url: string; className?
onClick={(e) => e.stopPropagation()} onClick={(e) => e.stopPropagation()}
className="flex-shrink-0" className="flex-shrink-0"
> >
<ExternalLink className="w-3 h-3 text-green-600 dark:text-green-400" /> <ExternalLink className="w-3 h-3 text-primary" />
</a> </a>
</div> </div>
{fetchedEvent && ( {fetchedEvent && (
<> <>
{/* Always show title in card header, hide it in content preview */} {/* Always show title in card header, hide it in content preview */}
{eventTitle && ( {eventTitle && (
<div className="font-semibold text-sm line-clamp-2 mb-1 text-green-900 dark:text-green-100">{eventTitle}</div> <div className="font-display font-semibold text-sm line-clamp-2 mb-1 text-brand-wordmark">
{eventTitle}
</div>
)} )}
{isBookstrEvent && bookMetadata && ( {isBookstrEvent && bookMetadata && (
<div className="text-xs text-muted-foreground space-x-2 mb-1"> <div className="text-xs text-muted-foreground space-x-2 mb-1">
@ -627,10 +633,13 @@ export default function WebPreview({ url, className }: { url: string; className?
return ( return (
<div <div
className={cn('p-3 flex w-full border rounded-lg overflow-hidden gap-0 bg-gradient-to-r from-green-50/50 to-transparent dark:from-green-950/20 max-w-full', className)} className={cn(
'p-3 flex w-full border border-border rounded-lg overflow-hidden gap-0 bg-card bg-gradient-to-r from-primary/[0.07] to-transparent dark:from-primary/15 max-w-full',
className
)}
> >
{fetchedProfile?.avatar && ( {fetchedProfile?.avatar && (
<div className="w-20 sm:w-28 md:w-36 lg:w-40 max-w-[80px] sm:max-w-[112px] md:max-w-[144px] lg:max-w-none flex-shrink-0 bg-gradient-to-r from-green-50/50 to-transparent dark:from-green-950/20 -my-3 -ml-3 -mr-0 flex items-center justify-center rounded-l-lg overflow-hidden"> <div className="w-20 sm:w-28 md:w-36 lg:w-40 max-w-[80px] sm:max-w-[112px] md:max-w-[144px] lg:max-w-none flex-shrink-0 bg-gradient-to-r from-primary/[0.07] to-transparent dark:from-primary/15 -my-3 -ml-3 -mr-0 flex items-center justify-center rounded-l-lg overflow-hidden">
<Image <Image
image={{ url: fetchedProfile.avatar, pubkey: fetchedProfile.pubkey }} image={{ url: fetchedProfile.avatar, pubkey: fetchedProfile.pubkey }}
className="w-full h-full object-cover" className="w-full h-full object-cover"
@ -647,7 +656,7 @@ export default function WebPreview({ url, className }: { url: string; className?
{fetchedProfile.nip05 && ( {fetchedProfile.nip05 && (
<> <>
<span className="text-xs text-muted-foreground flex-shrink-0"></span> <span className="text-xs text-muted-foreground flex-shrink-0"></span>
<span className="text-xs text-green-600 dark:text-green-400 truncate">{fetchedProfile.nip05}</span> <span className="text-xs text-primary truncate">{fetchedProfile.nip05}</span>
</> </>
)} )}
</> </>
@ -664,7 +673,7 @@ export default function WebPreview({ url, className }: { url: string; className?
onClick={(e) => e.stopPropagation()} onClick={(e) => e.stopPropagation()}
className="flex-shrink-0" className="flex-shrink-0"
> >
<ExternalLink className="w-3 h-3 text-green-600 dark:text-green-400" /> <ExternalLink className="w-3 h-3 text-primary" />
</a> </a>
</div> </div>
<ProfileAbout <ProfileAbout
@ -686,14 +695,34 @@ export default function WebPreview({ url, className }: { url: string; className?
) )
} }
// Basic fallback for non-nostr URLs - show site information // Basic fallback for non-nostr URLs — internal Imwald links get route-specific titles (not the shared index.html OG).
const imwaldPreview =
isInternalAppLink &&
(() => {
try {
return resolveImwaldRouteSocialCopy(new URL(cleanedUrl).pathname, '')
} catch {
return null
}
})()
return ( return (
<div <div
className={cn('p-3 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 max-w-full', className)} className={cn(
'p-3 flex w-full border border-border rounded-lg overflow-hidden gap-3 bg-card bg-gradient-to-r from-primary/[0.07] to-transparent dark:from-primary/15 max-w-full',
className
)}
> >
<div className="flex-1 min-w-0 overflow-hidden"> <div className="flex-1 min-w-0 overflow-hidden">
<div className="flex items-center gap-2 mb-1"> <div className="flex items-start gap-2 mb-1">
<div className="text-sm font-semibold text-green-900 dark:text-green-100 truncate flex-1 min-w-0">{hostname}</div> <div className="flex-1 min-w-0">
<div className="text-sm font-display font-semibold text-brand-wordmark truncate">
{imwaldPreview ? imwaldPreview.ogTitle : hostname}
</div>
{imwaldPreview && (
<div className="text-xs text-muted-foreground line-clamp-3 mt-0.5">{imwaldPreview.description}</div>
)}
</div>
<a <a
href={cleanedUrl} href={cleanedUrl}
target="_blank" target="_blank"
@ -701,19 +730,19 @@ export default function WebPreview({ url, className }: { url: string; className?
onClick={(e) => e.stopPropagation()} onClick={(e) => e.stopPropagation()}
className="flex-shrink-0" className="flex-shrink-0"
> >
<ExternalLink className="w-3 h-3 text-green-600 dark:text-green-400" /> <ExternalLink className="w-3 h-3 text-primary" />
</a> </a>
</div> </div>
<hr className="mt-4 mb-2 border-t border-border" /> <hr className="mt-4 mb-2 border-t border-border" />
<a <a
href={cleanedUrl} href={cleanedUrl}
target="_blank" target="_blank"
rel="noopener noreferrer" rel="noopener noreferrer"
onClick={(e) => e.stopPropagation()} onClick={(e) => e.stopPropagation()}
className="text-xs text-muted-foreground break-all line-clamp-2 block hover:underline" className="text-xs text-muted-foreground break-all line-clamp-2 block hover:underline"
> >
{cleanedUrl} {cleanedUrl}
</a> </a>
</div> </div>
</div> </div>
) )

271
src/lib/document-meta.ts

@ -0,0 +1,271 @@
/** Shared Open Graph / Twitter / document title helpers (client-side). */
export const SITE_NAME = 'Imwald'
export const SITE_TAGLINE =
'A user-friendly Nostr client focused on relay feed browsing, publications, and relay discovery.'
export function getSiteOrigin(): string {
if (typeof window === 'undefined') return 'https://jumble.imwald.eu'
return window.location.origin
}
export function defaultOgImageAbsoluteUrl(): string {
return `${getSiteOrigin()}/og-image.png`
}
export function avatarProxyUrl(pubkey: string): string {
return `${getSiteOrigin()}/api/avatar/${pubkey}`
}
export function updateMetaTag(property: string, content: string): void {
if (typeof document === 'undefined') return
const prop =
property.startsWith('og:') || property.startsWith('article:') || property.startsWith('profile:')
? property
: property.replace(/^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)
}
export function removeMetaByProperty(property: string): void {
if (typeof document === 'undefined') return
document.querySelectorAll(`meta[property="${property}"]`).forEach((m) => m.remove())
}
export function applyDefaultSiteSocialMeta(): void {
if (typeof window === 'undefined') return
const href = window.location.href
const truncatedUrl = href.length > 150 ? href.substring(0, 147) + '...' : href
const desc = `${truncatedUrl}${SITE_TAGLINE}`
const img = defaultOgImageAbsoluteUrl()
updateMetaTag('og:title', SITE_NAME)
updateMetaTag('og:description', desc)
updateMetaTag('og:image', img)
updateMetaTag('og:type', 'website')
updateMetaTag('og:url', href)
updateMetaTag('og:site_name', SITE_NAME)
updateMetaTag('twitter:card', 'summary_large_image')
updateMetaTag('twitter:title', SITE_NAME)
updateMetaTag('twitter:description', desc)
updateMetaTag('twitter:image', img)
}
const PRIMARY_PAGE_LABEL: Record<string, string> = {
explore: 'Explore',
feed: 'Feed',
me: 'Me',
profile: 'Profile',
relay: 'Relay',
search: 'Search',
'follows-latest': 'Latest follows',
rss: 'RSS',
settings: 'Settings',
spells: 'Spells'
}
function relayHostnameFromPath(pathname: string): string | null {
const m =
pathname.match(/\/(?:home|explore)\/relays\/(.+)$/i) || pathname.match(/^\/relays\/(.+)$/i)
if (!m?.[1]) return null
try {
const decoded = decodeURIComponent(m[1].split('/')[0])
const asHttp = decoded.startsWith('wss://')
? 'https://' + decoded.slice(6)
: decoded.startsWith('ws://')
? 'http://' + decoded.slice(5)
: decoded
const u = new URL(asHttp.includes('://') ? asHttp : `https://${asHttp}`)
return u.hostname || decoded
} catch {
return m[1].slice(0, 80)
}
}
export type TRouteSocialCopy = { pageTitle: string; ogTitle: string; description: string }
/** Note detail URLs set OG tags in NotePage. */
export function isNoteDetailPathname(pathname: string): boolean {
const path = pathname.split('?')[0].split('#')[0]
return (
/\/notes\/[^/?#]+/.test(path) ||
/\/(?:discussions|search|profile|home|feed|spells|explore|rss|follows-latest)\/notes\/[^/?#]+/.test(
path
)
)
}
/** Profile detail (/users/:id) sets OG in ProfilePage. */
export function isProfileDetailPathname(pathname: string): boolean {
const path = pathname.split('?')[0].split('#')[0].replace(/\/$/, '') || '/'
return /^\/users\/[^/]+$/.test(path)
}
/** Labels for static Imwald URLs (in-app link previews + route-level OG when no note/profile). */
export function resolveImwaldRouteSocialCopy(
pathname: string,
currentPrimaryPage: string
): TRouteSocialCopy {
const path = pathname.split('?')[0].split('#')[0].replace(/\/$/, '') || '/'
const href = typeof window !== 'undefined' ? window.location.href : ''
const relayHost = relayHostnameFromPath(path)
let pageTitle = SITE_NAME
let ogTitle = SITE_NAME
let description = href ? `${SITE_TAGLINE} ${href}` : SITE_TAGLINE
if (path.startsWith('/settings')) {
if (path.includes('/general')) {
pageTitle = `General · ${SITE_NAME}`
ogTitle = `General settings · ${SITE_NAME}`
} else if (path.includes('/relays')) {
pageTitle = `Relays · ${SITE_NAME}`
ogTitle = `Relay & storage settings · ${SITE_NAME}`
} else if (path.includes('/cache')) {
pageTitle = `Cache · ${SITE_NAME}`
ogTitle = `Cache & offline storage · ${SITE_NAME}`
} else if (path.includes('/wallet')) {
pageTitle = `Wallet · ${SITE_NAME}`
ogTitle = `Wallet settings · ${SITE_NAME}`
} else if (path.includes('/posts')) {
pageTitle = `Posts · ${SITE_NAME}`
ogTitle = `Post settings · ${SITE_NAME}`
} else if (path.includes('/translation')) {
pageTitle = `Translation · ${SITE_NAME}`
ogTitle = `Translation settings · ${SITE_NAME}`
} else if (path.includes('/rss-feeds')) {
pageTitle = `RSS feeds · ${SITE_NAME}`
ogTitle = `RSS feed settings · ${SITE_NAME}`
} else if (path.includes('/follow-sets')) {
pageTitle = `Follow sets · ${SITE_NAME}`
ogTitle = `Follow sets · ${SITE_NAME}`
} else if (path.includes('/personal-lists')) {
pageTitle = `Lists · ${SITE_NAME}`
ogTitle = `Personal lists · ${SITE_NAME}`
} else {
pageTitle = `Settings · ${SITE_NAME}`
ogTitle = `Settings · ${SITE_NAME}`
}
description = `${ogTitle}. ${SITE_TAGLINE}`
} else if (path === '/search' || path.startsWith('/search/')) {
pageTitle = `Search · ${SITE_NAME}`
ogTitle = pageTitle
description = `Search notes and people on Nostr with ${SITE_NAME}.`
} else if (relayHost) {
const host = relayHost
pageTitle = `${host} · ${SITE_NAME}`
ogTitle = `Relay ${host} · ${SITE_NAME}`
description = `Relay ${host} on ${SITE_NAME}. ${SITE_TAGLINE}`
} else if (path.startsWith('/rss-item') || path.includes('/rss-item/')) {
pageTitle = `Article · ${SITE_NAME}`
ogTitle = `RSS article · ${SITE_NAME}`
description = `Read an RSS-sourced article in ${SITE_NAME}.`
} else if (path === '/users') {
pageTitle = `People · ${SITE_NAME}`
ogTitle = `People on ${SITE_NAME}`
description = `Browse Nostr profiles in ${SITE_NAME}.`
} else if (path === '/bookmarks') {
pageTitle = `Bookmarks · ${SITE_NAME}`
ogTitle = pageTitle
description = `Your bookmarked notes on ${SITE_NAME}.`
} else if (path === '/mutes') {
pageTitle = `Muted users · ${SITE_NAME}`
ogTitle = pageTitle
description = `Muted users in ${SITE_NAME}.`
} else if (path === '/pins') {
pageTitle = `Pinned notes · ${SITE_NAME}`
ogTitle = pageTitle
description = `Pinned notes in ${SITE_NAME}.`
} else if (path === '/interests') {
pageTitle = `Interests · ${SITE_NAME}`
ogTitle = pageTitle
description = `Interest lists in ${SITE_NAME}.`
} else if (path === '/profile-editor') {
pageTitle = `Edit profile · ${SITE_NAME}`
ogTitle = pageTitle
description = `Edit your Nostr profile in ${SITE_NAME}.`
} else if (path === '/follow-packs') {
pageTitle = `Follow packs · ${SITE_NAME}`
ogTitle = pageTitle
description = `Follow packs on ${SITE_NAME}.`
} else if (path === '/notes') {
pageTitle = `Notes · ${SITE_NAME}`
ogTitle = pageTitle
description = `Notes in ${SITE_NAME}.`
} else if (path.match(/^\/users\/[^/]+\/following$/)) {
pageTitle = `Following · ${SITE_NAME}`
ogTitle = `Following list · ${SITE_NAME}`
description = `Following list on ${SITE_NAME}.`
} else if (path.match(/^\/users\/[^/]+\/relays$/)) {
pageTitle = `Relays · ${SITE_NAME}`
ogTitle = `User relays · ${SITE_NAME}`
description = `Relay list on ${SITE_NAME}.`
} else if (path === '/' || path === '/home') {
pageTitle = `Home · ${SITE_NAME}`
ogTitle = `Home · ${SITE_NAME}`
description = `${SITE_TAGLINE} ${href}`
} else {
const seg = path.split('/').filter(Boolean)[0]
if (seg && PRIMARY_PAGE_LABEL[seg]) {
const label = PRIMARY_PAGE_LABEL[seg]
pageTitle = `${label} · ${SITE_NAME}`
ogTitle = pageTitle
description = `${label} in ${SITE_NAME}. ${SITE_TAGLINE}`
} else if (currentPrimaryPage && PRIMARY_PAGE_LABEL[currentPrimaryPage]) {
const label = PRIMARY_PAGE_LABEL[currentPrimaryPage]
pageTitle = `${label} · ${SITE_NAME}`
ogTitle = pageTitle
description = `${label} in ${SITE_NAME}. ${SITE_TAGLINE}`
}
}
return { pageTitle, ogTitle, description }
}
/**
* Browser tab + social tags for routes that do not set their own (e.g. settings, lists).
* Note and profile detail pages set richer tags in their components.
*/
export function applyRouteDocumentMeta(pathname: string, currentPrimaryPage: string): void {
if (typeof window === 'undefined') return
const { pageTitle, ogTitle, description } = resolveImwaldRouteSocialCopy(pathname, currentPrimaryPage)
const href = window.location.href
const img = defaultOgImageAbsoluteUrl()
document.title = pageTitle
updateMetaTag('og:title', ogTitle)
updateMetaTag('og:description', description.length > 300 ? description.slice(0, 297) + '...' : description)
updateMetaTag('og:image', img)
updateMetaTag('og:type', 'website')
updateMetaTag('og:url', href)
updateMetaTag('og:site_name', SITE_NAME)
updateMetaTag('twitter:card', 'summary_large_image')
updateMetaTag('twitter:title', ogTitle)
updateMetaTag(
'twitter:description',
description.length > 200 ? description.slice(0, 197) + '...' : description
)
updateMetaTag('twitter:image', img)
removeMetaByProperty('article:tag')
removeMetaByProperty('article:author')
document.querySelector('meta[property="article:author:url"]')?.remove()
}

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

@ -25,6 +25,15 @@ import { kinds, nip19 } from 'nostr-tools'
import { forwardRef, useCallback, useEffect, useMemo, useState } from 'react' import { forwardRef, useCallback, useEffect, useMemo, useState } from 'react'
import { useTranslation } from 'react-i18next' import { useTranslation } from 'react-i18next'
import { NOSTR_URI_NADDR_REGEX } from '@/lib/content-patterns' import { NOSTR_URI_NADDR_REGEX } from '@/lib/content-patterns'
import {
applyDefaultSiteSocialMeta,
avatarProxyUrl,
defaultOgImageAbsoluteUrl,
getSiteOrigin,
removeMetaByProperty,
SITE_NAME,
updateMetaTag
} from '@/lib/document-meta'
import NotFound from './NotFound' import NotFound from './NotFound'
// Helper function to get event type name (matching WebPreview) // Helper function to get event type name (matching WebPreview)
@ -231,53 +240,11 @@ const NotePage = forwardRef(({ id, index, hideTitlebar = false, initialEvent }:
} }
}, [hideTitlebar, finalEvent]) }, [hideTitlebar, finalEvent])
// Helper function to update or create meta tags // Update OpenGraph metadata to match in-app preview cards and site branding
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="|"$/, '')
// 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)
}
// Update OpenGraph metadata to match fallback cards
useEffect(() => { useEffect(() => {
if (!finalEvent) { if (!finalEvent) {
// Reset to default meta tags with richer information applyDefaultSiteSocialMeta()
const defaultUrl = window.location.href removeMetaByProperty('article:tag')
const truncatedDefaultUrl = defaultUrl.length > 150 ? defaultUrl.substring(0, 147) + '...' : defaultUrl
updateMetaTag('og:title', 'Imwald')
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://jumble.imwald.eu/og-image.png')
updateMetaTag('og:type', 'website')
updateMetaTag('og:url', window.location.href)
updateMetaTag('og:site_name', 'Imwald')
// Twitter card meta tags
updateMetaTag('twitter:card', 'summary_large_image')
updateMetaTag('twitter:title', 'Imwald')
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://jumble.imwald.eu/og-image.png')
// Remove article:tag if it exists
const articleTagMeta = document.querySelector('meta[property="article:tag"]')
if (articleTagMeta) {
articleTagMeta.remove()
}
return return
} }
@ -351,15 +318,12 @@ const NotePage = forwardRef(({ id, index, hideTitlebar = false, initialEvent }:
ogDescription = ogDescription.replace('Event', `${eventTypeName} (kind ${finalEvent.kind})`) ogDescription = ogDescription.replace('Event', `${eventTypeName} (kind ${finalEvent.kind})`)
} }
// Prioritize event image, then author avatar, then default
// Use a beautiful green-themed image with profile data
let image = eventMetadata?.image let image = eventMetadata?.image
if (!image && authorProfile?.avatar) { if (!image && authorProfile?.pubkey) {
image = `https://jumble.imwald.eu/api/avatar/${authorProfile.pubkey}` image = avatarProxyUrl(authorProfile.pubkey)
} }
if (!image) { if (!image) {
// Use default OG image with green forest theme image = defaultOgImageAbsoluteUrl()
image = 'https://jumble.imwald.eu/og-image.png'
} }
const tags = eventMetadata?.tags || [] const tags = eventMetadata?.tags || []
@ -369,19 +333,19 @@ const NotePage = forwardRef(({ id, index, hideTitlebar = false, initialEvent }:
const ogType = isArticle ? 'article' : 'website' const ogType = isArticle ? 'article' : 'website'
// Enhanced title with profile info // Enhanced title with profile info
const ogTitle = authorName const ogTitle = authorName
? `${eventTitle} by @${authorName} - Imwald ` ? `${eventTitle} · @${authorName} · ${SITE_NAME}`
: `${eventTitle} - Imwald ` : `${eventTitle} · ${SITE_NAME}`
updateMetaTag('og:title', ogTitle) updateMetaTag('og:title', ogTitle)
updateMetaTag('og:description', ogDescription) updateMetaTag('og:description', ogDescription)
updateMetaTag('og:image', image) updateMetaTag('og:image', image)
updateMetaTag('og:image:width', '1200') updateMetaTag('og:image:width', '1200')
updateMetaTag('og:image:height', '630') updateMetaTag('og:image:height', '630')
updateMetaTag('og:image:alt', `${eventTitle}${authorName ? ` by @${authorName}` : ''} on Imwald`) updateMetaTag('og:image:alt', `${eventTitle}${authorName ? ` by @${authorName}` : ''} on ${SITE_NAME}`)
updateMetaTag('og:type', ogType) updateMetaTag('og:type', ogType)
updateMetaTag('og:url', window.location.href) updateMetaTag('og:url', window.location.href)
updateMetaTag('og:site_name', 'Imwald ') updateMetaTag('og:site_name', SITE_NAME)
// Add profile data - always include if available // Add profile data - always include if available
if (authorProfile) { if (authorProfile) {
@ -396,11 +360,8 @@ const NotePage = forwardRef(({ id, index, hideTitlebar = false, initialEvent }:
// Add author for articles // Add author for articles
if (isArticle && authorName) { if (isArticle && authorName) {
updateMetaTag('article:author', authorName) updateMetaTag('article:author', authorName)
if (authorProfile?.nip05) { const authorUrl = `${getSiteOrigin()}/users/${nip19.npubEncode(finalEvent.pubkey)}`
// Add author URL if NIP-05 is available updateMetaTag('article:author:url', authorUrl)
const authorUrl = `https://jumble.imwald.eu/profiles/${finalEvent.pubkey}`
updateMetaTag('article:author:url', authorUrl)
}
} }
// Twitter card meta tags // Twitter card meta tags
@ -408,14 +369,10 @@ const NotePage = forwardRef(({ id, index, hideTitlebar = false, initialEvent }:
updateMetaTag('twitter:title', ogTitle) updateMetaTag('twitter:title', ogTitle)
updateMetaTag('twitter:description', ogDescription.length > 200 ? ogDescription.substring(0, 197) + '...' : ogDescription) updateMetaTag('twitter:description', ogDescription.length > 200 ? ogDescription.substring(0, 197) + '...' : ogDescription)
updateMetaTag('twitter:image', image) updateMetaTag('twitter:image', image)
updateMetaTag('twitter:image:alt', `${eventTitle}${authorName ? ` by @${authorName}` : ''} on Imwald`) updateMetaTag('twitter:image:alt', `${eventTitle}${authorName ? ` by @${authorName}` : ''} on ${SITE_NAME}`)
// Remove old article:tag if it exists
const oldArticleTagMeta = document.querySelector('meta[property="article:tag"]')
if (oldArticleTagMeta) {
oldArticleTagMeta.remove()
}
removeMetaByProperty('article:tag')
// Add article-specific tags (one meta tag per tag) // Add article-specific tags (one meta tag per tag)
if (isArticle) { if (isArticle) {
tags.forEach(tag => { tags.forEach(tag => {
@ -426,29 +383,14 @@ const NotePage = forwardRef(({ id, index, hideTitlebar = false, initialEvent }:
}) })
} }
// Update document title document.title = ogTitle
document.title = `${eventTitle} - Imwald`
// Cleanup function
return () => { return () => {
// Reset to default on unmount with richer information applyDefaultSiteSocialMeta()
const cleanupUrl = window.location.href removeMetaByProperty('article:tag')
const truncatedCleanupUrl = cleanupUrl.length > 150 ? cleanupUrl.substring(0, 147) + '...' : cleanupUrl removeMetaByProperty('article:author')
updateMetaTag('og:title', 'Imwald ') document.querySelector('meta[property="article:author:url"]')?.remove()
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.`) document.title = SITE_NAME
updateMetaTag('og:image', 'https://jumble.imwald.eu/og-image.png')
updateMetaTag('og:type', 'website')
updateMetaTag('og:url', window.location.href)
updateMetaTag('og:site_name', 'Imwald ')
// 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 = 'Imwald '
} }
}, [finalEvent, articleMetadata, authorProfile]) }, [finalEvent, articleMetadata, authorProfile])

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

@ -3,29 +3,16 @@ import { RefreshButton } from '@/components/RefreshButton'
import { useFetchProfile } from '@/hooks' import { useFetchProfile } from '@/hooks'
import SecondaryPageLayout from '@/layouts/SecondaryPageLayout' import SecondaryPageLayout from '@/layouts/SecondaryPageLayout'
import { usePrimaryNoteView } from '@/contexts/primary-note-view-context' import { usePrimaryNoteView } from '@/contexts/primary-note-view-context'
import {
applyDefaultSiteSocialMeta,
avatarProxyUrl,
defaultOgImageAbsoluteUrl,
removeMetaByProperty,
SITE_NAME,
updateMetaTag
} from '@/lib/document-meta'
import { forwardRef, useCallback, useEffect, useRef } from 'react' import { forwardRef, useCallback, useEffect, useRef } 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 ProfilePage = forwardRef(({ id, index, hideTitlebar = false }: { id?: string; index?: number; hideTitlebar?: boolean }, ref) => {
const { registerPrimaryPanelRefresh } = usePrimaryNoteView() const { registerPrimaryPanelRefresh } = usePrimaryNoteView()
const feedRef = useRef<{ refresh: () => void }>(null) const feedRef = useRef<{ refresh: () => void }>(null)
@ -41,38 +28,20 @@ const ProfilePage = forwardRef(({ id, index, hideTitlebar = false }: { id?: stri
}, [hideTitlebar, registerPrimaryPanelRefresh, bumpFeed]) }, [hideTitlebar, registerPrimaryPanelRefresh, bumpFeed])
const { profile } = useFetchProfile(id) const { profile } = useFetchProfile(id)
// Update OpenGraph metadata to match fallback card format for profiles
useEffect(() => { useEffect(() => {
if (!profile) { if (!profile) {
// Reset to default meta tags applyDefaultSiteSocialMeta()
const defaultUrl = window.location.href
const truncatedDefaultUrl = defaultUrl.length > 150 ? defaultUrl.substring(0, 147) + '...' : defaultUrl
updateMetaTag('og:title', 'Imwald ')
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://jumble.imwald.eu/og-image.png')
updateMetaTag('og:type', 'profile') updateMetaTag('og:type', 'profile')
updateMetaTag('og:url', window.location.href)
updateMetaTag('og:site_name', 'Imwald ')
// Twitter card meta tags
updateMetaTag('twitter:card', 'summary')
updateMetaTag('twitter:title', 'Imwald ')
updateMetaTag('twitter:description', `${truncatedDefaultUrl} - Profile`)
updateMetaTag('twitter:image', 'https://jumble.imwald.eu/og-image.png')
return return
} }
// Build description matching fallback card: username, hostname, URL
const username = profile.username || '' const username = profile.username || ''
const ogTitle = username ? `@${username} - Imwald ` : 'Profile - Imwald ' const ogTitle = username ? `@${username} · ${SITE_NAME}` : `Profile · ${SITE_NAME}`
// Truncate URL to 150 chars
const fullUrl = window.location.href const fullUrl = window.location.href
const truncatedUrl = fullUrl.length > 150 ? fullUrl.substring(0, 147) + '...' : fullUrl const truncatedUrl = fullUrl.length > 150 ? fullUrl.substring(0, 147) + '...' : fullUrl
// Build rich description with profile info
let ogDescription = username ? `@${username}` : 'Profile' let ogDescription = username ? `@${username}` : 'Profile'
if (profile.nip05) { if (profile.nip05) {
ogDescription += `${profile.nip05}` ogDescription += `${profile.nip05}`
@ -82,52 +51,39 @@ const ProfilePage = forwardRef(({ id, index, hideTitlebar = false }: { id?: stri
ogDescription += ` | ${aboutPreview}` ogDescription += ` | ${aboutPreview}`
} }
ogDescription += ` | ${truncatedUrl}` ogDescription += ` | ${truncatedUrl}`
// Use profile avatar or default image with green theme const image = profile.avatar ? avatarProxyUrl(profile.pubkey) : defaultOgImageAbsoluteUrl()
const image = profile.avatar
? `https://jumble.imwald.eu/api/avatar/${profile.pubkey}`
: 'https://jumble.imwald.eu/og-image.png'
updateMetaTag('og:title', ogTitle) updateMetaTag('og:title', ogTitle)
updateMetaTag('og:description', ogDescription) updateMetaTag('og:description', ogDescription)
updateMetaTag('og:image', image) updateMetaTag('og:image', image)
updateMetaTag('og:image:width', '1200') updateMetaTag('og:image:width', '1200')
updateMetaTag('og:image:height', '630') updateMetaTag('og:image:height', '630')
updateMetaTag('og:image:alt', `${username ? `@${username}` : 'Profile'} on Imwald`) updateMetaTag('og:image:alt', `${username ? `@${username}` : 'Profile'} on ${SITE_NAME}`)
updateMetaTag('og:type', 'profile') updateMetaTag('og:type', 'profile')
updateMetaTag('og:url', window.location.href) updateMetaTag('og:url', window.location.href)
updateMetaTag('og:site_name', 'Imwald ') updateMetaTag('og:site_name', SITE_NAME)
// Add profile-specific meta tags
if (profile.username) { if (profile.username) {
updateMetaTag('profile:username', profile.username) updateMetaTag('profile:username', profile.username)
} }
if (profile.nip05) { if (profile.nip05) {
updateMetaTag('profile:username', profile.nip05) updateMetaTag('profile:username', profile.nip05)
} }
// Twitter card meta tags
updateMetaTag('twitter:card', 'summary_large_image') updateMetaTag('twitter:card', 'summary_large_image')
updateMetaTag('twitter:title', ogTitle) updateMetaTag('twitter:title', ogTitle)
updateMetaTag('twitter:description', ogDescription.length > 200 ? ogDescription.substring(0, 197) + '...' : ogDescription) updateMetaTag('twitter:description', ogDescription.length > 200 ? ogDescription.substring(0, 197) + '...' : ogDescription)
updateMetaTag('twitter:image', image) updateMetaTag('twitter:image', image)
updateMetaTag('twitter:image:alt', `${username ? `@${username}` : 'Profile'} on Imwald`) updateMetaTag('twitter:image:alt', `${username ? `@${username}` : 'Profile'} on ${SITE_NAME}`)
// Update document title document.title = ogTitle
document.title = `${ogTitle} - Imwald`
// Cleanup function
return () => { return () => {
// Reset to default on unmount applyDefaultSiteSocialMeta()
const cleanupUrl = window.location.href
const truncatedCleanupUrl = cleanupUrl.length > 150 ? cleanupUrl.substring(0, 147) + '...' : cleanupUrl
updateMetaTag('og:title', 'Imwald ')
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://jumble.imwald.eu/og-image.png')
updateMetaTag('og:type', 'website') updateMetaTag('og:type', 'website')
updateMetaTag('og:url', window.location.href) removeMetaByProperty('profile:username')
updateMetaTag('og:site_name', 'Imwald ') document.title = SITE_NAME
document.title = 'Imwald '
} }
}, [profile]) }, [profile])
@ -143,5 +99,7 @@ const ProfilePage = forwardRef(({ id, index, hideTitlebar = false }: { id?: stri
</SecondaryPageLayout> </SecondaryPageLayout>
) )
}) })
ProfilePage.displayName = 'ProfilePage' ProfilePage.displayName = 'ProfilePage'
export default ProfilePage export default ProfilePage

Loading…
Cancel
Save