9 changed files with 382 additions and 56 deletions
@ -0,0 +1,134 @@ |
|||||||
|
import { Button } from '@/components/ui/button' |
||||||
|
import { cn } from '@/lib/utils' |
||||||
|
import { cleanUrl } from '@/lib/url' |
||||||
|
import { |
||||||
|
extractExternalUrlNostrForExpandable, |
||||||
|
getBrowserAppOrigin, |
||||||
|
parseSameOriginAppNostrUrl |
||||||
|
} from '@/lib/nostr-from-http-url' |
||||||
|
import { ChevronDown } from 'lucide-react' |
||||||
|
import type { Event } from 'nostr-tools' |
||||||
|
import { useMemo, useState } from 'react' |
||||||
|
import { useTranslation } from 'react-i18next' |
||||||
|
import { EmbeddedMention } from './EmbeddedMention' |
||||||
|
import { EmbeddedNormalUrl } from './EmbeddedNormalUrl' |
||||||
|
import { EmbeddedNote } from './EmbeddedNote' |
||||||
|
import WebPreview from '@/components/WebPreview' |
||||||
|
|
||||||
|
type RenderMode = 'note-content' | 'article' |
||||||
|
|
||||||
|
export function HttpNostrAwareUrl({ |
||||||
|
url, |
||||||
|
renderMode, |
||||||
|
containingEvent, |
||||||
|
className |
||||||
|
}: { |
||||||
|
url: string |
||||||
|
renderMode: RenderMode |
||||||
|
containingEvent?: Event |
||||||
|
className?: string |
||||||
|
}) { |
||||||
|
const { t } = useTranslation() |
||||||
|
const appOrigin = useMemo(() => getBrowserAppOrigin(), []) |
||||||
|
const sameOriginTarget = useMemo( |
||||||
|
() => parseSameOriginAppNostrUrl(url, appOrigin), |
||||||
|
[url, appOrigin] |
||||||
|
) |
||||||
|
const expandableTarget = useMemo( |
||||||
|
() => (!sameOriginTarget ? extractExternalUrlNostrForExpandable(url, appOrigin) : null), |
||||||
|
[url, appOrigin, sameOriginTarget] |
||||||
|
) |
||||||
|
|
||||||
|
const cleaned = cleanUrl(url) || url |
||||||
|
|
||||||
|
if (sameOriginTarget) { |
||||||
|
if (sameOriginTarget.kind === 'event') { |
||||||
|
return ( |
||||||
|
<EmbeddedNote |
||||||
|
noteId={sameOriginTarget.id} |
||||||
|
className={cn('mt-2', className)} |
||||||
|
containingEvent={containingEvent} |
||||||
|
/> |
||||||
|
) |
||||||
|
} |
||||||
|
return ( |
||||||
|
<span className={cn('inline', className)}> |
||||||
|
<EmbeddedMention userId={sameOriginTarget.id} className="inline" /> |
||||||
|
</span> |
||||||
|
) |
||||||
|
} |
||||||
|
|
||||||
|
if (expandableTarget) { |
||||||
|
return ( |
||||||
|
<ExpandableExternalNostrLink |
||||||
|
url={url} |
||||||
|
cleanedUrl={cleaned} |
||||||
|
target={expandableTarget} |
||||||
|
containingEvent={containingEvent} |
||||||
|
className={className} |
||||||
|
expandLabel={t('link.expandNostrEmbed')} |
||||||
|
/> |
||||||
|
) |
||||||
|
} |
||||||
|
|
||||||
|
if (renderMode === 'article') { |
||||||
|
return <WebPreview url={cleaned} className={cn('mt-2', className)} /> |
||||||
|
} |
||||||
|
|
||||||
|
return <EmbeddedNormalUrl url={url} /> |
||||||
|
} |
||||||
|
|
||||||
|
function ExpandableExternalNostrLink({ |
||||||
|
url, |
||||||
|
cleanedUrl, |
||||||
|
target, |
||||||
|
containingEvent, |
||||||
|
className, |
||||||
|
expandLabel |
||||||
|
}: { |
||||||
|
url: string |
||||||
|
cleanedUrl: string |
||||||
|
target: { kind: 'event' | 'profile'; id: string } |
||||||
|
containingEvent?: Event |
||||||
|
className?: string |
||||||
|
expandLabel: string |
||||||
|
}) { |
||||||
|
const [open, setOpen] = useState(false) |
||||||
|
|
||||||
|
return ( |
||||||
|
<span className={cn('inline-flex max-w-full flex-wrap items-center gap-0.5 align-baseline', className)}> |
||||||
|
<EmbeddedNormalUrl url={url}>{cleanedUrl}</EmbeddedNormalUrl> |
||||||
|
<Button |
||||||
|
type="button" |
||||||
|
variant="ghost" |
||||||
|
size="icon" |
||||||
|
className="h-6 w-6 shrink-0 text-muted-foreground hover:text-foreground" |
||||||
|
aria-expanded={open} |
||||||
|
aria-label={expandLabel} |
||||||
|
title={expandLabel} |
||||||
|
onClick={(e) => { |
||||||
|
e.preventDefault() |
||||||
|
e.stopPropagation() |
||||||
|
setOpen((o) => !o) |
||||||
|
}} |
||||||
|
> |
||||||
|
<ChevronDown className={cn('h-4 w-4 transition-transform duration-200', open && 'rotate-180')} /> |
||||||
|
</Button> |
||||||
|
{open ? ( |
||||||
|
<span className="block w-full basis-full"> |
||||||
|
{target.kind === 'event' ? ( |
||||||
|
<EmbeddedNote |
||||||
|
noteId={target.id} |
||||||
|
className="mt-2" |
||||||
|
containingEvent={containingEvent} |
||||||
|
/> |
||||||
|
) : ( |
||||||
|
<span className="mt-2 inline-block"> |
||||||
|
<EmbeddedMention userId={target.id} /> |
||||||
|
</span> |
||||||
|
)} |
||||||
|
</span> |
||||||
|
) : null} |
||||||
|
</span> |
||||||
|
) |
||||||
|
} |
||||||
@ -0,0 +1,194 @@ |
|||||||
|
import { nip19 } from 'nostr-tools' |
||||||
|
|
||||||
|
/** Find npub1 / nprofile1 / note1 / nevent1 / naddr1 tokens in text. */ |
||||||
|
const BECH32_NOSTR_RE = /(?:npub1|nprofile1|note1|nevent1|naddr1)[a-z0-9]+/gi |
||||||
|
|
||||||
|
export type NostrUrlExtract = { kind: 'event' | 'profile'; id: string } |
||||||
|
|
||||||
|
function classifyBech32(id: string): NostrUrlExtract | null { |
||||||
|
try { |
||||||
|
const { type } = nip19.decode(id) |
||||||
|
if (type === 'npub' || type === 'nprofile') return { kind: 'profile', id } |
||||||
|
if (type === 'note' || type === 'nevent' || type === 'naddr') return { kind: 'event', id } |
||||||
|
} catch { |
||||||
|
// ignore
|
||||||
|
} |
||||||
|
return null |
||||||
|
} |
||||||
|
|
||||||
|
function firstNostrExtractInString(s: string): NostrUrlExtract | null { |
||||||
|
const re = new RegExp(BECH32_NOSTR_RE.source, 'gi') |
||||||
|
let m: RegExpExecArray | null |
||||||
|
while ((m = re.exec(s)) !== null) { |
||||||
|
const hit = classifyBech32(m[0]) |
||||||
|
if (hit) return hit |
||||||
|
} |
||||||
|
return null |
||||||
|
} |
||||||
|
|
||||||
|
function isValidEmbeddedNotePointer(id: string): boolean { |
||||||
|
const s = id.trim() |
||||||
|
if (/^[0-9a-f]{64}$/i.test(s)) return true |
||||||
|
const hit = classifyBech32(s) |
||||||
|
return hit?.kind === 'event' |
||||||
|
} |
||||||
|
|
||||||
|
function isProfilePointer(id: string): boolean { |
||||||
|
const s = id.trim() |
||||||
|
if (/^[0-9a-f]{64}$/i.test(s)) return true |
||||||
|
const hit = classifyBech32(s) |
||||||
|
return hit?.kind === 'profile' |
||||||
|
} |
||||||
|
|
||||||
|
function extractHex64(s: string): string | null { |
||||||
|
const m = s.match(/\b[0-9a-f]{64}\b/i) |
||||||
|
return m ? m[0].toLowerCase() : null |
||||||
|
} |
||||||
|
|
||||||
|
/** |
||||||
|
* True if this hostname serves this web app: current tab origin and/or known production/dev hosts. |
||||||
|
* Needed so `https://jumble.imwald.eu/.../notes/nevent…` embeds while the dev server runs on localhost. |
||||||
|
*/ |
||||||
|
export function urlHostnameIsKnownJumbleAppHost( |
||||||
|
urlHostname: string, |
||||||
|
appOrigin: string | null |
||||||
|
): boolean { |
||||||
|
const h = urlHostname.toLowerCase() |
||||||
|
if (h === 'jumble.imwald.eu') return true |
||||||
|
if (h === 'localhost' || h === '127.0.0.1') return true |
||||||
|
if (appOrigin) { |
||||||
|
try { |
||||||
|
if (h === new URL(appOrigin).hostname.toLowerCase()) return true |
||||||
|
} catch { |
||||||
|
// ignore
|
||||||
|
} |
||||||
|
} |
||||||
|
return false |
||||||
|
} |
||||||
|
|
||||||
|
/** |
||||||
|
* In-app HTTP(S) links to our routes → embed like `nostr:…` (same tab origin or known jumble/localhost host). |
||||||
|
*/ |
||||||
|
export function parseSameOriginAppNostrUrl(urlStr: string, appOrigin: string | null): NostrUrlExtract | null { |
||||||
|
let u: URL |
||||||
|
try { |
||||||
|
u = new URL(urlStr) |
||||||
|
} catch { |
||||||
|
return null |
||||||
|
} |
||||||
|
if (!urlHostnameIsKnownJumbleAppHost(u.hostname, appOrigin)) return null |
||||||
|
|
||||||
|
let path = u.pathname |
||||||
|
if (path.length > 1 && path.endsWith('/')) path = path.slice(0, -1) |
||||||
|
if (!path) path = '/' |
||||||
|
|
||||||
|
const usersMatch = path.match(/^\/users\/([^/?#]+)$/i) |
||||||
|
if (usersMatch) { |
||||||
|
const id = decodeURIComponent(usersMatch[1]) |
||||||
|
if (isProfilePointer(id)) { |
||||||
|
return { kind: 'profile', id } |
||||||
|
} |
||||||
|
return null |
||||||
|
} |
||||||
|
|
||||||
|
const notesMatch = path.match(/\/notes\/([^/?#]+)$/i) |
||||||
|
if (notesMatch) { |
||||||
|
const id = decodeURIComponent(notesMatch[1]) |
||||||
|
if (isValidEmbeddedNotePointer(id)) { |
||||||
|
return { kind: 'event', id } |
||||||
|
} |
||||||
|
return null |
||||||
|
} |
||||||
|
|
||||||
|
return null |
||||||
|
} |
||||||
|
|
||||||
|
const QUERY_KEYS_PRIORITY = [ |
||||||
|
'id', |
||||||
|
'nevent', |
||||||
|
'note', |
||||||
|
'naddr', |
||||||
|
'event', |
||||||
|
'e', |
||||||
|
'npub', |
||||||
|
'nprofile', |
||||||
|
'pubkey', |
||||||
|
'user', |
||||||
|
'p', |
||||||
|
'author' |
||||||
|
] |
||||||
|
|
||||||
|
/** |
||||||
|
* Third-party URLs: Nostr id in query or path — offer chevron-expand embed (not auto). |
||||||
|
*/ |
||||||
|
export function extractExternalUrlNostrForExpandable( |
||||||
|
urlStr: string, |
||||||
|
appOrigin: string | null |
||||||
|
): NostrUrlExtract | null { |
||||||
|
if (parseSameOriginAppNostrUrl(urlStr, appOrigin)) return null |
||||||
|
|
||||||
|
let u: URL |
||||||
|
try { |
||||||
|
u = new URL(urlStr) |
||||||
|
} catch { |
||||||
|
return null |
||||||
|
} |
||||||
|
|
||||||
|
const tryPiece = (raw: string): NostrUrlExtract | null => { |
||||||
|
const s = raw.trim() |
||||||
|
if (!s) return null |
||||||
|
const hex = extractHex64(s) |
||||||
|
if (hex) return { kind: 'event', id: hex } |
||||||
|
const b = firstNostrExtractInString(s) |
||||||
|
if (b) return b |
||||||
|
return null |
||||||
|
} |
||||||
|
|
||||||
|
for (const key of QUERY_KEYS_PRIORITY) { |
||||||
|
const v = u.searchParams.get(key) |
||||||
|
if (!v) continue |
||||||
|
let decoded = v |
||||||
|
try { |
||||||
|
decoded = decodeURIComponent(v) |
||||||
|
} catch { |
||||||
|
// use raw
|
||||||
|
} |
||||||
|
const hit = tryPiece(decoded) |
||||||
|
if (hit) return hit |
||||||
|
} |
||||||
|
|
||||||
|
for (const [, v] of u.searchParams.entries()) { |
||||||
|
let decoded = v |
||||||
|
try { |
||||||
|
decoded = decodeURIComponent(v) |
||||||
|
} catch { |
||||||
|
// use raw
|
||||||
|
} |
||||||
|
const hit = tryPiece(decoded) |
||||||
|
if (hit) return hit |
||||||
|
} |
||||||
|
|
||||||
|
const pathHit = tryPiece(u.pathname) |
||||||
|
if (pathHit) return pathHit |
||||||
|
|
||||||
|
const hash = u.hash ? u.hash.slice(1) : '' |
||||||
|
if (hash) { |
||||||
|
const hashHit = tryPiece(hash) |
||||||
|
if (hashHit) return hashHit |
||||||
|
} |
||||||
|
|
||||||
|
return firstNostrExtractInString(u.href) ?? null |
||||||
|
} |
||||||
|
|
||||||
|
export function getBrowserAppOrigin(): string | null { |
||||||
|
if (typeof window === 'undefined') return null |
||||||
|
return window.location.origin |
||||||
|
} |
||||||
|
|
||||||
|
/** Skip duplicate WebPreview at bottom of note when URL is handled as embed / expandable. */ |
||||||
|
export function httpUrlSkipsBottomWebPreview(urlStr: string, appOrigin: string | null): boolean { |
||||||
|
return ( |
||||||
|
parseSameOriginAppNostrUrl(urlStr, appOrigin) != null || |
||||||
|
extractExternalUrlNostrForExpandable(urlStr, appOrigin) != null |
||||||
|
) |
||||||
|
} |
||||||
Loading…
Reference in new issue