From 4efbc483a580774147629f8fd622dab78d1cf58d Mon Sep 17 00:00:00 2001 From: hzrd149 Date: Mon, 16 Oct 2023 15:20:46 -0500 Subject: [PATCH] Finish sign up view --- .changeset/pink-ears-fold.md | 5 + src/app.tsx | 19 +- src/components/embed-types/cashu.tsx | 3 +- src/components/embed-types/lightning.tsx | 2 +- src/components/embed-types/music.tsx | 11 +- src/components/embed-types/youtube.tsx | 2 + src/components/inline-cashu-card.tsx | 6 +- src/components/inline-invoice-card.tsx | 5 +- src/views/signup/backup-step.tsx | 109 +++++++++++ src/views/signup/common.tsx | 11 ++ src/views/signup/create-step.tsx | 93 ++++++++++ src/views/signup/finished-step.tsx | 61 +++++++ src/views/signup/index.tsx | 221 ++++------------------- src/views/signup/name-step.tsx | 46 +++++ src/views/signup/profile-image-step.tsx | 56 ++++++ src/views/signup/relay-step.tsx | 75 ++++++++ 16 files changed, 532 insertions(+), 193 deletions(-) create mode 100644 .changeset/pink-ears-fold.md create mode 100644 src/views/signup/backup-step.tsx create mode 100644 src/views/signup/common.tsx create mode 100644 src/views/signup/create-step.tsx create mode 100644 src/views/signup/finished-step.tsx create mode 100644 src/views/signup/name-step.tsx create mode 100644 src/views/signup/profile-image-step.tsx create mode 100644 src/views/signup/relay-step.tsx diff --git a/.changeset/pink-ears-fold.md b/.changeset/pink-ears-fold.md new file mode 100644 index 000000000..167868b26 --- /dev/null +++ b/.changeset/pink-ears-fold.md @@ -0,0 +1,5 @@ +--- +"nostrudel": minor +--- + +Add sign up view diff --git a/src/app.tsx b/src/app.tsx index cc31ab6b5..22a107038 100644 --- a/src/app.tsx +++ b/src/app.tsx @@ -134,7 +134,24 @@ const router = createHashRouter([ }, { path: "signup", - element: , + children: [ + { + path: "", + element: ( + + + + ), + }, + { + path: ":step", + element: ( + + + + ), + }, + ], }, { path: "streams/:naddr", diff --git a/src/components/embed-types/cashu.tsx b/src/components/embed-types/cashu.tsx index 44e89dea0..a52e9dad5 100644 --- a/src/components/embed-types/cashu.tsx +++ b/src/components/embed-types/cashu.tsx @@ -8,7 +8,8 @@ export function embedCashuTokens(content: EmbedableContent) { return embedJSX(content, { regexp: getMatchCashu(), render: (match) => { - return ; + // set zIndex and position so link over dose not cover card + return ; }, name: "emoji", }); diff --git a/src/components/embed-types/lightning.tsx b/src/components/embed-types/lightning.tsx index 3fb4d7570..6528bfcc9 100644 --- a/src/components/embed-types/lightning.tsx +++ b/src/components/embed-types/lightning.tsx @@ -5,6 +5,6 @@ export function embedLightningInvoice(content: EmbedableContent) { return embedJSX(content, { name: "Lightning Invoice", regexp: /(lightning:)?(LNBC[A-Za-z0-9]+)/gim, - render: (match) => , + render: (match) => , }); } diff --git a/src/components/embed-types/music.tsx b/src/components/embed-types/music.tsx index 6f1fef3df..54fb15c62 100644 --- a/src/components/embed-types/music.tsx +++ b/src/components/embed-types/music.tsx @@ -1,5 +1,8 @@ +import { CSSProperties } from "react"; import { Box, useColorMode } from "@chakra-ui/react"; +const setZIndex: CSSProperties = { zIndex: 1, position: "relative" }; + // nostr:nevent1qqsve4ud5v8gjds2f2h7exlmjvhqayu4s520pge7frpwe22wezny0pcpp4mhxue69uhkummn9ekx7mqprdmhxue69uhkvet9v3ejumn0wd68ytnzv9hxgtmdv4kk2mxs3z0 export function renderWavlakeUrl(match: URL) { if (match.hostname !== "wavlake.com") return null; @@ -13,7 +16,7 @@ export function renderWavlakeUrl(match: URL) { frameBorder="0" title="Wavlake Embed" src={embedUrl.toString()} - style={{ width: "100%", aspectRatio: 576 / 356, maxWidth: 573 }} + style={{ width: "100%", aspectRatio: 576 / 356, maxWidth: 573, ...setZIndex }} > ); } @@ -34,7 +37,7 @@ export function renderAppleMusicUrl(match: URL) { frameBorder="0" title={isList ? "Apple Music List Embed" : "Apple Music Embed"} height={isList ? 450 : 175} - style={{ width: "100%", maxWidth: "660px", overflow: "hidden", background: "transparent" }} + style={{ width: "100%", maxWidth: "660px", overflow: "hidden", background: "transparent", ...setZIndex }} src={embedUrl.toString()} > ); @@ -55,7 +58,7 @@ export function renderSpotifyUrl(match: URL) { return ( ); } @@ -102,6 +106,7 @@ export function renderSongDotLinkUrl(match: URL) { aspectRatio={16 / 10} src={`https://odesli.co/embed/?url=${encodeURIComponent(match.href)}&theme=${colorMode}`} sandbox="allow-same-origin allow-scripts allow-presentation allow-popups allow-popups-to-escape-sandbox" + style={setZIndex} > ); } diff --git a/src/components/embed-types/youtube.tsx b/src/components/embed-types/youtube.tsx index 0316110c3..33f6e3535 100644 --- a/src/components/embed-types/youtube.tsx +++ b/src/components/embed-types/youtube.tsx @@ -41,6 +41,7 @@ export function renderYoutubeUrl(match: URL) { frameBorder="0" allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture; web-share; fullscreen" width="100%" + style={{ zIndex: 1, position: "relative" }} > ); @@ -58,6 +59,7 @@ export function renderYoutubeUrl(match: URL) { frameBorder="0" allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture; fullscreen" width="100%" + style={{ zIndex: 1, position: "relative" }} > ); diff --git a/src/components/inline-cashu-card.tsx b/src/components/inline-cashu-card.tsx index aedcf2211..88bf6245b 100644 --- a/src/components/inline-cashu-card.tsx +++ b/src/components/inline-cashu-card.tsx @@ -1,5 +1,5 @@ import { useEffect, useState } from "react"; -import { Box, Button, ButtonGroup, Card, Heading, IconButton, Link } from "@chakra-ui/react"; +import { Box, Button, ButtonGroup, Card, CardProps, Heading, IconButton, Link } from "@chakra-ui/react"; import { getDecodedToken, Token } from "@cashu/cashu-ts"; import { CopyIconButton } from "./copy-icon-button"; @@ -22,7 +22,7 @@ function RedeemButton({ token }: { token: string }) { ); } -export default function InlineCachuCard({ token }: { token: string }) { +export default function InlineCachuCard({ token, ...props }: Omit & { token: string }) { const account = useCurrentAccount(); const [cashu, setCashu] = useState(); @@ -39,7 +39,7 @@ export default function InlineCachuCard({ token }: { token: string }) { const amount = cashu?.token[0].proofs.reduce((acc, v) => acc + v.amount, 0); return ( - + {amount} Cashu sats diff --git a/src/components/inline-invoice-card.tsx b/src/components/inline-invoice-card.tsx index 5205a0c4a..bb4d1a930 100644 --- a/src/components/inline-invoice-card.tsx +++ b/src/components/inline-invoice-card.tsx @@ -2,7 +2,7 @@ import React, { useState } from "react"; import { useAsync } from "react-use"; import dayjs from "dayjs"; import { requestProvider } from "webln"; -import { Box, Button, ButtonGroup, IconButton, Text } from "@chakra-ui/react"; +import { Box, BoxProps, Button, ButtonGroup, IconButton, Text } from "@chakra-ui/react"; import { parsePaymentRequest, readablizeSats } from "../helpers/bolt11"; import { CopyToClipboardIcon } from "./icons"; @@ -10,7 +10,7 @@ import { CopyToClipboardIcon } from "./icons"; export type InvoiceButtonProps = { paymentRequest: string; }; -export const InlineInvoiceCard = ({ paymentRequest }: InvoiceButtonProps) => { +export const InlineInvoiceCard = ({ paymentRequest, ...props }: Omit & InvoiceButtonProps) => { const { value: invoice, error } = useAsync(async () => parsePaymentRequest(paymentRequest)); const [loading, setLoading] = useState(false); @@ -50,6 +50,7 @@ export const InlineInvoiceCard = ({ paymentRequest }: InvoiceButtonProps) => { flexWrap="wrap" gap="4" alignItems="center" + {...props} > Lightning Invoice diff --git a/src/views/signup/backup-step.tsx b/src/views/signup/backup-step.tsx new file mode 100644 index 000000000..3af5266c7 --- /dev/null +++ b/src/views/signup/backup-step.tsx @@ -0,0 +1,109 @@ +import { + Alert, + AlertIcon, + Button, + Flex, + FormControl, + FormHelperText, + FormLabel, + Heading, + Input, +} from "@chakra-ui/react"; +import { nip19 } from "nostr-tools"; + +import { containerProps } from "./common"; +import { CopyIconButton } from "../../components/copy-icon-button"; +import styled from "@emotion/styled"; +import { useState } from "react"; + +const Blockquote = styled.figure` + padding: var(--chakra-sizes-2) var(--chakra-sizes-4); + border-radius: var(--chakra-radii-xl); + background-color: var(--chakra-colors-chakra-subtle-bg); + position: relative; + + &::before { + content: "“"; + font-size: 10rem; + line-height: 1em; + top: var(--chakra-sizes-2); + left: var(--chakra-sizes-2); + position: absolute; + opacity: 0.2; + } + &::after { + content: "”"; + font-size: 10rem; + line-height: 1em; + bottom: calc(-0.75em + var(--chakra-sizes-2)); + right: var(--chakra-sizes-2); + position: absolute; + opacity: 0.2; + } + + figcaption, + blockquote { + margin: var(--chakra-sizes-4); + } +`; + +export default function BackupStep({ secretKey, onConfirm }: { secretKey: string; onConfirm: () => void }) { + const nsec = nip19.nsecEncode(secretKey); + + const [confirmed, setConfirmed] = useState(false); + const [last4, setLast4] = useState(""); + + if (confirmed) { + return ( + + Confirm secret key + + Last four letters of secret key + setLast4(e.target.value)} placeholder="xxxx" autoFocus /> + This is the key to access your account, keep it secret. + + + + + ); + } + + return ( + + Backup your keys + +
+
“Keep It Secret, Keep it Safe.
+
— Gandalf, The Fellowship of the Ring
+
+ + + + Your secret key is like your password, if anyone gets a hold of it they will have complete control over your + account + + + + Secret Key + + + + + This is the key to access your account, keep it secret. + + +
+ ); +} diff --git a/src/views/signup/common.tsx b/src/views/signup/common.tsx new file mode 100644 index 000000000..c093aec09 --- /dev/null +++ b/src/views/signup/common.tsx @@ -0,0 +1,11 @@ +import { Avatar, FlexProps } from "@chakra-ui/react"; + +export const AppIcon = () => ; + +export const containerProps: FlexProps = { + w: "full", + maxW: "sm", + mx: "4", + alignItems: "center", + direction: "column", +}; diff --git a/src/views/signup/create-step.tsx b/src/views/signup/create-step.tsx new file mode 100644 index 000000000..3151c73cc --- /dev/null +++ b/src/views/signup/create-step.tsx @@ -0,0 +1,93 @@ +import { useEffect, useState } from "react"; +import { generatePrivateKey, finishEvent, Kind, getPublicKey } from "nostr-tools"; +import { Avatar, Box, Button, Flex, Heading, Text, useToast } from "@chakra-ui/react"; + +import { Kind0ParsedContent } from "../../helpers/user-metadata"; +import { containerProps } from "./common"; +import dayjs from "dayjs"; +import { nostrBuildUploadImage } from "../../helpers/nostr-build"; +import NostrPublishAction from "../../classes/nostr-publish-action"; +import accountService from "../../services/account"; +import signingService from "../../services/signing"; +import clientRelaysService from "../../services/client-relays"; +import { RelayMode } from "../../classes/relay"; + +export default function CreateStep({ + metadata, + profileImage, + relays, + onBack, + onSubmit, +}: { + metadata: Kind0ParsedContent; + relays: string[]; + profileImage?: File; + onBack: () => void; + onSubmit: (secretKey: string) => void; +}) { + const toast = useToast(); + + const [preview, setPreview] = useState(""); + useEffect(() => { + if (profileImage) { + const url = URL.createObjectURL(profileImage); + setPreview(url); + return () => URL.revokeObjectURL(url); + } + }, [profileImage]); + + const [loading, setLoading] = useState(false); + const createProfile = async () => { + setLoading(true); + try { + const hex = generatePrivateKey(); + + const uploaded = profileImage + ? await nostrBuildUploadImage(profileImage, async (draft) => finishEvent(draft, hex)) + : undefined; + + // create profile + const kind0 = finishEvent( + { + content: JSON.stringify({ ...metadata, picture: uploaded?.url }), + created_at: dayjs().unix(), + kind: Kind.Metadata, + tags: [], + }, + hex, + ); + + new NostrPublishAction("Create Profile", [...relays, "wss://purplepag.es"], kind0); + + // login + const pubkey = getPublicKey(hex); + const encrypted = await signingService.encryptSecKey(hex); + accountService.addAccount({ pubkey, relays, ...encrypted, readonly: false }); + accountService.switchAccount(pubkey); + + // set relays + await clientRelaysService.postUpdatedRelays(relays.map((url) => ({ url, mode: RelayMode.ALL }))); + + onSubmit(hex); + } catch (e) { + if (e instanceof Error) toast({ description: e.message, status: "error" }); + } + setLoading(false); + }; + + return ( + + + + {metadata.display_name} + {metadata.about && {metadata.about}} + + + + + ); +} diff --git a/src/views/signup/finished-step.tsx b/src/views/signup/finished-step.tsx new file mode 100644 index 000000000..bf3eb5cbf --- /dev/null +++ b/src/views/signup/finished-step.tsx @@ -0,0 +1,61 @@ +import { Box, Button, Card, Flex, Heading, Text } from "@chakra-ui/react"; +import { Link as RouterLink } from "react-router-dom"; +import { useAsync } from "react-use"; + +import { UserAvatarLink } from "../../components/user-avatar-link"; +import { UserLink } from "../../components/user-link"; +import { containerProps } from "./common"; +import { UserFollowButton } from "../../components/user-follow-button"; +import { Kind0ParsedContent } from "../../helpers/user-metadata"; +import { UserDnsIdentityIcon } from "../../components/user-dns-identity-icon"; + +type TrendingApi = { + profiles: { + pubkey: string; + relays: string[]; + profile: { + content: string; + id: string; + kind: 0; + created_at: number; + }; + }[]; +}; + +function About({ profile }: { profile: { content: string } }) { + const { value: metadata, error } = useAsync( + async () => JSON.parse(profile.content) as Kind0ParsedContent, + [profile.content], + ); + return metadata ? {metadata.about} : null; +} + +export default function FinishedStep() { + const { value: trending } = useAsync(async () => { + return await fetch("https://api.nostr.band/v0/trending/profiles").then((res) => res.json() as Promise); + }); + + return ( + + Follow a few others + + {trending?.profiles.map(({ pubkey, profile }) => ( + + + + + + + + + + {profile && } + + ))} + + + + ); +} diff --git a/src/views/signup/index.tsx b/src/views/signup/index.tsx index 7306570c3..fed828d90 100644 --- a/src/views/signup/index.tsx +++ b/src/views/signup/index.tsx @@ -1,216 +1,73 @@ -import { useEffect, useRef, useState } from "react"; -import { - Avatar, - Box, - Button, - Card, - CardBody, - Center, - Flex, - FlexProps, - Heading, - Input, - SimpleGrid, - Text, - VisuallyHiddenInput, -} from "@chakra-ui/react"; -import { useForm } from "react-hook-form"; -import { Link as RouterLink, useLocation } from "react-router-dom"; -import { useSet } from "react-use"; +import { useState } from "react"; +import { Center } from "@chakra-ui/react"; +import { useNavigate, useParams } from "react-router-dom"; import { Kind0ParsedContent } from "../../helpers/user-metadata"; -import { useRelayInfo } from "../../hooks/use-relay-info"; -import { RelayFavicon } from "../../components/relay-favicon"; -import ImagePlus from "../../components/icons/image-plus"; - -const containerProps: FlexProps = { - w: "full", - maxW: "sm", - mx: "4", - alignItems: "center", - direction: "column", -}; -const AppIcon = () => ; - -function NameStep({ onSubmit }: { onSubmit: (metadata: Kind0ParsedContent) => void }) { - const location = useLocation(); - const { register, handleSubmit } = useForm({ - defaultValues: { - name: "", - }, - mode: "all", - }); - const submit = handleSubmit((values) => { - const displayName = values.name; - const username = values.name.toLocaleLowerCase().replaceAll(/(\p{Z}|\p{P}|\p{C}|\p{M})/gu, "_"); - - onSubmit({ - name: username, - display_name: displayName, - }); - }); - - return ( - - - - Sign up - - What should we call you? - - - Already have an account? - - - ); -} - -function ProfileImageStep({ displayName, onSubmit }: { displayName?: string; onSubmit: (picture?: File) => void }) { - const [file, setFile] = useState(); - const uploadRef = useRef(null); - - const [preview, setPreview] = useState(""); - useEffect(() => { - if (file) { - const url = URL.createObjectURL(file); - setPreview(url); - return () => URL.revokeObjectURL(url); - } - }, [file]); - - return ( - - - Add a profile image - - setFile(e.target.files?.[0])} - /> - uploadRef.current?.click()} - cursor="pointer" - icon={} - /> - {displayName} - - - ); -} - -function RelayButton({ url, selected, onClick }: { url: string; selected: boolean; onClick: () => void }) { - const { info } = useRelayInfo(url); - - return ( - - - - - - {info?.name} - {url} - - - {info?.description} - - - ); -} - -const recommendedRelays = [ - "wss://relay.damus.io", - "wss://welcome.nostr.wine", - "wss://nos.lol", - "wss://purplerelay.com", - "wss://nostr.bitcoiner.social", - "wss://nostr-pub.wellorder.net", -]; -const defaultRelaySelection = new Set(["wss://relay.damus.io", "wss://nos.lol", "wss://welcome.nostr.wine"]); -function RelayStep({ onSubmit }: { onSubmit: (relays: string[]) => void }) { - const [relays, relayActions] = useSet(defaultRelaySelection); - - return ( - - - Select some relays - - - - {recommendedRelays.map((url) => ( - relayActions.toggle(url)} /> - ))} - - - {relays.size === 0 && You must select at least one relay} - - - ); -} +import NameStep from "./name-step"; +import ProfileImageStep from "./profile-image-step"; +import RelayStep from "./relay-step"; +import CreateStep from "./create-step"; +import BackupStep from "./backup-step"; +import FinishedStep from "./finished-step"; export default function SignupView() { - const [step, setStep] = useState(0); + const step = useParams().step || "name"; + const navigate = useNavigate(); + const [metadata, setMetadata] = useState({}); const [profileImage, setProfileImage] = useState(); const [relays, setRelays] = useState([]); + const [secretKey, setSecretKey] = useState(""); const renderStep = () => { - const next = () => setStep((v) => v + 1); switch (step) { - case 0: + case "name": return ( { - setMetadata((v) => ({ ...v, ...m })); - next(); + setMetadata(m); + navigate("/signup/profile"); }} /> ); - case 1: + case "profile": return ( { setProfileImage(file); - next(); + navigate("/signup/relays"); }} + onBack={() => navigate("/signup/name")} /> ); - case 2: + case "relays": return ( { setRelays(r); - next(); + navigate("/signup/create"); + }} + onBack={() => navigate("/signup/profile")} + /> + ); + case "create": + return ( + navigate("/signup/relays")} + onSubmit={(hex) => { + setSecretKey(hex); + navigate("/signup/backup"); }} /> ); + case "backup": + return navigate("/signup/finished")} />; + case "finished": + return ; } }; diff --git a/src/views/signup/name-step.tsx b/src/views/signup/name-step.tsx new file mode 100644 index 000000000..8e03d3a6d --- /dev/null +++ b/src/views/signup/name-step.tsx @@ -0,0 +1,46 @@ +import { Button, Flex, Heading, Input, Text, Textarea } from "@chakra-ui/react"; +import { useForm } from "react-hook-form"; +import { Link as RouterLink, useLocation } from "react-router-dom"; + +import { Kind0ParsedContent } from "../../helpers/user-metadata"; +import { AppIcon, containerProps } from "./common"; + +export default function NameStep({ onSubmit }: { onSubmit: (metadata: Kind0ParsedContent) => void }) { + const location = useLocation(); + const { register, handleSubmit, formState } = useForm({ + defaultValues: { + name: "", + about: "", + }, + mode: "all", + }); + const submit = handleSubmit((values) => { + const displayName = values.name; + const username = values.name.toLocaleLowerCase().replaceAll(/(\p{Z}|\p{P}|\p{C}|\p{M})/gu, "_"); + + onSubmit({ + name: username, + display_name: displayName, + about: values.about, + }); + }); + + return ( + + + + Sign up + + What should we call you? + +