feat: nip19/21 links
This commit is contained in:
@ -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 (
|
||||
<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="icon" style={{ color: copied ? "var(--success)" : "var(--highlight)" }}>
|
||||
{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 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 <WavlakeEmbed link={a} />;
|
||||
} else if (url.protocol === "nostr:" || url.protocol === "web+nostr:") {
|
||||
return <NostrLink link={a} />;
|
||||
} else {
|
||||
return (
|
||||
<a href={a} onClick={e => e.stopPropagation()} target="_blank" rel="noreferrer" className="ext">
|
||||
|
@ -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> | 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 (
|
||||
<Link to={profileLink(pubkey)} onClick={e => e.stopPropagation()}>
|
||||
<Link to={profileLink(pubkey, relays)} onClick={e => e.stopPropagation()}>
|
||||
@{name}
|
||||
</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,
|
||||
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: <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 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 && <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>
|
||||
);
|
||||
@ -286,7 +297,7 @@ export default function Note(props: NoteProps) {
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
<div className="body" onClick={e => goToEvent(e, ev.Id, true)}>
|
||||
<div className="body" onClick={e => goToEvent(e, unwrap(ev.Original), true)}>
|
||||
{transformBody()}
|
||||
{translation()}
|
||||
{options.showReactionsLink && (
|
||||
@ -319,7 +330,7 @@ export default function Note(props: NoteProps) {
|
||||
const note = (
|
||||
<div
|
||||
className={`${baseClassName}${highlight ? " active " : " "}${extendable && !showMore ? " note-expand" : ""}`}
|
||||
onClick={e => goToEvent(e, ev.Id)}
|
||||
onClick={e => goToEvent(e, unwrap(ev.Original))}
|
||||
ref={ref}>
|
||||
{content()}
|
||||
</div>
|
||||
|
@ -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) {
|
||||
|
@ -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 ? <Note data={root} options={opt} related={[]} /> : null}
|
||||
{!root && refEvent ? (
|
||||
<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>
|
||||
) : null}
|
||||
</div>
|
||||
|
@ -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) {
|
||||
|
@ -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 <HyperText key={a} link={a} creator={creator} />;
|
||||
}
|
||||
return a;
|
||||
@ -77,13 +77,13 @@ export default function Text({ content, tags, creator }: TextProps) {
|
||||
if (ref) {
|
||||
switch (ref.Key) {
|
||||
case "p": {
|
||||
return <Mention pubkey={ref.PubKey ?? ""} />;
|
||||
return <Mention pubkey={ref.PubKey ?? ""} relays={ref.Relay} />;
|
||||
}
|
||||
case "e": {
|
||||
const eText = hexToBech32("note", ref.Event).substring(0, 12);
|
||||
const eText = hexToBech32(NostrPrefix.Event, ref.Event).substring(0, 12);
|
||||
return ref.Event ? (
|
||||
<Link
|
||||
to={eventLink(ref.Event)}
|
||||
to={eventLink(ref.Event, ref.Relay)}
|
||||
onClick={e => e.stopPropagation()}
|
||||
state={{ from: location.pathname }}>
|
||||
#{eText}
|
||||
|
@ -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 }) => <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 {
|
||||
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<HexKey[]>([]);
|
||||
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;
|
||||
|
Reference in New Issue
Block a user