handle nostr: links

This commit is contained in:
hzrd149
2023-03-06 19:42:09 -06:00
parent 25f3201ac8
commit 0ec3955d3c
7 changed files with 132 additions and 35 deletions

View File

@@ -57,7 +57,7 @@ I would recomend you use a browser extension like [Alby](https://getalby.com/) o
- [x] [NIP-15](https://github.com/nostr-protocol/nips/blob/master/15.md): End of Stored Events Notice - [x] [NIP-15](https://github.com/nostr-protocol/nips/blob/master/15.md): End of Stored Events Notice
- [x] [NIP-19](https://github.com/nostr-protocol/nips/blob/master/19.md): bech32-encoded entities - [x] [NIP-19](https://github.com/nostr-protocol/nips/blob/master/19.md): bech32-encoded entities
- [ ] [NIP-20](https://github.com/nostr-protocol/nips/blob/master/20.md): Command Results - [ ] [NIP-20](https://github.com/nostr-protocol/nips/blob/master/20.md): Command Results
- [ ] [NIP-21](https://github.com/nostr-protocol/nips/blob/master/21.md): `nostr:` URL scheme - [x] [NIP-21](https://github.com/nostr-protocol/nips/blob/master/21.md): `nostr:` URL scheme
- [x] [NIP-25](https://github.com/nostr-protocol/nips/blob/master/25.md): Reactions - [x] [NIP-25](https://github.com/nostr-protocol/nips/blob/master/25.md): Reactions
- [ ] [NIP-26](https://github.com/nostr-protocol/nips/blob/master/26.md): Delegated Event Signing - [ ] [NIP-26](https://github.com/nostr-protocol/nips/blob/master/26.md): Delegated Event Signing
- [ ] [NIP-33](https://github.com/nostr-protocol/nips/blob/master/33.md): Parameterized Replaceable Events - [ ] [NIP-33](https://github.com/nostr-protocol/nips/blob/master/33.md): Parameterized Replaceable Events
@@ -78,10 +78,8 @@ I would recomend you use a browser extension like [Alby](https://getalby.com/) o
- add `client` tag to published events - add `client` tag to published events
- Save note drafts and let users manage them - Save note drafts and let users manage them
- make app a valid web share target https://developer.chrome.com/articles/web-share-target/ - make app a valid web share target https://developer.chrome.com/articles/web-share-target/
- handle `nostr:` links
- handle image share - handle image share
- implement NIP-56 and blocking - implement NIP-56 and blocking
- block notes based on content
- allow user to select relay or following list when fetching replies (default to my relays + following?) - allow user to select relay or following list when fetching replies (default to my relays + following?)
- massive thread note1dapvuu8fl09yjtg2gyr2h6nypaffl2sq0aj5raz86463qk5kpyzqlxvtc3 - massive thread note1dapvuu8fl09yjtg2gyr2h6nypaffl2sq0aj5raz86463qk5kpyzqlxvtc3

View File

@@ -30,6 +30,7 @@ const LoginNsecView = React.lazy(() => import("./views/login/nsec"));
const UserZapsTab = React.lazy(() => import("./views/user/zaps")); const UserZapsTab = React.lazy(() => import("./views/user/zaps"));
const DirectMessagesView = React.lazy(() => import("./views/dm")); const DirectMessagesView = React.lazy(() => import("./views/dm"));
const DirectMessageChatView = React.lazy(() => import("./views/dm/chat")); const DirectMessageChatView = React.lazy(() => import("./views/dm/chat"));
const NostrLinkView = React.lazy(() => import("./views/link"));
const RequireCurrentAccount = ({ children }: { children: JSX.Element }) => { const RequireCurrentAccount = ({ children }: { children: JSX.Element }) => {
let location = useLocation(); let location = useLocation();
@@ -107,30 +108,13 @@ const router = createBrowserRouter([
}, },
element: <NoteView />, element: <NoteView />,
}, },
{ { path: "settings", element: <SettingsView /> },
path: "settings", { path: "relays", element: <RelaysView /> },
element: <SettingsView />, { path: "notifications", element: <NotificationsView /> },
}, { path: "dm", element: <DirectMessagesView /> },
{ { path: "dm/:key", element: <DirectMessageChatView /> },
path: "relays", { path: "profile", element: <ProfileView /> },
element: <RelaysView />, { path: "nostr-link", element: <NostrLinkView /> },
},
{
path: "notifications",
element: <NotificationsView />,
},
{
path: "dm",
element: <DirectMessagesView />,
},
{
path: "dm/:key",
element: <DirectMessageChatView />,
},
{
path: "profile",
element: <ProfileView />,
},
{ {
path: "", path: "",
element: <HomeView />, element: <HomeView />,

View File

@@ -9,14 +9,27 @@ import {
ModalCloseButton, ModalCloseButton,
} from "@chakra-ui/react"; } from "@chakra-ui/react";
import { useCopyToClipboard } from "react-use"; import { useCopyToClipboard } from "react-use";
import { nip19 } from "nostr-tools";
import { Bech32Prefix, normalizeToBech32 } from "../../helpers/nip19"; import { Bech32Prefix, normalizeToBech32 } from "../../helpers/nip19";
import { NostrEvent } from "../../types/nostr-event"; import { NostrEvent } from "../../types/nostr-event";
import { MenuIconButton, MenuIconButtonProps } from "../menu-icon-button"; import { MenuIconButton, MenuIconButtonProps } from "../menu-icon-button";
import { ClipboardIcon, CodeIcon, LikeIcon } from "../icons"; import { ClipboardIcon, CodeIcon, LikeIcon, ShareIcon } from "../icons";
import { getReferences } from "../../helpers/nostr-event"; import { getReferences } from "../../helpers/nostr-event";
import NoteReactionsModal from "./note-reactions-modal"; import NoteReactionsModal from "./note-reactions-modal";
import { getEventRelays } from "../../services/event-relays";
import relayScoreboardService from "../../services/relay-scoreboard";
function getShareLink(eventId: string) {
const relays = getEventRelays(eventId).value;
const ranked = relayScoreboardService.getRankedRelays(relays);
const onlyTwo = ranked.slice(0, 2);
if (onlyTwo.length > 0) {
return nip19.neventEncode({ id: eventId, relays: onlyTwo });
} else return nip19.noteEncode(eventId);
}
export const NoteMenu = ({ event, ...props }: { event: NostrEvent } & Omit<MenuIconButtonProps, "children">) => { export const NoteMenu = ({ event, ...props }: { event: NostrEvent } & Omit<MenuIconButtonProps, "children">) => {
const infoModal = useDisclosure(); const infoModal = useDisclosure();
@@ -30,6 +43,9 @@ export const NoteMenu = ({ event, ...props }: { event: NostrEvent } & Omit<MenuI
<MenuItem onClick={reactionsModal.onOpen} icon={<LikeIcon />}> <MenuItem onClick={reactionsModal.onOpen} icon={<LikeIcon />}>
Reactions Reactions
</MenuItem> </MenuItem>
<MenuItem onClick={() => copyToClipboard("nostr:" + getShareLink(event.id))} icon={<ShareIcon />}>
Copy Share Link
</MenuItem>
{noteId && ( {noteId && (
<MenuItem onClick={() => copyToClipboard(noteId)} icon={<ClipboardIcon />}> <MenuItem onClick={() => copyToClipboard(noteId)} icon={<ClipboardIcon />}>
Copy Note ID Copy Note ID

View File

@@ -148,14 +148,14 @@ export default function ZapModal({
const payWithApp = async () => { const payWithApp = async () => {
window.open("lightning:" + invoice); window.open("lightning:" + invoice);
window.addEventListener( const listener = () => {
"focus", if (document.visibilityState === "visible") {
() => {
if (onPaid) onPaid(); if (onPaid) onPaid();
onClose(); onClose();
}, document.removeEventListener("visibilitychange", listener);
{ once: true } }
); };
document.addEventListener("visibilitychange", listener);
}; };
const handleClose = () => { const handleClose = () => {

View File

@@ -3,6 +3,14 @@ import { createRoot } from "react-dom/client";
import { App } from "./app"; import { App } from "./app";
import { Providers } from "./providers"; import { Providers } from "./providers";
// register nostr: protocol handler
try {
navigator.registerProtocolHandler("web+nostr", new URL("/nostr-link?q=%s", location.origin).toString());
} catch (e) {
console.log("Failed to register handler");
console.log(e);
}
const element = document.getElementById("root"); const element = document.getElementById("root");
if (!element) throw new Error("missing mount point"); if (!element) throw new Error("missing mount point");
const root = createRoot(element); const root = createRoot(element);

72
src/views/link/index.tsx Normal file
View File

@@ -0,0 +1,72 @@
import { Alert, AlertIcon, AlertTitle, Spinner } from "@chakra-ui/react";
import { Navigate, useSearchParams } from "react-router-dom";
import { Kind, nip19 } from "nostr-tools";
import { useUserMetadata } from "../../hooks/use-user-metadata";
import useSingleEvent from "../../hooks/use-single-event";
import { useReadRelayUrls } from "../../hooks/use-client-relays";
import { EventPointer, ProfilePointer } from "nostr-tools/lib/nip19";
export function NpubLinkHandler({ pubkey, relays }: { pubkey: string; relays?: string[] }) {
const readRelays = useReadRelayUrls(relays);
const metadata = useUserMetadata(pubkey, readRelays);
if (!metadata) return <Spinner />;
return <Navigate to={`/u/${pubkey}`} replace />;
}
export function NoteLinkHandler({ eventId, relays }: { eventId: string; relays?: string[] }) {
const readRelays = useReadRelayUrls(relays);
const { event, loading } = useSingleEvent(eventId, readRelays);
if (loading) return <Spinner />;
if (!event)
return (
<Alert status="error">
<AlertIcon />
<AlertTitle>Failed to find event</AlertTitle>
</Alert>
);
if (event.kind !== Kind.Text)
return (
<Alert status="error">
<AlertIcon />
<AlertTitle>Cant handle event kind {event.kind}</AlertTitle>
</Alert>
);
return <Navigate to={`/n/${eventId}`} replace />;
}
export default function NostrLinkView() {
const [searchParams] = useSearchParams();
const rawLink = searchParams.get("q");
if (!rawLink)
return (
<Alert status="warning">
<AlertIcon />
<AlertTitle>No link provided</AlertTitle>
</Alert>
);
const cleanLink = rawLink.replace(/(web\+)?nostr:/, "");
const decoded = nip19.decode(cleanLink);
if ((decoded.type = "npub")) return <NpubLinkHandler pubkey={decoded.data as string} />;
if (decoded.type === "nprofile") {
const data = decoded.data as ProfilePointer;
return <NpubLinkHandler pubkey={data.pubkey} relays={data.relays} />;
}
if (decoded.type === "note") return <NoteLinkHandler eventId={decoded.data as string} />;
if (decoded.type === "nevent") {
const data = decoded.data as EventPointer;
return <NoteLinkHandler eventId={data.id} relays={data.relays} />;
}
return (
<Alert status="warning">
<AlertIcon />
<AlertTitle>Unknown type "{decoded.type}"</AlertTitle>
</Alert>
);
}

View File

@@ -1,5 +1,8 @@
import { Flex, Heading, SkeletonText, Text, Link, IconButton } from "@chakra-ui/react"; import { Flex, Heading, SkeletonText, Text, Link, IconButton } from "@chakra-ui/react";
import { nip19 } from "nostr-tools";
import { useMemo } from "react";
import { useNavigate, Link as RouterLink } from "react-router-dom"; import { useNavigate, Link as RouterLink } from "react-router-dom";
import { RelayMode } from "../../../classes/relay";
import { CopyIconButton } from "../../../components/copy-icon-button"; import { CopyIconButton } from "../../../components/copy-icon-button";
import { ChatIcon, ExternalLinkIcon, KeyIcon, SettingsIcon } from "../../../components/icons"; import { ChatIcon, ExternalLinkIcon, KeyIcon, SettingsIcon } from "../../../components/icons";
import { QrIconButton } from "../../../components/qr-icon-button"; import { QrIconButton } from "../../../components/qr-icon-button";
@@ -12,9 +15,23 @@ import { truncatedId } from "../../../helpers/nostr-event";
import { fixWebsiteUrl, getUserDisplayName } from "../../../helpers/user-metadata"; import { fixWebsiteUrl, getUserDisplayName } from "../../../helpers/user-metadata";
import { useCurrentAccount } from "../../../hooks/use-current-account"; import { useCurrentAccount } from "../../../hooks/use-current-account";
import { useIsMobile } from "../../../hooks/use-is-mobile"; import { useIsMobile } from "../../../hooks/use-is-mobile";
import useMergedUserRelays from "../../../hooks/use-merged-user-relays";
import { useUserMetadata } from "../../../hooks/use-user-metadata"; import { useUserMetadata } from "../../../hooks/use-user-metadata";
import relayScoreboardService from "../../../services/relay-scoreboard";
import { UserProfileMenu } from "./user-profile-menu"; import { UserProfileMenu } from "./user-profile-menu";
function useUserShareLink(pubkey: string) {
const userRelays = useMergedUserRelays(pubkey);
return useMemo(() => {
const writeUrls = userRelays.filter((r) => r.mode & RelayMode.WRITE).map((r) => r.url);
const ranked = relayScoreboardService.getRankedRelays(writeUrls);
const onlyTwo = ranked.slice(0, 2);
return onlyTwo.length > 0 ? nip19.nprofileEncode({ pubkey, relays: onlyTwo }) : nip19.npubEncode(pubkey);
}, [userRelays]);
}
export default function Header({ pubkey }: { pubkey: string }) { export default function Header({ pubkey }: { pubkey: string }) {
const isMobile = useIsMobile(); const isMobile = useIsMobile();
const navigate = useNavigate(); const navigate = useNavigate();
@@ -24,6 +41,8 @@ export default function Header({ pubkey }: { pubkey: string }) {
const account = useCurrentAccount(); const account = useCurrentAccount();
const isSelf = pubkey === account.pubkey; const isSelf = pubkey === account.pubkey;
const shareLink = useUserShareLink(pubkey);
return ( return (
<Flex direction="column" gap="2" px="2" pt="2"> <Flex direction="column" gap="2" px="2" pt="2">
<Flex gap="4"> <Flex gap="4">
@@ -56,7 +75,7 @@ export default function Header({ pubkey }: { pubkey: string }) {
<KeyIcon /> <KeyIcon />
<Text>{truncatedId(npub, 10)}</Text> <Text>{truncatedId(npub, 10)}</Text>
<CopyIconButton text={npub} title="Copy npub" aria-label="Copy npub" size="xs" /> <CopyIconButton text={npub} title="Copy npub" aria-label="Copy npub" size="xs" />
<QrIconButton content={"nostr:" + npub} title="Show QrCode" aria-label="Show QrCode" size="xs" /> <QrIconButton content={"nostr:" + shareLink} title="Show QrCode" aria-label="Show QrCode" size="xs" />
</Flex> </Flex>
)} )}
<Flex gap="2" ml="auto"> <Flex gap="2" ml="auto">