You can not select more than 25 topics
Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
194 lines
4.9 KiB
194 lines
4.9 KiB
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 urlHostnameIsKnownImwaldWebHost( |
|
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 Imwald/localhost host). |
|
*/ |
|
export function parseSameOriginAppNostrUrl(urlStr: string, appOrigin: string | null): NostrUrlExtract | null { |
|
let u: URL |
|
try { |
|
u = new URL(urlStr) |
|
} catch { |
|
return null |
|
} |
|
if (!urlHostnameIsKnownImwaldWebHost(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 |
|
) |
|
}
|
|
|