diff --git a/src/Nostr/Links.ts b/src/Nostr/Links.ts new file mode 100644 index 0000000..086ddbe --- /dev/null +++ b/src/Nostr/Links.ts @@ -0,0 +1,56 @@ +import * as secp from "@noble/secp256k1"; +import { bech32 } from "bech32"; + +export enum NostrPrefix { + PublicKey = "npub", + PrivateKey = "nsec", + Note = "note", + + // TLV prefixes + Profile = "nprofile", + Event = "nevent", + Relay = "nrelay" +} + +export interface TLVEntry { + type: number, + length: number, + value: string // hex encoded data +} + +export function encodeTLV(hex: string, prefix: NostrPrefix, relays?: string[]) { + if (typeof hex !== "string" || hex.length === 0 || hex.length % 2 !== 0) { + return ""; + } + + let enc = new TextEncoder(); + let buf = secp.utils.hexToBytes(hex); + + let tl0 = [0, buf.length, ...buf]; + let tl1 = relays?.map(a => { + let data = enc.encode(a); + return [1, data.length, ...data]; + }).flat() ?? []; + + return bech32.encode(prefix, bech32.toWords([...tl0, ...tl1])); +} + +export function decodeTLV(str: string) { + let decoded = bech32.decode(str); + let data = bech32.fromWords(decoded.words); + + let entries: TLVEntry[] = []; + let x = 0; + while (x < data.length) { + let t = data[x]; + let l = data[x + 1]; + let v = data.slice(x + 2, x + 2 + l); + entries.push({ + type: t, + length: l, + value: secp.utils.bytesToHex(new Uint8Array(v)) + }); + x += 2 + l; + } + return entries; +} \ No newline at end of file diff --git a/src/Pages/Layout.tsx b/src/Pages/Layout.tsx index 606f6e0..9fa85ae 100644 --- a/src/Pages/Layout.tsx +++ b/src/Pages/Layout.tsx @@ -65,9 +65,9 @@ export default function Layout() { () => publicKey ? totalUnread( - dms.filter(a => !isMuted(a.pubkey)), - publicKey - ) + dms.filter(a => !isMuted(a.pubkey)), + publicKey + ) : 0, [dms, publicKey] ); @@ -138,6 +138,15 @@ export default function Layout() { console.debug(`Using db: ${dbType}`); dispatch(init(dbType)); + + try { + if ("registerProtocolHandler" in window.navigator) { + window.navigator.registerProtocolHandler("web+nostr", `${window.location.protocol}//${window.location.host}/handler/%s`); + console.info("Registered protocol handler for \"nostr\""); + } + } catch (e) { + console.error("Failed to register protocol handler", e); + } }); }, []); diff --git a/src/Pages/NostrLinkHandler.tsx b/src/Pages/NostrLinkHandler.tsx new file mode 100644 index 0000000..e53f0fd --- /dev/null +++ b/src/Pages/NostrLinkHandler.tsx @@ -0,0 +1,26 @@ +import { NostrPrefix } from "Nostr/Links"; +import { useEffect } from "react"; +import { redirect, useNavigate, useParams } from "react-router-dom"; + +export default function NostrLinkHandler() { + const params = useParams(); + const navigate = useNavigate(); + const link = decodeURIComponent(params["*"] ?? ""); + + useEffect(() => { + if (link.length > 0) { + let ls = link.split(":"); + let entity = ls[1]; + if (entity.startsWith(NostrPrefix.PublicKey) || entity.startsWith(NostrPrefix.Profile)) { + navigate(`/p/${entity}`); + } + else if (entity.startsWith(NostrPrefix.Event) || entity.startsWith(NostrPrefix.Note)) { + navigate(`/e/${entity}`) + } + } + }, [link]); + + return ( + <>Could not handle {link} + ) +} \ No newline at end of file diff --git a/src/Pages/ProfilePage.tsx b/src/Pages/ProfilePage.tsx index d1daea1..81dc839 100644 --- a/src/Pages/ProfilePage.tsx +++ b/src/Pages/ProfilePage.tsx @@ -42,7 +42,7 @@ import QrCode from "Element/QrCode"; import Modal from "Element/Modal"; import { ProxyImg } from "Element/ProxyImg"; import useHorizontalScroll from "Hooks/useHorizontalScroll"; - +import { NostrPrefix } from "Nostr/Links"; import messages from "./messages"; const NOTES = 0; @@ -262,7 +262,7 @@ export default function ProfilePage() { {showProfileQr && ( setShowProfileQr(false)}> - + )} {isMe ? ( @@ -280,7 +280,7 @@ export default function ProfilePage() { )} {!loggedOut && ( <> - navigate(`/messages/${hexToBech32("npub", id)}`)}> + navigate(`/messages/${hexToBech32(NostrPrefix.PublicKey, id)}`)}> diff --git a/src/Util.ts b/src/Util.ts index 2c7685d..bec605f 100644 --- a/src/Util.ts +++ b/src/Util.ts @@ -3,6 +3,7 @@ import { sha256 as hash } from "@noble/hashes/sha256"; import { bech32 } from "bech32"; import { HexKey, TaggedRawEvent, u256 } from "Nostr"; import EventKind from "Nostr/EventKind"; +import { encodeTLV, NostrPrefix } from "Nostr/Links"; export const sha256 = (str: string) => { return secp.utils.bytesToHex(hash(str)); @@ -64,7 +65,7 @@ export function bech32ToText(str: string) { * @returns */ export function eventLink(hex: u256) { - return `/e/${hexToBech32("note", hex)}`; + return `/e/${hexToBech32(NostrPrefix.Note, hex)}`; } /** @@ -77,8 +78,12 @@ export function hexToBech32(hrp: string, hex?: string) { } try { - const buf = secp.utils.hexToBytes(hex); - return bech32.encode(hrp, bech32.toWords(buf)); + if (hrp === NostrPrefix.Note || hrp === NostrPrefix.PrivateKey || hrp === NostrPrefix.PublicKey) { + let buf = secp.utils.hexToBytes(hex); + return bech32.encode(hrp, bech32.toWords(buf)); + } else { + return encodeTLV(hex, hrp as NostrPrefix); + } } catch (e) { console.warn("Invalid hex", hex, e); return ""; @@ -91,7 +96,7 @@ export function hexToBech32(hrp: string, hex?: string) { * @returns */ export function profileLink(hex: HexKey) { - return `/p/${hexToBech32("npub", hex)}`; + return `/p/${hexToBech32(NostrPrefix.PublicKey, hex)}`; } /** diff --git a/src/index.tsx b/src/index.tsx index d6b4a7c..bef709a 100644 --- a/src/index.tsx +++ b/src/index.tsx @@ -25,6 +25,7 @@ import HashTagsPage from "Pages/HashTagsPage"; import SearchPage from "Pages/SearchPage"; import HelpPage from "Pages/HelpPage"; import { NewUserRoutes } from "Pages/new"; +import NostrLinkHandler from 'Pages/NostrLinkHandler'; import { IntlProvider } from "./IntlProvider"; import { unwrap } from "Util"; @@ -93,6 +94,10 @@ export const router = createBrowserRouter([ path: "/search/:keyword?", element: , }, + { + path: "/handler/*", + element: + }, ...NewUserRoutes, ], },