add npub login for fun

This commit is contained in:
hzrd149 2023-02-07 17:04:19 -06:00
parent 62c0581cf2
commit ae06d3fa1f
13 changed files with 177 additions and 34 deletions

View File

@ -29,7 +29,7 @@
- [ ] [NIP-04](https://github.com/nostr-protocol/nips/blob/master/04.md): Encrypted Direct Message
- [x] [NIP-05](https://github.com/nostr-protocol/nips/blob/master/05.md): Mapping Nostr keys to DNS-based internet identifiers
- [ ] [NIP-06](https://github.com/nostr-protocol/nips/blob/master/06.md): Basic key derivation from mnemonic seed phrase
- [ ] [NIP-07](https://github.com/nostr-protocol/nips/blob/master/07.md): `window.nostr` capability for web browsers
- [x] [NIP-07](https://github.com/nostr-protocol/nips/blob/master/07.md): `window.nostr` capability for web browsers
- [ ] [NIP-08](https://github.com/nostr-protocol/nips/blob/master/08.md): Handling Mentions
- [ ] [NIP-09](https://github.com/nostr-protocol/nips/blob/master/09.md): Event Deletion
- [ ] [NIP-11](https://github.com/nostr-protocol/nips/blob/master/11.md): Relay Information Document
@ -51,7 +51,6 @@
## TODO
- add relay selection to global feed and allow user to specify custom relay
- add `client` tag to published events
- add relay selection to global feed
- add button for creating lightning invoice via WebLN
@ -65,6 +64,7 @@
- sort replies by date
- filter list of followers by users the user has blocked/reported (stops bots/spammers from showing up at followers)
- Add client side relay groups
- Add mentions in posts (https://css-tricks.com/so-you-want-to-build-an-mention-autocomplete-feature/)
## Setup

View File

@ -19,6 +19,8 @@ import UserFollowersTab from "./views/user/followers";
import UserRelaysTab from "./views/user/relays";
import UserFollowingTab from "./views/user/following";
import NoteView from "./views/note";
import { LoginStartView } from "./views/login/start";
import { LoginNpubView } from "./views/login/npub";
const RequireSetup = ({ children }: { children: JSX.Element }) => {
let location = useLocation();
@ -38,7 +40,14 @@ const RootPage = () => (
);
const router = createBrowserRouter([
{ path: "login", element: <LoginView /> },
{
path: "login",
element: <LoginView />,
children: [
{ path: "", element: <LoginStartView /> },
{ path: "npub", element: <LoginNpubView /> },
],
},
{
path: "/",
element: <RootPage />,

View File

@ -151,3 +151,9 @@ export const VerificationFailed = createIcon({
d: "M12 22C6.477 22 2 17.523 2 12S6.477 2 12 2s10 4.477 10 10-4.477 10-10 10zm-1-7v2h2v-2h-2zm0-8v6h2V7h-2z",
defaultProps,
});
export const SpyIcon = createIcon({
displayName: "SpyIcon",
d: "M17 13a4 4 0 1 1-4 4h-2a4 4 0 1 1-.535-2h3.07A3.998 3.998 0 0 1 17 13zM7 15a2 2 0 1 0 0 4 2 2 0 0 0 0-4zm10 0a2 2 0 1 0 0 4 2 2 0 0 0 0-4zM16 3a4 4 0 0 1 4 4v3h2v2H2v-2h2V7a4 4 0 0 1 4-4h8zm0 2H8c-1.054 0-2 .95-2 2v3h12V7c0-1.054-.95-2-2-2z",
defaultProps,
});

View File

@ -19,12 +19,14 @@ import { ReplyIcon } from "../icons";
import { PostModalContext } from "../../providers/post-modal-provider";
import { buildReply } from "../../helpers/nostr-event";
import { UserDnsIdentityIcon } from "../user-dns-identity";
import { useReadonlyMode } from "../../hooks/use-readonly-mode";
export type NoteProps = {
event: NostrEvent;
};
export const Note = React.memo(({ event }: NoteProps) => {
const isMobile = useIsMobile();
const readonly = useReadonlyMode();
const { openModal } = useContext(PostModalContext);
const pubkey = useSubject(identity.pubkey);
@ -53,7 +55,14 @@ export const Note = React.memo(({ event }: NoteProps) => {
<NoteContents event={event} trusted={following.includes(event.pubkey)} />
</CardBody>
<CardFooter padding="2" display="flex" gap="2">
<IconButton icon={<ReplyIcon />} title="Reply" aria-label="Reply" onClick={reply} size="xs" />
<IconButton
icon={<ReplyIcon />}
title="Reply"
aria-label="Reply"
onClick={reply}
size="xs"
isDisabled={readonly}
/>
<Box flexGrow={1} />
<UserTipButton pubkey={event.pubkey} size="xs" />
<NoteRelays event={event} size="xs" />

View File

@ -1,5 +1,5 @@
import React from "react";
import { Avatar, Button, Container, Flex, Heading, IconButton, LinkOverlay, VStack } from "@chakra-ui/react";
import { Avatar, Button, Container, Flex, Heading, IconButton, LinkOverlay, Text, VStack } from "@chakra-ui/react";
import { Link, useNavigate } from "react-router-dom";
import { FeedIcon, LogoutIcon, ProfileIcon, SettingsIcon } from "./icons";
import { ErrorBoundary } from "./error-boundary";
@ -10,10 +10,12 @@ import identity from "../services/identity";
import { FollowingList } from "./following-list";
import { ReloadPrompt } from "./reload-prompt";
import { PostModalProvider } from "../providers/post-modal-provider";
import { useReadonlyMode } from "../hooks/use-readonly-mode";
export const Page = ({ children }: { children: React.ReactNode }) => {
const navigate = useNavigate();
const isMobile = useIsMobile();
const readonly = useReadonlyMode();
if (isMobile) {
return (
@ -72,6 +74,11 @@ export const Page = ({ children }: { children: React.ReactNode }) => {
<Button onClick={() => identity.logout()} leftIcon={<LogoutIcon />}>
Logout
</Button>
{readonly && (
<Text color="yellow.500" textAlign="center">
Readonly Mode
</Text>
)}
<ConnectedRelays />
</VStack>
<Flex flexGrow={1} direction="column" overflow="hidden">

View File

@ -0,0 +1,6 @@
import identity from "../services/identity";
import useSubject from "./use-subject";
export function useReadonlyMode() {
return useSubject(identity.readonly);
}

View File

@ -14,6 +14,7 @@ class IdentityService {
loading = new BehaviorSubject(true);
setup = new BehaviorSubject(false);
pubkey = new BehaviorSubject("");
readonly = new BehaviorSubject(false);
// TODO: remove this when there is a service to manage user relays
relays = new BehaviorSubject<PresetRelays>({});
private useExtension: boolean = false;
@ -25,11 +26,13 @@ class IdentityService {
if (savedIdentity) {
this.setup.next(true);
this.pubkey.next(savedIdentity.pubkey);
this.readonly.next(false);
this.secKey = savedIdentity.secKey;
this.useExtension = savedIdentity.useExtension;
} else {
this.setup.next(false);
this.pubkey.next("");
this.readonly.next(false);
this.secKey = undefined;
this.useExtension = false;
}
@ -38,6 +41,7 @@ class IdentityService {
async loginWithExtension() {
if (window.nostr) {
this.loading.next(true);
const pubkey = await window.nostr.getPublicKey();
settings.identity.next({
pubkey,
@ -60,7 +64,14 @@ class IdentityService {
// });
}
async logout() {
loginWithPubkey(pubkey: string) {
this.readonly.next(true);
this.pubkey.next(pubkey);
this.setup.next(true);
this.loading.next(false);
}
logout() {
settings.identity.next(null);
}
}

View File

@ -11,8 +11,10 @@ import settings from "../../services/settings";
import { AddIcon } from "@chakra-ui/icons";
import { useContext } from "react";
import { PostModalContext } from "../../providers/post-modal-provider";
import { useReadonlyMode } from "../../hooks/use-readonly-mode";
export const FollowingTab = () => {
const readonly = useReadonlyMode();
const pubkey = useSubject(identity.pubkey);
const relays = useSubject(settings.relays);
const { openModal } = useContext(PostModalContext);
@ -35,7 +37,7 @@ export const FollowingTab = () => {
return (
<Flex direction="column" gap="2">
<Button variant="outline" leftIcon={<AddIcon />} onClick={() => openModal()}>
<Button variant="outline" leftIcon={<AddIcon />} onClick={() => openModal()} isDisabled={readonly}>
New Post
</Button>
<FormControl display="flex" alignItems="center">

View File

@ -1,21 +0,0 @@
import { Avatar, Button, Flex, Heading, Spinner } from "@chakra-ui/react";
import { Navigate, useLocation } from "react-router-dom";
import useSubject from "../hooks/use-subject";
import identity from "../services/identity";
export const LoginView = () => {
const setup = useSubject(identity.setup);
const loading = useSubject(identity.loading);
const location = useLocation();
if (loading) return <Spinner />;
if (setup) return <Navigate to={location.state?.from ?? "/"} replace />;
return (
<Flex direction="column" alignItems="center" justifyContent="center" gap="4" height="80%">
<Avatar src="/apple-touch-icon.png" size="lg" />
<Heading>noStrudel</Heading>
<Button onClick={() => identity.loginWithExtension()}>Use browser extension</Button>
</Flex>
);
};

19
src/views/login/index.tsx Normal file
View File

@ -0,0 +1,19 @@
import { Avatar, Box, Flex, Heading } from "@chakra-ui/react";
import { Navigate, Outlet, useLocation } from "react-router-dom";
import useSubject from "../../hooks/use-subject";
import identity from "../../services/identity";
export const LoginView = () => {
const setup = useSubject(identity.setup);
const location = useLocation();
if (setup) return <Navigate to={location.state?.from ?? "/"} replace />;
return (
<Flex direction="column" alignItems="center" justifyContent="center" gap="4" height="80%" px="4">
<Avatar src="/apple-touch-icon.png" size="lg" />
<Heading>noStrudel</Heading>
<Outlet />
</Flex>
);
};

60
src/views/login/npub.tsx Normal file
View File

@ -0,0 +1,60 @@
import { useState } from "react";
import { Button, Flex, FormControl, FormHelperText, FormLabel, Input, Link, useToast } from "@chakra-ui/react";
import { useNavigate } from "react-router-dom";
import { RelayUrlInput } from "../../components/relay-url-input";
import { normalizeToHex } from "../../helpers/nip-19";
import identity from "../../services/identity";
import settings from "../../services/settings";
export const LoginNpubView = () => {
const navigate = useNavigate();
const toast = useToast();
const [npub, setNpub] = useState("");
const [relay, setRelay] = useState("");
const handleSubmit: React.FormEventHandler<HTMLDivElement> = (e) => {
e.preventDefault();
const pubkey = normalizeToHex(npub);
if (!pubkey) {
return toast({ status: "error", title: "Invalid npub" });
}
identity.loginWithPubkey(pubkey);
// TODO: the settings service should not be in charge of the relays
if (!settings.relays.value.includes(relay)) {
settings.relays.next([...settings.relays.value, relay]);
}
};
return (
<Flex as="form" direction="column" gap="4" onSubmit={handleSubmit}>
<Button variant="link" onClick={() => navigate("../")}>
Back
</Button>
<FormControl>
<FormLabel>Enter user npub</FormLabel>
<Input type="text" placeholder="npub1" isRequired value={npub} onChange={(e) => setNpub(e.target.value)} />
<FormHelperText>
Enter any npub you want.{" "}
<Link isExternal href="https://nostr.directory" color="blue.500" target="_blank">
nostr.directory
</Link>
</FormHelperText>
</FormControl>
<FormControl>
<FormLabel>Bootstrap relay</FormLabel>
<RelayUrlInput
placeholder="wss://nostr.example.com"
isRequired
value={relay}
onChange={(e) => setRelay(e.target.value)}
/>
<FormHelperText>The first relay to connect to.</FormHelperText>
</FormControl>
<Button colorScheme="brand" ml="auto" type="submit">
Login
</Button>
</Flex>
);
};

21
src/views/login/start.tsx Normal file
View File

@ -0,0 +1,21 @@
import { Button, Spinner } from "@chakra-ui/react";
import { useNavigate } from "react-router-dom";
import useSubject from "../../hooks/use-subject";
import identity from "../../services/identity";
export const LoginStartView = () => {
const navigate = useNavigate();
const loading = useSubject(identity.loading);
if (loading) return <Spinner />;
return (
<>
<Button onClick={() => identity.loginWithExtension()} colorScheme="brand">
Use browser extension
</Button>
<Button variant="link" onClick={() => navigate("./npub")}>
Login with npub
</Button>
</>
);
};

View File

@ -1,17 +1,36 @@
import { Avatar, MenuItem } from "@chakra-ui/react";
import { MenuIconButton, MenuIconButtonProps } from "../../../components/menu-icon-button";
import { ClipboardIcon, IMAGE_ICONS } from "../../../components/icons";
import { ClipboardIcon, IMAGE_ICONS, SpyIcon } from "../../../components/icons";
import { Bech32Prefix, normalizeToBech32 } from "../../../helpers/nip-19";
import { useCopyToClipboard } from "react-use";
import { truncatedId } from "../../../helpers/nostr-event";
import identity from "../../../services/identity";
import { useUserMetadata } from "../../../hooks/use-user-metadata";
import { getUserDisplayName } from "../../../helpers/user-metadata";
export const UserProfileMenu = ({ pubkey, ...props }: { pubkey: string } & Omit<MenuIconButtonProps, "children">) => {
const [_clipboardState, copyToClipboard] = useCopyToClipboard();
const npub = normalizeToBech32(pubkey, Bech32Prefix.Pubkey);
const metadata = useUserMetadata(pubkey);
const loginAsUser = () => {
if (confirm(`Do you want to logout and login as ${getUserDisplayName(metadata, pubkey)}?`)) {
identity.logout();
identity.loginWithPubkey(pubkey);
}
};
return (
<MenuIconButton {...props}>
<MenuItem icon={<SpyIcon fontSize="1.5em" />} onClick={() => loginAsUser()}>
Login as {getUserDisplayName(metadata, pubkey)}
</MenuItem>
{npub && (
<MenuItem onClick={() => copyToClipboard(npub)} icon={<ClipboardIcon fontSize="1.5em" />}>
Copy {truncatedId(npub)}
</MenuItem>
)}
<MenuItem
as="a"
icon={<Avatar src={IMAGE_ICONS.nostrGuruIcon} size="xs" />}
@ -44,11 +63,6 @@ export const UserProfileMenu = ({ pubkey, ...props }: { pubkey: string } & Omit<
>
Open in snort.social
</MenuItem>
{npub && (
<MenuItem onClick={() => copyToClipboard(npub)} icon={<ClipboardIcon />}>
Copy {truncatedId(npub)}
</MenuItem>
)}
</MenuIconButton>
);
};