feat: nip19/21 links
This commit is contained in:
parent
12f82372e5
commit
9b6e5090dc
@ -5,14 +5,15 @@ import { useCopy } from "useCopy";
|
|||||||
export interface CopyProps {
|
export interface CopyProps {
|
||||||
text: string;
|
text: string;
|
||||||
maxSize?: number;
|
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 { copy, copied } = useCopy();
|
||||||
const sliceLength = maxSize / 2;
|
const sliceLength = maxSize / 2;
|
||||||
const trimmed = text.length > maxSize ? `${text.slice(0, sliceLength)}...${text.slice(-sliceLength)}` : text;
|
const trimmed = text.length > maxSize ? `${text.slice(0, sliceLength)}...${text.slice(-sliceLength)}` : text;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-row copy" onClick={() => copy(text)}>
|
<div className={`flex flex-row copy ${className}`} onClick={() => copy(text)}>
|
||||||
<span className="body">{trimmed}</span>
|
<span className="body">{trimmed}</span>
|
||||||
<span className="icon" style={{ color: copied ? "var(--success)" : "var(--highlight)" }}>
|
<span className="icon" style={{ color: copied ? "var(--success)" : "var(--highlight)" }}>
|
||||||
{copied ? <Icon name="check" size={14} /> : <Icon name="copy" size={14} />}
|
{copied ? <Icon name="check" size={14} /> : <Icon name="copy" size={14} />}
|
||||||
|
@ -27,6 +27,7 @@ import TwitchEmbed from "Element/TwitchEmbed";
|
|||||||
import AppleMusicEmbed from "Element/AppleMusicEmbed";
|
import AppleMusicEmbed from "Element/AppleMusicEmbed";
|
||||||
import NostrNestsEmbed from "Element/NostrNestsEmbed";
|
import NostrNestsEmbed from "Element/NostrNestsEmbed";
|
||||||
import WavlakeEmbed from "Element/WavlakeEmbed";
|
import WavlakeEmbed from "Element/WavlakeEmbed";
|
||||||
|
import NostrLink from "Element/NostrLink";
|
||||||
|
|
||||||
export default function HyperText({ link, creator }: { link: string; creator: HexKey }) {
|
export default function HyperText({ link, creator }: { link: string; creator: HexKey }) {
|
||||||
const pref = useSelector((s: RootState) => s.login.preferences);
|
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) {
|
} else if (isWavlakeLink) {
|
||||||
return <WavlakeEmbed link={a} />;
|
return <WavlakeEmbed link={a} />;
|
||||||
|
} else if (url.protocol === "nostr:" || url.protocol === "web+nostr:") {
|
||||||
|
return <NostrLink link={a} />;
|
||||||
} else {
|
} else {
|
||||||
return (
|
return (
|
||||||
<a href={a} onClick={e => e.stopPropagation()} target="_blank" rel="noreferrer" className="ext">
|
<a href={a} onClick={e => e.stopPropagation()} target="_blank" rel="noreferrer" className="ext">
|
||||||
|
@ -1,24 +1,20 @@
|
|||||||
import { useMemo } from "react";
|
import { useMemo } from "react";
|
||||||
import { Link } from "react-router-dom";
|
import { Link } from "react-router-dom";
|
||||||
import { useUserProfile } from "Hooks/useUserProfile";
|
|
||||||
import { HexKey } from "@snort/nostr";
|
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> | string }) {
|
||||||
const user = useUserProfile(pubkey);
|
const user = useUserProfile(pubkey);
|
||||||
|
|
||||||
const name = useMemo(() => {
|
const name = useMemo(() => {
|
||||||
let name = hexToBech32("npub", pubkey).substring(0, 12);
|
return getDisplayName(user, pubkey);
|
||||||
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;
|
|
||||||
}, [user, pubkey]);
|
}, [user, pubkey]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Link to={profileLink(pubkey)} onClick={e => e.stopPropagation()}>
|
<Link to={profileLink(pubkey, relays)} onClick={e => e.stopPropagation()}>
|
||||||
@{name}
|
@{name}
|
||||||
</Link>
|
</Link>
|
||||||
);
|
);
|
||||||
|
27
packages/app/src/Element/NostrLink.tsx
Normal file
27
packages/app/src/Element/NostrLink.tsx
Normal file
@ -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 <Mention pubkey={nav.id} relays={nav.relays} />;
|
||||||
|
} 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 (
|
||||||
|
<Link to={`/e/${evLink}`} onClick={e => e.stopPropagation()} state={{ from: location.pathname }}>
|
||||||
|
#{evLink.substring(0, 12)}
|
||||||
|
</Link>
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
return (
|
||||||
|
<a href={link} onClick={e => e.stopPropagation()} target="_blank" rel="noreferrer" className="ext">
|
||||||
|
{link}
|
||||||
|
</a>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
@ -19,10 +19,11 @@ import {
|
|||||||
normalizeReaction,
|
normalizeReaction,
|
||||||
Reaction,
|
Reaction,
|
||||||
profileLink,
|
profileLink,
|
||||||
|
unwrap,
|
||||||
} from "Util";
|
} from "Util";
|
||||||
import NoteFooter, { Translation } from "Element/NoteFooter";
|
import NoteFooter, { Translation } from "Element/NoteFooter";
|
||||||
import NoteTime from "Element/NoteTime";
|
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 useModeration from "Hooks/useModeration";
|
||||||
import { setPinned, setBookmarked } from "State/Login";
|
import { setPinned, setBookmarked } from "State/Login";
|
||||||
import type { RootState } from "State/Store";
|
import type { RootState } from "State/Store";
|
||||||
@ -173,17 +174,22 @@ export default function Note(props: NoteProps) {
|
|||||||
}
|
}
|
||||||
}, [inView, entry, extendable]);
|
}, [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) {
|
if (!isTargetAllowed) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
|
const link = eventLink(eTarget.id, eTarget.relays);
|
||||||
// detect cmd key and open in new tab
|
// detect cmd key and open in new tab
|
||||||
if (e.metaKey) {
|
if (e.metaKey) {
|
||||||
window.open(eventLink(id), "_blank");
|
window.open(link, "_blank");
|
||||||
} else {
|
} else {
|
||||||
navigate(eventLink(id));
|
navigate(link);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -194,10 +200,11 @@ export default function Note(props: NoteProps) {
|
|||||||
|
|
||||||
const maxMentions = 2;
|
const maxMentions = 2;
|
||||||
const replyId = ev.Thread?.ReplyTo?.Event ?? ev.Thread?.Root?.Event;
|
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 }[] = [];
|
const mentions: { pk: string; name: string; link: ReactNode }[] = [];
|
||||||
for (const pk of ev.Thread?.PubKeys ?? []) {
|
for (const pk of ev.Thread?.PubKeys ?? []) {
|
||||||
const u = UserCache.get(pk);
|
const u = UserCache.get(pk);
|
||||||
const npub = hexToBech32("npub", pk);
|
const npub = hexToBech32(NostrPrefix.PublicKey, pk);
|
||||||
const shortNpub = npub.substring(0, 12);
|
const shortNpub = npub.substring(0, 12);
|
||||||
mentions.push({
|
mentions.push({
|
||||||
pk,
|
pk,
|
||||||
@ -205,7 +212,7 @@ export default function Note(props: NoteProps) {
|
|||||||
link: <Link to={profileLink(pk)}>{u?.name ? `@${u.name}` : shortNpub}</Link>,
|
link: <Link to={profileLink(pk)}>{u?.name ? `@${u.name}` : shortNpub}</Link>,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
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 othersLength = mentions.length - maxMentions;
|
||||||
const renderMention = (m: { link: React.ReactNode; pk: string; name: string }, idx: number) => {
|
const renderMention = (m: { link: React.ReactNode; pk: string; name: string }, idx: number) => {
|
||||||
return (
|
return (
|
||||||
@ -226,7 +233,11 @@ export default function Note(props: NoteProps) {
|
|||||||
{pubMentions} {others}
|
{pubMentions} {others}
|
||||||
</>
|
</>
|
||||||
) : (
|
) : (
|
||||||
replyId && <Link to={eventLink(replyId)}>{hexToBech32("note", replyId)?.substring(0, 12)}</Link>
|
replyId && (
|
||||||
|
<Link to={eventLink(replyId, replyRelayHints)}>
|
||||||
|
{hexToBech32(NostrPrefix.Event, replyId)?.substring(0, 12)}
|
||||||
|
</Link>
|
||||||
|
)
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
@ -286,7 +297,7 @@ export default function Note(props: NoteProps) {
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
<div className="body" onClick={e => goToEvent(e, ev.Id, true)}>
|
<div className="body" onClick={e => goToEvent(e, unwrap(ev.Original), true)}>
|
||||||
{transformBody()}
|
{transformBody()}
|
||||||
{translation()}
|
{translation()}
|
||||||
{options.showReactionsLink && (
|
{options.showReactionsLink && (
|
||||||
@ -319,7 +330,7 @@ export default function Note(props: NoteProps) {
|
|||||||
const note = (
|
const note = (
|
||||||
<div
|
<div
|
||||||
className={`${baseClassName}${highlight ? " active " : " "}${extendable && !showMore ? " note-expand" : ""}`}
|
className={`${baseClassName}${highlight ? " active " : " "}${extendable && !showMore ? " note-expand" : ""}`}
|
||||||
onClick={e => goToEvent(e, ev.Id)}
|
onClick={e => goToEvent(e, unwrap(ev.Original))}
|
||||||
ref={ref}>
|
ref={ref}>
|
||||||
{content()}
|
{content()}
|
||||||
</div>
|
</div>
|
||||||
|
@ -3,14 +3,14 @@ import { useSelector, useDispatch } from "react-redux";
|
|||||||
import { useIntl, FormattedMessage } from "react-intl";
|
import { useIntl, FormattedMessage } from "react-intl";
|
||||||
import { Menu, MenuItem } from "@szhsin/react-menu";
|
import { Menu, MenuItem } from "@szhsin/react-menu";
|
||||||
import { useLongPress } from "use-long-press";
|
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 Icon from "Icons/Icon";
|
||||||
import Spinner from "Icons/Spinner";
|
import Spinner from "Icons/Spinner";
|
||||||
|
|
||||||
import { formatShort } from "Number";
|
import { formatShort } from "Number";
|
||||||
import useEventPublisher from "Feed/EventPublisher";
|
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 { NoteCreator } from "Element/NoteCreator";
|
||||||
import Reactions from "Element/Reactions";
|
import Reactions from "Element/Reactions";
|
||||||
import SendSats from "Element/SendSats";
|
import SendSats from "Element/SendSats";
|
||||||
@ -263,7 +263,8 @@ export default function NoteFooter(props: NoteFooterProps) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function share() {
|
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) {
|
if ("share" in window.navigator) {
|
||||||
await window.navigator.share({
|
await window.navigator.share({
|
||||||
title: "Snort",
|
title: "Snort",
|
||||||
@ -298,7 +299,8 @@ export default function NoteFooter(props: NoteFooterProps) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function copyId() {
|
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) {
|
async function pin(id: HexKey) {
|
||||||
|
@ -2,7 +2,7 @@ import "./NoteReaction.css";
|
|||||||
import { Link } from "react-router-dom";
|
import { Link } from "react-router-dom";
|
||||||
import { useMemo } from "react";
|
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 Note from "Element/Note";
|
||||||
import ProfileImage from "Element/ProfileImage";
|
import ProfileImage from "Element/ProfileImage";
|
||||||
import { eventLink, hexToBech32 } from "Util";
|
import { eventLink, hexToBech32 } from "Util";
|
||||||
@ -24,7 +24,7 @@ export default function NoteReaction(props: NoteReactionProps) {
|
|||||||
if (ev) {
|
if (ev) {
|
||||||
const eTags = ev.Tags.filter(a => a.Key === "e");
|
const eTags = ev.Tags.filter(a => a.Key === "e");
|
||||||
if (eTags.length > 0) {
|
if (eTags.length > 0) {
|
||||||
return eTags[0].Event;
|
return eTags[0];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return null;
|
return null;
|
||||||
@ -34,7 +34,7 @@ export default function NoteReaction(props: NoteReactionProps) {
|
|||||||
ev.Kind !== EventKind.Reaction &&
|
ev.Kind !== EventKind.Reaction &&
|
||||||
ev.Kind !== EventKind.Repost &&
|
ev.Kind !== EventKind.Repost &&
|
||||||
(ev.Kind !== EventKind.TextNote ||
|
(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;
|
return null;
|
||||||
}
|
}
|
||||||
@ -73,7 +73,9 @@ export default function NoteReaction(props: NoteReactionProps) {
|
|||||||
{root ? <Note data={root} options={opt} related={[]} /> : null}
|
{root ? <Note data={root} options={opt} related={[]} /> : null}
|
||||||
{!root && refEvent ? (
|
{!root && refEvent ? (
|
||||||
<p>
|
<p>
|
||||||
<Link to={eventLink(refEvent)}>#{hexToBech32("note", refEvent).substring(0, 12)}</Link>
|
<Link to={eventLink(refEvent.Event ?? "", refEvent.Relay)}>
|
||||||
|
#{hexToBech32(NostrPrefix.Event, refEvent.Event).substring(0, 12)}
|
||||||
|
</Link>
|
||||||
</p>
|
</p>
|
||||||
) : null}
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
|
@ -6,7 +6,7 @@ import { useUserProfile } from "Hooks/useUserProfile";
|
|||||||
import { hexToBech32, profileLink } from "Util";
|
import { hexToBech32, profileLink } from "Util";
|
||||||
import Avatar from "Element/Avatar";
|
import Avatar from "Element/Avatar";
|
||||||
import Nip05 from "Element/Nip05";
|
import Nip05 from "Element/Nip05";
|
||||||
import { HexKey } from "@snort/nostr";
|
import { HexKey, NostrPrefix } from "@snort/nostr";
|
||||||
import { MetadataCache } from "State/Users";
|
import { MetadataCache } from "State/Users";
|
||||||
import usePageWidth from "Hooks/usePageWidth";
|
import usePageWidth from "Hooks/usePageWidth";
|
||||||
|
|
||||||
@ -77,7 +77,7 @@ export default function ProfileImage({
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function getDisplayName(user: MetadataCache | undefined, pubkey: HexKey) {
|
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) {
|
if (user?.display_name !== undefined && user.display_name.length > 0) {
|
||||||
name = user.display_name;
|
name = user.display_name;
|
||||||
} else if (user?.name !== undefined && user.name.length > 0) {
|
} else if (user?.name !== undefined && user.name.length > 0) {
|
||||||
|
@ -4,7 +4,7 @@ import { Link, useLocation } from "react-router-dom";
|
|||||||
import ReactMarkdown from "react-markdown";
|
import ReactMarkdown from "react-markdown";
|
||||||
import { visit, SKIP } from "unist-util-visit";
|
import { visit, SKIP } from "unist-util-visit";
|
||||||
import * as unist from "unist";
|
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 { MentionRegex, InvoiceRegex, HashtagRegex, MagnetRegex } from "Const";
|
||||||
import { eventLink, hexToBech32, magnetURIDecode, splitByUrl, unwrap } from "Util";
|
import { eventLink, hexToBech32, magnetURIDecode, splitByUrl, unwrap } from "Util";
|
||||||
@ -35,7 +35,7 @@ export default function Text({ content, tags, creator }: TextProps) {
|
|||||||
.map(f => {
|
.map(f => {
|
||||||
if (typeof f === "string") {
|
if (typeof f === "string") {
|
||||||
return splitByUrl(f).map(a => {
|
return splitByUrl(f).map(a => {
|
||||||
if (a.match(/^https?:\/\//)) {
|
if (a.match(/^(?:https?|(?:web\+)?nostr):/i)) {
|
||||||
return <HyperText key={a} link={a} creator={creator} />;
|
return <HyperText key={a} link={a} creator={creator} />;
|
||||||
}
|
}
|
||||||
return a;
|
return a;
|
||||||
@ -77,13 +77,13 @@ export default function Text({ content, tags, creator }: TextProps) {
|
|||||||
if (ref) {
|
if (ref) {
|
||||||
switch (ref.Key) {
|
switch (ref.Key) {
|
||||||
case "p": {
|
case "p": {
|
||||||
return <Mention pubkey={ref.PubKey ?? ""} />;
|
return <Mention pubkey={ref.PubKey ?? ""} relays={ref.Relay} />;
|
||||||
}
|
}
|
||||||
case "e": {
|
case "e": {
|
||||||
const eText = hexToBech32("note", ref.Event).substring(0, 12);
|
const eText = hexToBech32(NostrPrefix.Event, ref.Event).substring(0, 12);
|
||||||
return ref.Event ? (
|
return ref.Event ? (
|
||||||
<Link
|
<Link
|
||||||
to={eventLink(ref.Event)}
|
to={eventLink(ref.Event, ref.Relay)}
|
||||||
onClick={e => e.stopPropagation()}
|
onClick={e => e.stopPropagation()}
|
||||||
state={{ from: location.pathname }}>
|
state={{ from: location.pathname }}>
|
||||||
#{eText}
|
#{eText}
|
||||||
|
@ -5,6 +5,7 @@ import { useIntl } from "react-intl";
|
|||||||
import ReactTextareaAutocomplete from "@webscopeio/react-textarea-autocomplete";
|
import ReactTextareaAutocomplete from "@webscopeio/react-textarea-autocomplete";
|
||||||
import emoji from "@jukben/emoji-search";
|
import emoji from "@jukben/emoji-search";
|
||||||
import TextareaAutosize from "react-textarea-autosize";
|
import TextareaAutosize from "react-textarea-autosize";
|
||||||
|
import { NostrPrefix } from "@snort/nostr";
|
||||||
|
|
||||||
import Avatar from "Element/Avatar";
|
import Avatar from "Element/Avatar";
|
||||||
import Nip05 from "Element/Nip05";
|
import Nip05 from "Element/Nip05";
|
||||||
@ -82,7 +83,7 @@ const Textarea = (props: TextareaProps) => {
|
|||||||
afterWhitespace: true,
|
afterWhitespace: true,
|
||||||
dataProvider: userDataProvider,
|
dataProvider: userDataProvider,
|
||||||
component: (props: { entity: MetadataCache }) => <UserItem {...props.entity} />,
|
component: (props: { entity: MetadataCache }) => <UserItem {...props.entity} />,
|
||||||
output: (item: { pubkey: string }) => `@${hexToBech32("npub", item.pubkey)}`,
|
output: (item: { pubkey: string }) => `@${hexToBech32(NostrPrefix.PublicKey, item.pubkey)}`,
|
||||||
},
|
},
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
|
@ -255,14 +255,14 @@ const TierThree = ({ active, path, isLastSubthread, from, notes, related, chains
|
|||||||
|
|
||||||
export interface ThreadProps {
|
export interface ThreadProps {
|
||||||
notes?: TaggedRawEvent[];
|
notes?: TaggedRawEvent[];
|
||||||
|
selected?: u256;
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function Thread(props: ThreadProps) {
|
export default function Thread(props: ThreadProps) {
|
||||||
const notes = props.notes ?? [];
|
const notes = props.notes ?? [];
|
||||||
const parsedNotes = notes.map(a => new NEvent(a));
|
const parsedNotes = notes.map(a => new NEvent(a));
|
||||||
const [path, setPath] = useState<HexKey[]>([]);
|
const [path, setPath] = useState<HexKey[]>([]);
|
||||||
const currentId = path.length > 0 && path[path.length - 1];
|
const currentRoot = useMemo(() => parsedNotes.find(a => a.Id === props.selected), [notes, props.selected]);
|
||||||
const currentRoot = useMemo(() => parsedNotes.find(a => a.Id === currentId), [notes, currentId]);
|
|
||||||
const [navigated, setNavigated] = useState(false);
|
const [navigated, setNavigated] = useState(false);
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const isSingleNote = parsedNotes.filter(a => a.Kind === EventKind.TextNote).length === 1;
|
const isSingleNote = parsedNotes.filter(a => a.Kind === EventKind.TextNote).length === 1;
|
||||||
|
@ -5,10 +5,10 @@ import useSubscription from "Feed/Subscription";
|
|||||||
import { useSelector } from "react-redux";
|
import { useSelector } from "react-redux";
|
||||||
import { RootState } from "State/Store";
|
import { RootState } from "State/Store";
|
||||||
import { UserPreferences } from "State/Login";
|
import { UserPreferences } from "State/Login";
|
||||||
import { debounce } from "Util";
|
import { debounce, NostrLink } from "Util";
|
||||||
|
|
||||||
export default function useThreadFeed(id: u256) {
|
export default function useThreadFeed(link: NostrLink) {
|
||||||
const [trackingEvents, setTrackingEvent] = useState<u256[]>([id]);
|
const [trackingEvents, setTrackingEvent] = useState<u256[]>([link.id]);
|
||||||
const pref = useSelector<RootState, UserPreferences>(s => s.login.preferences);
|
const pref = useSelector<RootState, UserPreferences>(s => s.login.preferences);
|
||||||
|
|
||||||
function addId(id: u256[]) {
|
function addId(id: u256[]) {
|
||||||
@ -25,7 +25,7 @@ export default function useThreadFeed(id: u256) {
|
|||||||
|
|
||||||
const sub = useMemo(() => {
|
const sub = useMemo(() => {
|
||||||
const thisSub = new Subscriptions();
|
const thisSub = new Subscriptions();
|
||||||
thisSub.Id = `thread:${id.substring(0, 8)}`;
|
thisSub.Id = `thread:${link.id.substring(0, 8)}`;
|
||||||
thisSub.Ids = new Set(trackingEvents);
|
thisSub.Ids = new Set(trackingEvents);
|
||||||
|
|
||||||
// get replies to this event
|
// get replies to this event
|
||||||
@ -39,7 +39,7 @@ export default function useThreadFeed(id: u256) {
|
|||||||
thisSub.AddSubscription(subRelated);
|
thisSub.AddSubscription(subRelated);
|
||||||
|
|
||||||
return thisSub;
|
return thisSub;
|
||||||
}, [trackingEvents, pref, id]);
|
}, [trackingEvents, pref, link.id]);
|
||||||
|
|
||||||
const main = useSubscription(sub, { leaveOpen: true, cache: true });
|
const main = useSubscription(sub, { leaveOpen: true, cache: true });
|
||||||
|
|
||||||
|
@ -1,12 +1,17 @@
|
|||||||
import { useParams } from "react-router-dom";
|
import { useParams } from "react-router-dom";
|
||||||
|
|
||||||
import Thread from "Element/Thread";
|
import Thread from "Element/Thread";
|
||||||
import useThreadFeed from "Feed/ThreadFeed";
|
import useThreadFeed from "Feed/ThreadFeed";
|
||||||
import { parseId } from "Util";
|
import { parseNostrLink, unwrap } from "Util";
|
||||||
|
|
||||||
export default function EventPage() {
|
export default function EventPage() {
|
||||||
const params = useParams();
|
const params = useParams();
|
||||||
const id = parseId(params.id ?? "");
|
const link = parseNostrLink(params.id ?? "");
|
||||||
const thread = useThreadFeed(id);
|
const thread = useThreadFeed(unwrap(link));
|
||||||
|
|
||||||
return <Thread key={id} notes={thread.notes} />;
|
if (link) {
|
||||||
|
return <Thread key={link.id} notes={thread.notes} selected={link.id} />;
|
||||||
|
} else {
|
||||||
|
return <b>{params.id}</b>;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
@ -10,7 +10,8 @@ import { HexKey } from "@snort/nostr";
|
|||||||
import { RootState } from "State/Store";
|
import { RootState } from "State/Store";
|
||||||
import { setPrivateKey, setPublicKey, setRelays, setGeneratedPrivateKey } from "State/Login";
|
import { setPrivateKey, setPublicKey, setRelays, setGeneratedPrivateKey } from "State/Login";
|
||||||
import { DefaultRelays, EmailRegex, MnemonicRegex } from "Const";
|
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 ZapButton from "Element/ZapButton";
|
||||||
import useImgProxy from "Hooks/useImgProxy";
|
import useImgProxy from "Hooks/useImgProxy";
|
||||||
|
|
||||||
|
@ -1,9 +1,10 @@
|
|||||||
import { decodeTLV, NostrPrefix, TLVEntryType } from "@snort/nostr";
|
import { NostrPrefix } from "@snort/nostr";
|
||||||
import { useEffect } from "react";
|
import { useEffect } from "react";
|
||||||
import { useDispatch } from "react-redux";
|
import { useDispatch } from "react-redux";
|
||||||
import { useNavigate, useParams } from "react-router-dom";
|
import { useNavigate, useParams } from "react-router-dom";
|
||||||
|
|
||||||
import { setRelays } from "State/Login";
|
import { setRelays } from "State/Login";
|
||||||
import { eventLink, profileLink } from "Util";
|
import { parseNostrLink, unixNowMs, unwrap } from "Util";
|
||||||
|
|
||||||
export default function NostrLinkHandler() {
|
export default function NostrLinkHandler() {
|
||||||
const params = useParams();
|
const params = useParams();
|
||||||
@ -13,29 +14,21 @@ export default function NostrLinkHandler() {
|
|||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (link.length > 0) {
|
if (link.length > 0) {
|
||||||
const entity = link.startsWith("web+nostr:") ? link.split(":")[1] : link;
|
const nav = parseNostrLink(link);
|
||||||
if (entity.startsWith(NostrPrefix.PublicKey)) {
|
if (nav) {
|
||||||
navigate(`/p/${entity}`);
|
if ((nav.relays?.length ?? 0) > 0) {
|
||||||
} else if (entity.startsWith(NostrPrefix.Note)) {
|
// todo: add as ephemerial connection
|
||||||
navigate(`/e/${entity}`);
|
dispatch(
|
||||||
} else if (entity.startsWith(NostrPrefix.Profile) || entity.startsWith(NostrPrefix.Event)) {
|
setRelays({
|
||||||
const decoded = decodeTLV(entity);
|
relays: Object.fromEntries(unwrap(nav.relays).map(a => [a, { read: true, write: false }])),
|
||||||
console.debug(decoded);
|
createdAt: unixNowMs(),
|
||||||
|
})
|
||||||
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));
|
|
||||||
}
|
}
|
||||||
|
if (nav.type === NostrPrefix.Event || nav.type === NostrPrefix.Note || nav.type === NostrPrefix.Address) {
|
||||||
if (entity.startsWith(NostrPrefix.Profile)) {
|
navigate(`/e/${nav.encode()}`);
|
||||||
navigate(profileLink(id));
|
} else if (nav.type === NostrPrefix.PublicKey || nav.type === NostrPrefix.Profile) {
|
||||||
} else if (entity.startsWith(NostrPrefix.Event)) {
|
navigate(`/p/${nav.encode()}`);
|
||||||
navigate(eventLink(id));
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -3,9 +3,9 @@ import { useEffect, useState } from "react";
|
|||||||
import { useIntl, FormattedMessage } from "react-intl";
|
import { useIntl, FormattedMessage } from "react-intl";
|
||||||
import { useSelector } from "react-redux";
|
import { useSelector } from "react-redux";
|
||||||
import { useNavigate, useParams } from "react-router-dom";
|
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 { formatShort } from "Number";
|
||||||
import Note from "Element/Note";
|
import Note from "Element/Note";
|
||||||
import Bookmarks from "Element/Bookmarks";
|
import Bookmarks from "Element/Bookmarks";
|
||||||
@ -73,7 +73,7 @@ export default function ProfilePage() {
|
|||||||
tags: [],
|
tags: [],
|
||||||
creator: "",
|
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 lnurl = extractLnAddress(user?.lud16 || user?.lud06 || "");
|
||||||
const website_url =
|
const website_url =
|
||||||
@ -116,7 +116,13 @@ export default function ProfilePage() {
|
|||||||
setId(a);
|
setId(a);
|
||||||
});
|
});
|
||||||
} else {
|
} 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);
|
setTab(ProfileTab.Notes);
|
||||||
}, [params]);
|
}, [params]);
|
||||||
@ -252,6 +258,10 @@ export default function ProfilePage() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function renderIcons() {
|
function renderIcons() {
|
||||||
|
if (!id) return;
|
||||||
|
|
||||||
|
const firstRelay = relays.find(a => a.settings.write)?.url;
|
||||||
|
const link = encodeTLV(id, NostrPrefix.Profile, firstRelay ? [firstRelay] : undefined);
|
||||||
return (
|
return (
|
||||||
<div className="icon-actions">
|
<div className="icon-actions">
|
||||||
<IconButton onClick={() => setShowProfileQr(true)}>
|
<IconButton onClick={() => setShowProfileQr(true)}>
|
||||||
@ -259,13 +269,9 @@ export default function ProfilePage() {
|
|||||||
</IconButton>
|
</IconButton>
|
||||||
{showProfileQr && (
|
{showProfileQr && (
|
||||||
<Modal className="qr-modal" onClose={() => setShowProfileQr(false)}>
|
<Modal className="qr-modal" onClose={() => setShowProfileQr(false)}>
|
||||||
<ProfileImage pubkey={id ?? ""} />
|
<ProfileImage pubkey={id} />
|
||||||
|
<QrCode data={link} className="m10 align-center" />
|
||||||
<QrCode
|
<Copy text={link} className="align-center" />
|
||||||
data={`nostr:${hexToBech32(NostrPrefix.PublicKey, id)}`}
|
|
||||||
link={undefined}
|
|
||||||
className=" m10 align-center"
|
|
||||||
/>
|
|
||||||
</Modal>
|
</Modal>
|
||||||
)}
|
)}
|
||||||
{isMe ? (
|
{isMe ? (
|
||||||
@ -295,12 +301,13 @@ export default function ProfilePage() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function userDetails() {
|
function userDetails() {
|
||||||
|
if (!id) return;
|
||||||
return (
|
return (
|
||||||
<div className="details-wrapper">
|
<div className="details-wrapper">
|
||||||
{username()}
|
{username()}
|
||||||
<div className="profile-actions">
|
<div className="profile-actions">
|
||||||
{renderIcons()}
|
{renderIcons()}
|
||||||
{!isMe && <FollowButton pubkey={id ?? ""} />}
|
{!isMe && <FollowButton pubkey={id} />}
|
||||||
</div>
|
</div>
|
||||||
{bio()}
|
{bio()}
|
||||||
</div>
|
</div>
|
||||||
|
@ -6,7 +6,8 @@ import Logo from "Element/Logo";
|
|||||||
import { CollapsedSection } from "Element/Collapsed";
|
import { CollapsedSection } from "Element/Collapsed";
|
||||||
import Copy from "Element/Copy";
|
import Copy from "Element/Copy";
|
||||||
import { RootState } from "State/Store";
|
import { RootState } from "State/Store";
|
||||||
import { hexToBech32, hexToMnemonic } from "Util";
|
import { hexToBech32 } from "Util";
|
||||||
|
import { hexToMnemonic } from "nip6";
|
||||||
|
|
||||||
import messages from "./messages";
|
import messages from "./messages";
|
||||||
|
|
||||||
|
@ -31,6 +31,21 @@ describe("splitByUrl", () => {
|
|||||||
expect(splitByUrl(inputStr)).toEqual(expectedOutput);
|
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", () => {
|
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 inputStr = "This is a regular string with no URLs";
|
||||||
const expectedOutput = ["This is a regular string with no URLs"];
|
const expectedOutput = ["This is a regular string with no URLs"];
|
||||||
|
@ -5,12 +5,7 @@ import { bytesToHex } from "@noble/hashes/utils";
|
|||||||
import { decode as invoiceDecode } from "light-bolt11-decoder";
|
import { decode as invoiceDecode } from "light-bolt11-decoder";
|
||||||
import { bech32 } from "bech32";
|
import { bech32 } from "bech32";
|
||||||
import base32Decode from "base32-decode";
|
import base32Decode from "base32-decode";
|
||||||
import { HexKey, TaggedRawEvent, u256, EventKind, encodeTLV, NostrPrefix } from "@snort/nostr";
|
import { HexKey, TaggedRawEvent, u256, EventKind, encodeTLV, NostrPrefix, decodeTLV, TLVEntryType } 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 { MetadataCache } from "State/Users";
|
import { MetadataCache } from "State/Users";
|
||||||
|
|
||||||
export const sha256 = (str: string) => {
|
export const sha256 = (str: string) => {
|
||||||
@ -80,8 +75,21 @@ export function bech32ToText(str: string) {
|
|||||||
* @param hex
|
* @param hex
|
||||||
* @returns
|
* @returns
|
||||||
*/
|
*/
|
||||||
export function eventLink(hex: u256) {
|
export function eventLink(hex: u256, relays?: Array<string> | string) {
|
||||||
return `/e/${hexToBech32(NostrPrefix.Note, hex)}`;
|
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> | 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
|
* Reaction types
|
||||||
*/
|
*/
|
||||||
@ -275,7 +243,7 @@ export function groupByPubkey(acc: Record<HexKey, MetadataCache>, user: Metadata
|
|||||||
|
|
||||||
export function splitByUrl(str: string) {
|
export function splitByUrl(str: string) {
|
||||||
const urlRegex =
|
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);
|
return str.split(urlRegex);
|
||||||
}
|
}
|
||||||
@ -467,3 +435,75 @@ export function getRelayName(url: string) {
|
|||||||
const parsedUrl = new URL(url);
|
const parsedUrl = new URL(url);
|
||||||
return parsedUrl.host + parsedUrl.search;
|
return parsedUrl.host + parsedUrl.search;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface NostrLink {
|
||||||
|
type: NostrPrefix;
|
||||||
|
id: string;
|
||||||
|
kind?: number;
|
||||||
|
author?: string;
|
||||||
|
relays?: Array<string>;
|
||||||
|
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,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
39
packages/app/src/nip6.ts
Normal file
39
packages/app/src/nip6.ts
Normal file
@ -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);
|
||||||
|
}
|
@ -11,6 +11,7 @@ export enum NostrPrefix {
|
|||||||
Profile = "nprofile",
|
Profile = "nprofile",
|
||||||
Event = "nevent",
|
Event = "nevent",
|
||||||
Relay = "nrelay",
|
Relay = "nrelay",
|
||||||
|
Address = "naddr",
|
||||||
}
|
}
|
||||||
|
|
||||||
export enum TLVEntryType {
|
export enum TLVEntryType {
|
||||||
@ -43,7 +44,7 @@ export function encodeTLV(hex: string, prefix: NostrPrefix, relays?: string[]) {
|
|||||||
})
|
})
|
||||||
.flat() ?? [];
|
.flat() ?? [];
|
||||||
|
|
||||||
return bech32.encode(prefix, bech32.toWords([...tl0, ...tl1]));
|
return bech32.encode(prefix, bech32.toWords([...tl0, ...tl1]), 1_000);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function decodeTLV(str: string) {
|
export function decodeTLV(str: string) {
|
||||||
|
Loading…
x
Reference in New Issue
Block a user