9 changed files with 382 additions and 56 deletions
@ -0,0 +1,134 @@
@@ -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 @@
@@ -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