From 9b6e5090dc24093cd1d561b6ed286b045c92dd4c Mon Sep 17 00:00:00 2001 From: Kieran Date: Sat, 25 Mar 2023 22:55:34 +0000 Subject: [PATCH] feat: nip19/21 links --- packages/app/src/Element/Copy.tsx | 5 +- packages/app/src/Element/HyperText.tsx | 3 + packages/app/src/Element/Mention.tsx | 18 +-- packages/app/src/Element/NostrLink.tsx | 27 ++++ packages/app/src/Element/Note.tsx | 29 ++-- packages/app/src/Element/NoteFooter.tsx | 10 +- packages/app/src/Element/NoteReaction.tsx | 10 +- packages/app/src/Element/ProfileImage.tsx | 4 +- packages/app/src/Element/Text.tsx | 10 +- packages/app/src/Element/Textarea.tsx | 3 +- packages/app/src/Element/Thread.tsx | 4 +- packages/app/src/Feed/ThreadFeed.ts | 10 +- packages/app/src/Pages/EventPage.tsx | 13 +- packages/app/src/Pages/Login.tsx | 3 +- packages/app/src/Pages/NostrLinkHandler.tsx | 41 +++--- packages/app/src/Pages/ProfilePage.tsx | 31 +++-- packages/app/src/Pages/new/NewUserFlow.tsx | 3 +- packages/app/src/Util.test.ts | 15 +++ packages/app/src/Util.ts | 138 +++++++++++++------- packages/app/src/nip6.ts | 39 ++++++ packages/nostr/src/legacy/Links.ts | 3 +- 21 files changed, 282 insertions(+), 137 deletions(-) create mode 100644 packages/app/src/Element/NostrLink.tsx create mode 100644 packages/app/src/nip6.ts diff --git a/packages/app/src/Element/Copy.tsx b/packages/app/src/Element/Copy.tsx index 7d05ba66..837705a4 100644 --- a/packages/app/src/Element/Copy.tsx +++ b/packages/app/src/Element/Copy.tsx @@ -5,14 +5,15 @@ import { useCopy } from "useCopy"; export interface CopyProps { text: string; maxSize?: number; + className?: string; } -export default function Copy({ text, maxSize = 32 }: CopyProps) { +export default function Copy({ text, maxSize = 32, className }: CopyProps) { const { copy, copied } = useCopy(); const sliceLength = maxSize / 2; const trimmed = text.length > maxSize ? `${text.slice(0, sliceLength)}...${text.slice(-sliceLength)}` : text; return ( -
copy(text)}> +
copy(text)}> {trimmed} {copied ? : } diff --git a/packages/app/src/Element/HyperText.tsx b/packages/app/src/Element/HyperText.tsx index 165fdaa2..05a16ecd 100644 --- a/packages/app/src/Element/HyperText.tsx +++ b/packages/app/src/Element/HyperText.tsx @@ -27,6 +27,7 @@ import TwitchEmbed from "Element/TwitchEmbed"; import AppleMusicEmbed from "Element/AppleMusicEmbed"; import NostrNestsEmbed from "Element/NostrNestsEmbed"; import WavlakeEmbed from "Element/WavlakeEmbed"; +import NostrLink from "Element/NostrLink"; export default function HyperText({ link, creator }: { link: string; creator: HexKey }) { const pref = useSelector((s: RootState) => s.login.preferences); @@ -149,6 +150,8 @@ export default function HyperText({ link, creator }: { link: string; creator: He ]; } else if (isWavlakeLink) { return ; + } else if (url.protocol === "nostr:" || url.protocol === "web+nostr:") { + return ; } else { return ( e.stopPropagation()} target="_blank" rel="noreferrer" className="ext"> diff --git a/packages/app/src/Element/Mention.tsx b/packages/app/src/Element/Mention.tsx index be3d153e..add8c869 100644 --- a/packages/app/src/Element/Mention.tsx +++ b/packages/app/src/Element/Mention.tsx @@ -1,24 +1,20 @@ import { useMemo } from "react"; import { Link } from "react-router-dom"; -import { useUserProfile } from "Hooks/useUserProfile"; import { HexKey } from "@snort/nostr"; -import { hexToBech32, profileLink } from "Util"; -export default function Mention({ pubkey }: { pubkey: HexKey }) { +import { useUserProfile } from "Hooks/useUserProfile"; +import { profileLink } from "Util"; +import { getDisplayName } from "Element/ProfileImage"; + +export default function Mention({ pubkey, relays }: { pubkey: HexKey; relays?: Array | string }) { const user = useUserProfile(pubkey); const name = useMemo(() => { - let name = hexToBech32("npub", pubkey).substring(0, 12); - if (user?.display_name !== undefined && user.display_name.length > 0) { - name = user.display_name; - } else if (user?.name !== undefined && user.name.length > 0) { - name = user.name; - } - return name; + return getDisplayName(user, pubkey); }, [user, pubkey]); return ( - e.stopPropagation()}> + e.stopPropagation()}> @{name} ); diff --git a/packages/app/src/Element/NostrLink.tsx b/packages/app/src/Element/NostrLink.tsx new file mode 100644 index 00000000..69d0499b --- /dev/null +++ b/packages/app/src/Element/NostrLink.tsx @@ -0,0 +1,27 @@ +import { encodeTLV, NostrPrefix } from "@snort/nostr"; +import { Link } from "react-router-dom"; + +import Mention from "Element/Mention"; +import { parseNostrLink } from "Util"; + +export default function NostrLink({ link }: { link: string }) { + const nav = parseNostrLink(link); + + if (nav?.type === NostrPrefix.PublicKey || nav?.type === NostrPrefix.Profile) { + return ; + } else if (nav?.type === NostrPrefix.Note || nav?.type === NostrPrefix.Event) { + // translate all "event" links to nevent + const evLink = encodeTLV(nav.id, NostrPrefix.Event, nav.relays); + return ( + e.stopPropagation()} state={{ from: location.pathname }}> + #{evLink.substring(0, 12)} + + ); + } else { + return ( + e.stopPropagation()} target="_blank" rel="noreferrer" className="ext"> + {link} + + ); + } +} diff --git a/packages/app/src/Element/Note.tsx b/packages/app/src/Element/Note.tsx index 815573d1..1776e7ec 100644 --- a/packages/app/src/Element/Note.tsx +++ b/packages/app/src/Element/Note.tsx @@ -19,10 +19,11 @@ import { normalizeReaction, Reaction, profileLink, + unwrap, } from "Util"; import NoteFooter, { Translation } from "Element/NoteFooter"; import NoteTime from "Element/NoteTime"; -import { TaggedRawEvent, u256, HexKey, Event as NEvent, EventKind } from "@snort/nostr"; +import { TaggedRawEvent, HexKey, Event as NEvent, EventKind, NostrPrefix } from "@snort/nostr"; import useModeration from "Hooks/useModeration"; import { setPinned, setBookmarked } from "State/Login"; import type { RootState } from "State/Store"; @@ -173,17 +174,22 @@ export default function Note(props: NoteProps) { } }, [inView, entry, extendable]); - function goToEvent(e: React.MouseEvent, id: u256, isTargetAllowed: boolean = e.target === e.currentTarget) { + function goToEvent( + e: React.MouseEvent, + eTarget: TaggedRawEvent, + isTargetAllowed: boolean = e.target === e.currentTarget + ) { if (!isTargetAllowed) { return; } e.stopPropagation(); + const link = eventLink(eTarget.id, eTarget.relays); // detect cmd key and open in new tab if (e.metaKey) { - window.open(eventLink(id), "_blank"); + window.open(link, "_blank"); } else { - navigate(eventLink(id)); + navigate(link); } } @@ -194,10 +200,11 @@ export default function Note(props: NoteProps) { const maxMentions = 2; const replyId = ev.Thread?.ReplyTo?.Event ?? ev.Thread?.Root?.Event; + const replyRelayHints = ev?.Thread?.ReplyTo?.Relay ?? ev.Thread.Root?.Relay; const mentions: { pk: string; name: string; link: ReactNode }[] = []; for (const pk of ev.Thread?.PubKeys ?? []) { const u = UserCache.get(pk); - const npub = hexToBech32("npub", pk); + const npub = hexToBech32(NostrPrefix.PublicKey, pk); const shortNpub = npub.substring(0, 12); mentions.push({ pk, @@ -205,7 +212,7 @@ export default function Note(props: NoteProps) { link: {u?.name ? `@${u.name}` : shortNpub}, }); } - mentions.sort(a => (a.name.startsWith("npub") ? 1 : -1)); + mentions.sort(a => (a.name.startsWith(NostrPrefix.PublicKey) ? 1 : -1)); const othersLength = mentions.length - maxMentions; const renderMention = (m: { link: React.ReactNode; pk: string; name: string }, idx: number) => { return ( @@ -226,7 +233,11 @@ export default function Note(props: NoteProps) { {pubMentions} {others} ) : ( - replyId && {hexToBech32("note", replyId)?.substring(0, 12)} + replyId && ( + + {hexToBech32(NostrPrefix.Event, replyId)?.substring(0, 12)} + + ) )}
); @@ -286,7 +297,7 @@ export default function Note(props: NoteProps) { )}
)} -
goToEvent(e, ev.Id, true)}> +
goToEvent(e, unwrap(ev.Original), true)}> {transformBody()} {translation()} {options.showReactionsLink && ( @@ -319,7 +330,7 @@ export default function Note(props: NoteProps) { const note = (
goToEvent(e, ev.Id)} + onClick={e => goToEvent(e, unwrap(ev.Original))} ref={ref}> {content()}
diff --git a/packages/app/src/Element/NoteFooter.tsx b/packages/app/src/Element/NoteFooter.tsx index 8bc36ae2..b1256f37 100644 --- a/packages/app/src/Element/NoteFooter.tsx +++ b/packages/app/src/Element/NoteFooter.tsx @@ -3,14 +3,14 @@ import { useSelector, useDispatch } from "react-redux"; import { useIntl, FormattedMessage } from "react-intl"; import { Menu, MenuItem } from "@szhsin/react-menu"; import { useLongPress } from "use-long-press"; -import { Event as NEvent, TaggedRawEvent, HexKey, u256 } from "@snort/nostr"; +import { Event as NEvent, TaggedRawEvent, HexKey, u256, encodeTLV, NostrPrefix } from "@snort/nostr"; import Icon from "Icons/Icon"; import Spinner from "Icons/Spinner"; import { formatShort } from "Number"; import useEventPublisher from "Feed/EventPublisher"; -import { bech32ToHex, delay, hexToBech32, normalizeReaction, unwrap } from "Util"; +import { bech32ToHex, delay, normalizeReaction, unwrap } from "Util"; import { NoteCreator } from "Element/NoteCreator"; import Reactions from "Element/Reactions"; import SendSats from "Element/SendSats"; @@ -263,7 +263,8 @@ export default function NoteFooter(props: NoteFooterProps) { } async function share() { - const url = `${window.location.protocol}//${window.location.host}/e/${hexToBech32("note", ev.Id)}`; + const link = encodeTLV(ev.Id, NostrPrefix.Event, ev.Original?.relays); + const url = `${window.location.protocol}//${window.location.host}/e/${link}`; if ("share" in window.navigator) { await window.navigator.share({ title: "Snort", @@ -298,7 +299,8 @@ export default function NoteFooter(props: NoteFooterProps) { } async function copyId() { - await navigator.clipboard.writeText(hexToBech32("note", ev.Id)); + const link = encodeTLV(ev.Id, NostrPrefix.Event, ev.Original?.relays); + await navigator.clipboard.writeText(link); } async function pin(id: HexKey) { diff --git a/packages/app/src/Element/NoteReaction.tsx b/packages/app/src/Element/NoteReaction.tsx index d4949783..a31ca7a3 100644 --- a/packages/app/src/Element/NoteReaction.tsx +++ b/packages/app/src/Element/NoteReaction.tsx @@ -2,7 +2,7 @@ import "./NoteReaction.css"; import { Link } from "react-router-dom"; import { useMemo } from "react"; -import { EventKind, Event as NEvent } from "@snort/nostr"; +import { EventKind, Event as NEvent, NostrPrefix } from "@snort/nostr"; import Note from "Element/Note"; import ProfileImage from "Element/ProfileImage"; import { eventLink, hexToBech32 } from "Util"; @@ -24,7 +24,7 @@ export default function NoteReaction(props: NoteReactionProps) { if (ev) { const eTags = ev.Tags.filter(a => a.Key === "e"); if (eTags.length > 0) { - return eTags[0].Event; + return eTags[0]; } } return null; @@ -34,7 +34,7 @@ export default function NoteReaction(props: NoteReactionProps) { ev.Kind !== EventKind.Reaction && ev.Kind !== EventKind.Repost && (ev.Kind !== EventKind.TextNote || - ev.Tags.every((a, i) => a.Event !== refEvent || a.Marker !== "mention" || ev.Content !== `#[${i}]`)) + ev.Tags.every((a, i) => a.Event !== refEvent?.Event || a.Marker !== "mention" || ev.Content !== `#[${i}]`)) ) { return null; } @@ -73,7 +73,9 @@ export default function NoteReaction(props: NoteReactionProps) { {root ? : null} {!root && refEvent ? (

- #{hexToBech32("note", refEvent).substring(0, 12)} + + #{hexToBech32(NostrPrefix.Event, refEvent.Event).substring(0, 12)} +

) : null}
diff --git a/packages/app/src/Element/ProfileImage.tsx b/packages/app/src/Element/ProfileImage.tsx index b382db19..55661053 100644 --- a/packages/app/src/Element/ProfileImage.tsx +++ b/packages/app/src/Element/ProfileImage.tsx @@ -6,7 +6,7 @@ import { useUserProfile } from "Hooks/useUserProfile"; import { hexToBech32, profileLink } from "Util"; import Avatar from "Element/Avatar"; import Nip05 from "Element/Nip05"; -import { HexKey } from "@snort/nostr"; +import { HexKey, NostrPrefix } from "@snort/nostr"; import { MetadataCache } from "State/Users"; import usePageWidth from "Hooks/usePageWidth"; @@ -77,7 +77,7 @@ export default function ProfileImage({ } export function getDisplayName(user: MetadataCache | undefined, pubkey: HexKey) { - let name = hexToBech32("npub", pubkey).substring(0, 12); + let name = hexToBech32(NostrPrefix.PublicKey, pubkey).substring(0, 12); if (user?.display_name !== undefined && user.display_name.length > 0) { name = user.display_name; } else if (user?.name !== undefined && user.name.length > 0) { diff --git a/packages/app/src/Element/Text.tsx b/packages/app/src/Element/Text.tsx index 0e13507c..077546db 100644 --- a/packages/app/src/Element/Text.tsx +++ b/packages/app/src/Element/Text.tsx @@ -4,7 +4,7 @@ import { Link, useLocation } from "react-router-dom"; import ReactMarkdown from "react-markdown"; import { visit, SKIP } from "unist-util-visit"; import * as unist from "unist"; -import { HexKey, Tag } from "@snort/nostr"; +import { HexKey, NostrPrefix, Tag } from "@snort/nostr"; import { MentionRegex, InvoiceRegex, HashtagRegex, MagnetRegex } from "Const"; import { eventLink, hexToBech32, magnetURIDecode, splitByUrl, unwrap } from "Util"; @@ -35,7 +35,7 @@ export default function Text({ content, tags, creator }: TextProps) { .map(f => { if (typeof f === "string") { return splitByUrl(f).map(a => { - if (a.match(/^https?:\/\//)) { + if (a.match(/^(?:https?|(?:web\+)?nostr):/i)) { return ; } return a; @@ -77,13 +77,13 @@ export default function Text({ content, tags, creator }: TextProps) { if (ref) { switch (ref.Key) { case "p": { - return ; + return ; } case "e": { - const eText = hexToBech32("note", ref.Event).substring(0, 12); + const eText = hexToBech32(NostrPrefix.Event, ref.Event).substring(0, 12); return ref.Event ? ( e.stopPropagation()} state={{ from: location.pathname }}> #{eText} diff --git a/packages/app/src/Element/Textarea.tsx b/packages/app/src/Element/Textarea.tsx index 60ba2a3b..677c73d0 100644 --- a/packages/app/src/Element/Textarea.tsx +++ b/packages/app/src/Element/Textarea.tsx @@ -5,6 +5,7 @@ import { useIntl } from "react-intl"; import ReactTextareaAutocomplete from "@webscopeio/react-textarea-autocomplete"; import emoji from "@jukben/emoji-search"; import TextareaAutosize from "react-textarea-autosize"; +import { NostrPrefix } from "@snort/nostr"; import Avatar from "Element/Avatar"; import Nip05 from "Element/Nip05"; @@ -82,7 +83,7 @@ const Textarea = (props: TextareaProps) => { afterWhitespace: true, dataProvider: userDataProvider, component: (props: { entity: MetadataCache }) => , - output: (item: { pubkey: string }) => `@${hexToBech32("npub", item.pubkey)}`, + output: (item: { pubkey: string }) => `@${hexToBech32(NostrPrefix.PublicKey, item.pubkey)}`, }, }} /> diff --git a/packages/app/src/Element/Thread.tsx b/packages/app/src/Element/Thread.tsx index d1ba5895..7a2ca558 100644 --- a/packages/app/src/Element/Thread.tsx +++ b/packages/app/src/Element/Thread.tsx @@ -255,14 +255,14 @@ const TierThree = ({ active, path, isLastSubthread, from, notes, related, chains export interface ThreadProps { notes?: TaggedRawEvent[]; + selected?: u256; } export default function Thread(props: ThreadProps) { const notes = props.notes ?? []; const parsedNotes = notes.map(a => new NEvent(a)); const [path, setPath] = useState([]); - const currentId = path.length > 0 && path[path.length - 1]; - const currentRoot = useMemo(() => parsedNotes.find(a => a.Id === currentId), [notes, currentId]); + const currentRoot = useMemo(() => parsedNotes.find(a => a.Id === props.selected), [notes, props.selected]); const [navigated, setNavigated] = useState(false); const navigate = useNavigate(); const isSingleNote = parsedNotes.filter(a => a.Kind === EventKind.TextNote).length === 1; diff --git a/packages/app/src/Feed/ThreadFeed.ts b/packages/app/src/Feed/ThreadFeed.ts index 73a6922b..9e9d05f5 100644 --- a/packages/app/src/Feed/ThreadFeed.ts +++ b/packages/app/src/Feed/ThreadFeed.ts @@ -5,10 +5,10 @@ import useSubscription from "Feed/Subscription"; import { useSelector } from "react-redux"; import { RootState } from "State/Store"; import { UserPreferences } from "State/Login"; -import { debounce } from "Util"; +import { debounce, NostrLink } from "Util"; -export default function useThreadFeed(id: u256) { - const [trackingEvents, setTrackingEvent] = useState([id]); +export default function useThreadFeed(link: NostrLink) { + const [trackingEvents, setTrackingEvent] = useState([link.id]); const pref = useSelector(s => s.login.preferences); function addId(id: u256[]) { @@ -25,7 +25,7 @@ export default function useThreadFeed(id: u256) { const sub = useMemo(() => { const thisSub = new Subscriptions(); - thisSub.Id = `thread:${id.substring(0, 8)}`; + thisSub.Id = `thread:${link.id.substring(0, 8)}`; thisSub.Ids = new Set(trackingEvents); // get replies to this event @@ -39,7 +39,7 @@ export default function useThreadFeed(id: u256) { thisSub.AddSubscription(subRelated); return thisSub; - }, [trackingEvents, pref, id]); + }, [trackingEvents, pref, link.id]); const main = useSubscription(sub, { leaveOpen: true, cache: true }); diff --git a/packages/app/src/Pages/EventPage.tsx b/packages/app/src/Pages/EventPage.tsx index 753a41ee..b0d72430 100644 --- a/packages/app/src/Pages/EventPage.tsx +++ b/packages/app/src/Pages/EventPage.tsx @@ -1,12 +1,17 @@ import { useParams } from "react-router-dom"; + import Thread from "Element/Thread"; import useThreadFeed from "Feed/ThreadFeed"; -import { parseId } from "Util"; +import { parseNostrLink, unwrap } from "Util"; export default function EventPage() { const params = useParams(); - const id = parseId(params.id ?? ""); - const thread = useThreadFeed(id); + const link = parseNostrLink(params.id ?? ""); + const thread = useThreadFeed(unwrap(link)); - return ; + if (link) { + return ; + } else { + return {params.id}; + } } diff --git a/packages/app/src/Pages/Login.tsx b/packages/app/src/Pages/Login.tsx index f27d37cc..1ca1b63b 100644 --- a/packages/app/src/Pages/Login.tsx +++ b/packages/app/src/Pages/Login.tsx @@ -10,7 +10,8 @@ import { HexKey } from "@snort/nostr"; import { RootState } from "State/Store"; import { setPrivateKey, setPublicKey, setRelays, setGeneratedPrivateKey } from "State/Login"; import { DefaultRelays, EmailRegex, MnemonicRegex } from "Const"; -import { bech32ToHex, generateBip39Entropy, entropyToDerivedKey, unwrap } from "Util"; +import { bech32ToHex, unwrap } from "Util"; +import { generateBip39Entropy, entropyToDerivedKey } from "nip6"; import ZapButton from "Element/ZapButton"; import useImgProxy from "Hooks/useImgProxy"; diff --git a/packages/app/src/Pages/NostrLinkHandler.tsx b/packages/app/src/Pages/NostrLinkHandler.tsx index b61e5f51..b60e47cc 100644 --- a/packages/app/src/Pages/NostrLinkHandler.tsx +++ b/packages/app/src/Pages/NostrLinkHandler.tsx @@ -1,9 +1,10 @@ -import { decodeTLV, NostrPrefix, TLVEntryType } from "@snort/nostr"; +import { NostrPrefix } from "@snort/nostr"; import { useEffect } from "react"; import { useDispatch } from "react-redux"; import { useNavigate, useParams } from "react-router-dom"; + import { setRelays } from "State/Login"; -import { eventLink, profileLink } from "Util"; +import { parseNostrLink, unixNowMs, unwrap } from "Util"; export default function NostrLinkHandler() { const params = useParams(); @@ -13,29 +14,21 @@ export default function NostrLinkHandler() { useEffect(() => { if (link.length > 0) { - const entity = link.startsWith("web+nostr:") ? link.split(":")[1] : link; - if (entity.startsWith(NostrPrefix.PublicKey)) { - navigate(`/p/${entity}`); - } else if (entity.startsWith(NostrPrefix.Note)) { - navigate(`/e/${entity}`); - } else if (entity.startsWith(NostrPrefix.Profile) || entity.startsWith(NostrPrefix.Event)) { - const decoded = decodeTLV(entity); - console.debug(decoded); - - const id = decoded.find(a => a.type === TLVEntryType.Special)?.value as string; - const relays = decoded.filter(a => a.type === TLVEntryType.Relay); - if (relays.length > 0) { - const relayObj = { - relays: Object.fromEntries(relays.map(a => [a.value, { read: true, write: false }])), - createdAt: new Date().getTime(), - }; - dispatch(setRelays(relayObj)); + const nav = parseNostrLink(link); + if (nav) { + if ((nav.relays?.length ?? 0) > 0) { + // todo: add as ephemerial connection + dispatch( + setRelays({ + relays: Object.fromEntries(unwrap(nav.relays).map(a => [a, { read: true, write: false }])), + createdAt: unixNowMs(), + }) + ); } - - if (entity.startsWith(NostrPrefix.Profile)) { - navigate(profileLink(id)); - } else if (entity.startsWith(NostrPrefix.Event)) { - navigate(eventLink(id)); + if (nav.type === NostrPrefix.Event || nav.type === NostrPrefix.Note || nav.type === NostrPrefix.Address) { + navigate(`/e/${nav.encode()}`); + } else if (nav.type === NostrPrefix.PublicKey || nav.type === NostrPrefix.Profile) { + navigate(`/p/${nav.encode()}`); } } } diff --git a/packages/app/src/Pages/ProfilePage.tsx b/packages/app/src/Pages/ProfilePage.tsx index d56d4f71..d8b51118 100644 --- a/packages/app/src/Pages/ProfilePage.tsx +++ b/packages/app/src/Pages/ProfilePage.tsx @@ -3,9 +3,9 @@ import { useEffect, useState } from "react"; import { useIntl, FormattedMessage } from "react-intl"; import { useSelector } from "react-redux"; import { useNavigate, useParams } from "react-router-dom"; -import { NostrPrefix } from "@snort/nostr"; +import { encodeTLV, NostrPrefix } from "@snort/nostr"; -import { unwrap } from "Util"; +import { parseNostrLink, unwrap } from "Util"; import { formatShort } from "Number"; import Note from "Element/Note"; import Bookmarks from "Element/Bookmarks"; @@ -73,7 +73,7 @@ export default function ProfilePage() { tags: [], creator: "", }); - const npub = !id?.startsWith("npub") ? hexToBech32("npub", id || undefined) : id; + const npub = !id?.startsWith(NostrPrefix.PublicKey) ? hexToBech32(NostrPrefix.PublicKey, id || undefined) : id; const lnurl = extractLnAddress(user?.lud16 || user?.lud06 || ""); const website_url = @@ -116,7 +116,13 @@ export default function ProfilePage() { setId(a); }); } else { - setId(parseId(params.id ?? "")); + const nav = parseNostrLink(params.id ?? ""); + if (nav?.type === NostrPrefix.PublicKey || nav?.type === NostrPrefix.Profile) { + // todo: use relays if any for nprofile + setId(nav.id); + } else { + setId(parseId(params.id ?? "")); + } } setTab(ProfileTab.Notes); }, [params]); @@ -252,6 +258,10 @@ export default function ProfilePage() { } function renderIcons() { + if (!id) return; + + const firstRelay = relays.find(a => a.settings.write)?.url; + const link = encodeTLV(id, NostrPrefix.Profile, firstRelay ? [firstRelay] : undefined); return (
setShowProfileQr(true)}> @@ -259,13 +269,9 @@ export default function ProfilePage() { {showProfileQr && ( setShowProfileQr(false)}> - - - + + + )} {isMe ? ( @@ -295,12 +301,13 @@ export default function ProfilePage() { } function userDetails() { + if (!id) return; return (
{username()}
{renderIcons()} - {!isMe && } + {!isMe && }
{bio()}
diff --git a/packages/app/src/Pages/new/NewUserFlow.tsx b/packages/app/src/Pages/new/NewUserFlow.tsx index 6e5b3e80..891ea943 100644 --- a/packages/app/src/Pages/new/NewUserFlow.tsx +++ b/packages/app/src/Pages/new/NewUserFlow.tsx @@ -6,7 +6,8 @@ import Logo from "Element/Logo"; import { CollapsedSection } from "Element/Collapsed"; import Copy from "Element/Copy"; import { RootState } from "State/Store"; -import { hexToBech32, hexToMnemonic } from "Util"; +import { hexToBech32 } from "Util"; +import { hexToMnemonic } from "nip6"; import messages from "./messages"; diff --git a/packages/app/src/Util.test.ts b/packages/app/src/Util.test.ts index a70c34a3..7745f888 100644 --- a/packages/app/src/Util.test.ts +++ b/packages/app/src/Util.test.ts @@ -31,6 +31,21 @@ describe("splitByUrl", () => { expect(splitByUrl(inputStr)).toEqual(expectedOutput); }); + it("should parse nostr links", () => { + const input = + "web+nostr:npub1v0lxxxxutpvrelsksy8cdhgfux9l6a42hsj2qzquu2zk7vc9qnkszrqj49\nnostr:note1jp6d36lmquhxqn2s5n4ce00pzu2jrpkek8udav6l0y3qcdngpnxsle6ngm\nnostr:naddr1qqv8x6r0wf6x2um594cxzarg946x7ttpwajhxmmdv5pzqx78pgq53vlnzmdr8l3u38eru0n3438lnxqz0mr39wg9e5j0dfq3qvzqqqr4gu5d05rr\nnostr is cool"; + const expected = [ + "", + "web+nostr:npub1v0lxxxxutpvrelsksy8cdhgfux9l6a42hsj2qzquu2zk7vc9qnkszrqj49", + "\n", + "nostr:note1jp6d36lmquhxqn2s5n4ce00pzu2jrpkek8udav6l0y3qcdngpnxsle6ngm", + "\n", + "nostr:naddr1qqv8x6r0wf6x2um594cxzarg946x7ttpwajhxmmdv5pzqx78pgq53vlnzmdr8l3u38eru0n3438lnxqz0mr39wg9e5j0dfq3qvzqqqr4gu5d05rr", + "\nnostr is cool", + ]; + expect(splitByUrl(input)).toEqual(expected); + }); + it("should return an array with a single string if no URLs are found", () => { const inputStr = "This is a regular string with no URLs"; const expectedOutput = ["This is a regular string with no URLs"]; diff --git a/packages/app/src/Util.ts b/packages/app/src/Util.ts index ab493195..53940f38 100644 --- a/packages/app/src/Util.ts +++ b/packages/app/src/Util.ts @@ -5,12 +5,7 @@ import { bytesToHex } from "@noble/hashes/utils"; import { decode as invoiceDecode } from "light-bolt11-decoder"; import { bech32 } from "bech32"; import base32Decode from "base32-decode"; -import { HexKey, TaggedRawEvent, u256, EventKind, encodeTLV, NostrPrefix } from "@snort/nostr"; -import * as bip39 from "@scure/bip39"; -import { wordlist } from "@scure/bip39/wordlists/english"; -import { HDKey } from "@scure/bip32"; - -import { DerivationPath } from "Const"; +import { HexKey, TaggedRawEvent, u256, EventKind, encodeTLV, NostrPrefix, decodeTLV, TLVEntryType } from "@snort/nostr"; import { MetadataCache } from "State/Users"; export const sha256 = (str: string) => { @@ -80,8 +75,21 @@ export function bech32ToText(str: string) { * @param hex * @returns */ -export function eventLink(hex: u256) { - return `/e/${hexToBech32(NostrPrefix.Note, hex)}`; +export function eventLink(hex: u256, relays?: Array | string) { + const encoded = relays + ? encodeTLV(hex, NostrPrefix.Event, Array.isArray(relays) ? relays : [relays]) + : hexToBech32(NostrPrefix.Note, hex); + return `/e/${encoded}`; +} + +/** + * Convert hex pubkey to bech32 link url + */ +export function profileLink(hex: HexKey, relays?: Array | string) { + const encoded = relays + ? encodeTLV(hex, NostrPrefix.Profile, Array.isArray(relays) ? relays : [relays]) + : hexToBech32(NostrPrefix.PublicKey, hex); + return `/p/${encoded}`; } /** @@ -105,46 +113,6 @@ export function hexToBech32(hrp: string, hex?: string) { } } -export function generateBip39Entropy(mnemonic?: string): Uint8Array { - try { - const mn = mnemonic ?? bip39.generateMnemonic(wordlist, 256); - return bip39.mnemonicToEntropy(mn, wordlist); - } catch (e) { - throw new Error("INVALID MNEMONIC PHRASE"); - } -} - -/** - * Convert hex-encoded entropy into mnemonic phrase - */ -export function hexToMnemonic(hex: string): string { - const bytes = secp.utils.hexToBytes(hex); - return bip39.entropyToMnemonic(bytes, wordlist); -} - -/** - * Convert mnemonic phrase into hex-encoded private key - * using the derivation path specified in NIP06 - * @param mnemonic the mnemonic-encoded entropy - */ -export function entropyToDerivedKey(entropy: Uint8Array): string { - const masterKey = HDKey.fromMasterSeed(entropy); - const newKey = masterKey.derive(DerivationPath); - - if (!newKey.privateKey) { - throw new Error("INVALID KEY DERIVATION"); - } - - return secp.utils.bytesToHex(newKey.privateKey); -} - -/** - * Convert hex pubkey to bech32 link url - */ -export function profileLink(hex: HexKey) { - return `/p/${hexToBech32(NostrPrefix.PublicKey, hex)}`; -} - /** * Reaction types */ @@ -275,7 +243,7 @@ export function groupByPubkey(acc: Record, user: Metadata export function splitByUrl(str: string) { const urlRegex = - /((?:http|ftp|https):\/\/(?:[\w+?.\w+])+(?:[a-zA-Z0-9~!@#$%^&*()_\-=+\\/?.:;',]*)?(?:[-A-Za-z0-9+&@#/%=~_|]))/i; + /((?:http|ftp|https|nostr|web\+nostr):\/?\/?(?:[\w+?.\w+])+(?:[a-zA-Z0-9~!@#$%^&*()_\-=+\\/?.:;',]*)?(?:[-A-Za-z0-9+&@#/%=~_|]))/i; return str.split(urlRegex); } @@ -467,3 +435,75 @@ export function getRelayName(url: string) { const parsedUrl = new URL(url); return parsedUrl.host + parsedUrl.search; } + +export interface NostrLink { + type: NostrPrefix; + id: string; + kind?: number; + author?: string; + relays?: Array; + encode(): string; +} + +export function parseNostrLink(link: string): NostrLink | undefined { + const entity = link.startsWith("web+nostr:") || link.startsWith("nostr:") ? link.split(":")[1] : link; + + if (entity.startsWith(NostrPrefix.PublicKey)) { + const id = bech32ToHex(entity); + return { + type: NostrPrefix.PublicKey, + id: id, + encode: () => hexToBech32(NostrPrefix.PublicKey, id), + }; + } else if (entity.startsWith(NostrPrefix.Note)) { + const id = bech32ToHex(entity); + return { + type: NostrPrefix.Note, + id: id, + encode: () => hexToBech32(NostrPrefix.Note, id), + }; + } else if ( + entity.startsWith(NostrPrefix.Profile) || + entity.startsWith(NostrPrefix.Event) || + entity.startsWith(NostrPrefix.Address) + ) { + const decoded = decodeTLV(entity); + + const id = decoded.find(a => a.type === TLVEntryType.Special)?.value as string; + const relays = decoded.filter(a => a.type === TLVEntryType.Relay).map(a => a.value as string); + const author = decoded.find(a => a.type === TLVEntryType.Author)?.value as string; + const kind = decoded.find(a => a.type === TLVEntryType.Kind)?.value as number; + + const encode = () => { + return entity; // return original + }; + if (entity.startsWith(NostrPrefix.Profile)) { + return { + type: NostrPrefix.Profile, + id, + relays, + kind, + author, + encode, + }; + } else if (entity.startsWith(NostrPrefix.Event)) { + return { + type: NostrPrefix.Event, + id, + relays, + kind, + author, + encode, + }; + } else if (entity.startsWith(NostrPrefix.Address)) { + return { + type: NostrPrefix.Address, + id, + relays, + kind, + author, + encode, + }; + } + } +} diff --git a/packages/app/src/nip6.ts b/packages/app/src/nip6.ts new file mode 100644 index 00000000..89639c4c --- /dev/null +++ b/packages/app/src/nip6.ts @@ -0,0 +1,39 @@ +import * as secp from "@noble/secp256k1"; +import * as bip39 from "@scure/bip39"; +import { wordlist } from "@scure/bip39/wordlists/english"; +import { HDKey } from "@scure/bip32"; + +import { DerivationPath } from "Const"; + +export function generateBip39Entropy(mnemonic?: string): Uint8Array { + try { + const mn = mnemonic ?? bip39.generateMnemonic(wordlist, 256); + return bip39.mnemonicToEntropy(mn, wordlist); + } catch (e) { + throw new Error("INVALID MNEMONIC PHRASE"); + } +} + +/** + * Convert hex-encoded entropy into mnemonic phrase + */ +export function hexToMnemonic(hex: string): string { + const bytes = secp.utils.hexToBytes(hex); + return bip39.entropyToMnemonic(bytes, wordlist); +} + +/** + * Convert mnemonic phrase into hex-encoded private key + * using the derivation path specified in NIP06 + * @param mnemonic the mnemonic-encoded entropy + */ +export function entropyToDerivedKey(entropy: Uint8Array): string { + const masterKey = HDKey.fromMasterSeed(entropy); + const newKey = masterKey.derive(DerivationPath); + + if (!newKey.privateKey) { + throw new Error("INVALID KEY DERIVATION"); + } + + return secp.utils.bytesToHex(newKey.privateKey); +} diff --git a/packages/nostr/src/legacy/Links.ts b/packages/nostr/src/legacy/Links.ts index 4b5a0d94..3b315ba0 100644 --- a/packages/nostr/src/legacy/Links.ts +++ b/packages/nostr/src/legacy/Links.ts @@ -11,6 +11,7 @@ export enum NostrPrefix { Profile = "nprofile", Event = "nevent", Relay = "nrelay", + Address = "naddr", } export enum TLVEntryType { @@ -43,7 +44,7 @@ export function encodeTLV(hex: string, prefix: NostrPrefix, relays?: string[]) { }) .flat() ?? []; - return bech32.encode(prefix, bech32.toWords([...tl0, ...tl1])); + return bech32.encode(prefix, bech32.toWords([...tl0, ...tl1]), 1_000); } export function decodeTLV(str: string) {