diff --git a/.gitignore b/.gitignore index 36876ce0..618edc7e 100644 --- a/.gitignore +++ b/.gitignore @@ -29,5 +29,8 @@ dev-dist .vercel +# Ephemeral SVG with inlined font for OG PNG rasterization +public/.og-image.raster.svg + .venv-i18n scripts/i18n-overrides/.gaps diff --git a/index.html b/index.html index b0fe470c..95b3ded3 100644 --- a/index.html +++ b/index.html @@ -13,7 +13,7 @@ Imwald
diff --git a/package-lock.json b/package-lock.json index 601ebb03..470b56c3 100644 --- a/package-lock.json +++ b/package-lock.json @@ -100,6 +100,7 @@ "eslint-plugin-react-refresh": "^0.4.16", "globals": "^15.13.0", "jsdom": "^27.1.0", + "opentype.js": "^1.3.4", "postcss": "^8.4.49", "prettier": "3.4.2", "tailwindcss": "^3.4.17", @@ -11888,6 +11889,23 @@ "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": { "version": "0.9.4", "resolved": "https://registry.npmjs.org/optionator/-/optionator-0.9.4.tgz", @@ -14046,6 +14064,13 @@ "devOptional": true, "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": { "version": "4.0.12", "resolved": "https://registry.npmjs.org/string.prototype.matchall/-/string.prototype.matchall-4.0.12.tgz", @@ -14520,6 +14545,13 @@ "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": { "version": "2.9.0", "resolved": "https://registry.npmjs.org/tinybench/-/tinybench-2.9.0.tgz", diff --git a/package.json b/package.json index 0afc3253..f12b595d 100644 --- a/package.json +++ b/package.json @@ -27,7 +27,8 @@ "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 .\"", "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": { "@asciidoctor/core": "^3.0.4", @@ -121,6 +122,7 @@ "eslint-plugin-react-refresh": "^0.4.16", "globals": "^15.13.0", "jsdom": "^27.1.0", + "opentype.js": "^1.3.4", "postcss": "^8.4.49", "prettier": "3.4.2", "tailwindcss": "^3.4.17", diff --git a/public/fonts/OFL-PlayfairDisplay.txt b/public/fonts/OFL-PlayfairDisplay.txt new file mode 100644 index 00000000..13153890 --- /dev/null +++ b/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. diff --git a/public/fonts/PlayfairDisplay-wght.ttf b/public/fonts/PlayfairDisplay-wght.ttf new file mode 100644 index 00000000..7a09eb71 Binary files /dev/null and b/public/fonts/PlayfairDisplay-wght.ttf differ diff --git a/public/og-image.png b/public/og-image.png index 93bff78b..8650f23d 100644 Binary files a/public/og-image.png and b/public/og-image.png differ diff --git a/public/og-image.svg b/public/og-image.svg new file mode 100644 index 00000000..817e84af --- /dev/null +++ b/public/og-image.svg @@ -0,0 +1,37 @@ + + + + + + + + + + + + + + + + Imwald + Nostr reader & writer + + diff --git a/scripts/generate-og-png.mjs b/scripts/generate-og-png.mjs new file mode 100644 index 00000000..3a408cde --- /dev/null +++ b/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` 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('', idPos) + if (textOpen < 0 || textClose < 0) return defaults + const t = svg.slice(textOpen, textClose + ''.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(']*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) diff --git a/src/PageManager.tsx b/src/PageManager.tsx index 41e66359..8b81042f 100644 --- a/src/PageManager.tsx +++ b/src/PageManager.tsx @@ -41,6 +41,11 @@ import { usePrimaryPageOptional, type PrimaryPageContextValue } from '@/contexts/primary-page-context' +import { + applyRouteDocumentMeta, + isNoteDetailPathname, + isProfileDetailPathname +} from '@/lib/document-meta' import { normalizeUrl } from './lib/url' import modalManager from './services/modal-manager.service' import { decodeRssArticlePathSegment, encodeRssArticlePathSegment } from '@/lib/rss-article' @@ -1761,6 +1766,25 @@ export function PageManager({ maxStackSize = 5 }: { maxStackSize?: number }) { } }, [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) => { // Clear any primary note view when navigating to a new primary page diff --git a/src/components/WebPreview/index.tsx b/src/components/WebPreview/index.tsx index 2ac9327a..3691ae6f 100644 --- a/src/components/WebPreview/index.tsx +++ b/src/components/WebPreview/index.tsx @@ -13,6 +13,7 @@ import { nip19, kinds } from 'nostr-tools' import { useMemo, useEffect, useState } from 'react' import Image from '../Image' import Username from '../Username' +import { resolveImwaldRouteSocialCopy } from '@/lib/document-meta' import { cleanUrl, isSafeMediaUrl } from '@/lib/url' import { tagNameEquals } from '@/lib/tag' 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 return (
{displayImage && isSafeMediaUrl(displayImage) && (
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" )}> e.stopPropagation()} className="flex-shrink-0" > - +
{fetchedEvent && ( <> {/* Always show title in card header, hide it in content preview */} {eventTitle && ( -
{eventTitle}
+
+ {eventTitle} +
)} {isBookstrEvent && bookMetadata && (
@@ -627,10 +633,13 @@ export default function WebPreview({ url, className }: { url: string; className? return (
{fetchedProfile?.avatar && ( -
+
- {fetchedProfile.nip05} + {fetchedProfile.nip05} )} @@ -664,7 +673,7 @@ export default function WebPreview({ url, className }: { url: string; className? onClick={(e) => e.stopPropagation()} className="flex-shrink-0" > - +
{ + try { + return resolveImwaldRouteSocialCopy(new URL(cleanedUrl).pathname, '') + } catch { + return null + } + })() + return (
) diff --git a/src/lib/document-meta.ts b/src/lib/document-meta.ts new file mode 100644 index 00000000..75f6c559 --- /dev/null +++ b/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 = { + 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() +} diff --git a/src/pages/secondary/NotePage/index.tsx b/src/pages/secondary/NotePage/index.tsx index d781bda2..540949cb 100644 --- a/src/pages/secondary/NotePage/index.tsx +++ b/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 { useTranslation } from 'react-i18next' 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' // Helper function to get event type name (matching WebPreview) @@ -231,53 +240,11 @@ const NotePage = forwardRef(({ id, index, hideTitlebar = false, initialEvent }: } }, [hideTitlebar, finalEvent]) - // Helper function to update or create meta tags - 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 + // Update OpenGraph metadata to match in-app preview cards and site branding useEffect(() => { 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', '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() - } - + applyDefaultSiteSocialMeta() + removeMetaByProperty('article:tag') return } @@ -351,15 +318,12 @@ const NotePage = forwardRef(({ id, index, hideTitlebar = false, initialEvent }: 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 - if (!image && authorProfile?.avatar) { - image = `https://jumble.imwald.eu/api/avatar/${authorProfile.pubkey}` + if (!image && authorProfile?.pubkey) { + image = avatarProxyUrl(authorProfile.pubkey) } if (!image) { - // Use default OG image with green forest theme - image = 'https://jumble.imwald.eu/og-image.png' + image = defaultOgImageAbsoluteUrl() } const tags = eventMetadata?.tags || [] @@ -369,19 +333,19 @@ const NotePage = forwardRef(({ id, index, hideTitlebar = false, initialEvent }: const ogType = isArticle ? 'article' : 'website' // Enhanced title with profile info - const ogTitle = authorName - ? `${eventTitle} by @${authorName} - Imwald ` - : `${eventTitle} - Imwald ` + const ogTitle = authorName + ? `${eventTitle} · @${authorName} · ${SITE_NAME}` + : `${eventTitle} · ${SITE_NAME}` 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 Imwald`) + updateMetaTag('og:image:alt', `${eventTitle}${authorName ? ` by @${authorName}` : ''} on ${SITE_NAME}`) updateMetaTag('og:type', ogType) updateMetaTag('og:url', window.location.href) - updateMetaTag('og:site_name', 'Imwald ') + updateMetaTag('og:site_name', SITE_NAME) // Add profile data - always include if available if (authorProfile) { @@ -396,11 +360,8 @@ const NotePage = forwardRef(({ id, index, hideTitlebar = false, initialEvent }: // 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) - } + const authorUrl = `${getSiteOrigin()}/users/${nip19.npubEncode(finalEvent.pubkey)}` + updateMetaTag('article:author:url', authorUrl) } // Twitter card meta tags @@ -408,14 +369,10 @@ const NotePage = forwardRef(({ id, index, hideTitlebar = false, initialEvent }: 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 Imwald`) - - // Remove old article:tag if it exists - const oldArticleTagMeta = document.querySelector('meta[property="article:tag"]') - if (oldArticleTagMeta) { - oldArticleTagMeta.remove() - } + updateMetaTag('twitter:image:alt', `${eventTitle}${authorName ? ` by @${authorName}` : ''} on ${SITE_NAME}`) + removeMetaByProperty('article:tag') + // Add article-specific tags (one meta tag per tag) if (isArticle) { tags.forEach(tag => { @@ -426,29 +383,14 @@ const NotePage = forwardRef(({ id, index, hideTitlebar = false, initialEvent }: }) } - // Update document title - document.title = `${eventTitle} - Imwald` + document.title = ogTitle - // Cleanup function return () => { - // 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', '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: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 ' + applyDefaultSiteSocialMeta() + removeMetaByProperty('article:tag') + removeMetaByProperty('article:author') + document.querySelector('meta[property="article:author:url"]')?.remove() + document.title = SITE_NAME } }, [finalEvent, articleMetadata, authorProfile]) diff --git a/src/pages/secondary/ProfilePage/index.tsx b/src/pages/secondary/ProfilePage/index.tsx index e87e736e..c1c3d281 100644 --- a/src/pages/secondary/ProfilePage/index.tsx +++ b/src/pages/secondary/ProfilePage/index.tsx @@ -3,29 +3,16 @@ import { RefreshButton } from '@/components/RefreshButton' import { useFetchProfile } from '@/hooks' import SecondaryPageLayout from '@/layouts/SecondaryPageLayout' 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' -// 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 { registerPrimaryPanelRefresh } = usePrimaryNoteView() const feedRef = useRef<{ refresh: () => void }>(null) @@ -41,38 +28,20 @@ const ProfilePage = forwardRef(({ id, index, hideTitlebar = false }: { id?: stri }, [hideTitlebar, registerPrimaryPanelRefresh, bumpFeed]) 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', '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') + applyDefaultSiteSocialMeta() 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 } - - // Build description matching fallback card: username, hostname, URL + const username = profile.username || '' - const ogTitle = username ? `@${username} - Imwald ` : 'Profile - Imwald ' - - // Truncate URL to 150 chars + const ogTitle = username ? `@${username} · ${SITE_NAME}` : `Profile · ${SITE_NAME}` + 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}` @@ -82,52 +51,39 @@ const ProfilePage = forwardRef(({ id, index, hideTitlebar = false }: { id?: stri ogDescription += ` | ${aboutPreview}` } ogDescription += ` | ${truncatedUrl}` - - // Use profile avatar or default image with green theme - const image = profile.avatar - ? `https://jumble.imwald.eu/api/avatar/${profile.pubkey}` - : 'https://jumble.imwald.eu/og-image.png' - + + const image = profile.avatar ? avatarProxyUrl(profile.pubkey) : defaultOgImageAbsoluteUrl() + 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 Imwald`) + updateMetaTag('og:image:alt', `${username ? `@${username}` : 'Profile'} on ${SITE_NAME}`) updateMetaTag('og:type', 'profile') updateMetaTag('og:url', window.location.href) - updateMetaTag('og:site_name', 'Imwald ') - - // Add profile-specific meta tags + updateMetaTag('og:site_name', SITE_NAME) + if (profile.username) { updateMetaTag('profile:username', profile.username) } if (profile.nip05) { updateMetaTag('profile:username', profile.nip05) } - - // Twitter card meta tags + 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 Imwald`) - - // Update document title - document.title = `${ogTitle} - Imwald` - - // Cleanup function + updateMetaTag('twitter:image:alt', `${username ? `@${username}` : 'Profile'} on ${SITE_NAME}`) + + document.title = ogTitle + return () => { - // Reset to default on unmount - 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') + applyDefaultSiteSocialMeta() updateMetaTag('og:type', 'website') - updateMetaTag('og:url', window.location.href) - updateMetaTag('og:site_name', 'Imwald ') - document.title = 'Imwald ' + removeMetaByProperty('profile:username') + document.title = SITE_NAME } }, [profile]) @@ -143,5 +99,7 @@ const ProfilePage = forwardRef(({ id, index, hideTitlebar = false }: { id?: stri ) }) + ProfilePage.displayName = 'ProfilePage' + export default ProfilePage