mirror of
https://github.com/hzrd149/nostrudel.git
synced 2025-10-09 12:23:37 +02:00
Finish sign up view
This commit is contained in:
5
.changeset/pink-ears-fold.md
Normal file
5
.changeset/pink-ears-fold.md
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
---
|
||||||
|
"nostrudel": minor
|
||||||
|
---
|
||||||
|
|
||||||
|
Add sign up view
|
19
src/app.tsx
19
src/app.tsx
@@ -134,7 +134,24 @@ const router = createHashRouter([
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
path: "signup",
|
path: "signup",
|
||||||
element: <SignupView />,
|
children: [
|
||||||
|
{
|
||||||
|
path: "",
|
||||||
|
element: (
|
||||||
|
<PageProviders>
|
||||||
|
<SignupView />
|
||||||
|
</PageProviders>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: ":step",
|
||||||
|
element: (
|
||||||
|
<PageProviders>
|
||||||
|
<SignupView />
|
||||||
|
</PageProviders>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
path: "streams/:naddr",
|
path: "streams/:naddr",
|
||||||
|
@@ -8,7 +8,8 @@ export function embedCashuTokens(content: EmbedableContent) {
|
|||||||
return embedJSX(content, {
|
return embedJSX(content, {
|
||||||
regexp: getMatchCashu(),
|
regexp: getMatchCashu(),
|
||||||
render: (match) => {
|
render: (match) => {
|
||||||
return <InlineCachuCard token={match[0]} />;
|
// set zIndex and position so link over dose not cover card
|
||||||
|
return <InlineCachuCard token={match[0]} zIndex={1} position="relative" />;
|
||||||
},
|
},
|
||||||
name: "emoji",
|
name: "emoji",
|
||||||
});
|
});
|
||||||
|
@@ -5,6 +5,6 @@ export function embedLightningInvoice(content: EmbedableContent) {
|
|||||||
return embedJSX(content, {
|
return embedJSX(content, {
|
||||||
name: "Lightning Invoice",
|
name: "Lightning Invoice",
|
||||||
regexp: /(lightning:)?(LNBC[A-Za-z0-9]+)/gim,
|
regexp: /(lightning:)?(LNBC[A-Za-z0-9]+)/gim,
|
||||||
render: (match) => <InlineInvoiceCard paymentRequest={match[2]} />,
|
render: (match) => <InlineInvoiceCard paymentRequest={match[2]} zIndex={1} position="relative" />,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
@@ -1,5 +1,8 @@
|
|||||||
|
import { CSSProperties } from "react";
|
||||||
import { Box, useColorMode } from "@chakra-ui/react";
|
import { Box, useColorMode } from "@chakra-ui/react";
|
||||||
|
|
||||||
|
const setZIndex: CSSProperties = { zIndex: 1, position: "relative" };
|
||||||
|
|
||||||
// nostr:nevent1qqsve4ud5v8gjds2f2h7exlmjvhqayu4s520pge7frpwe22wezny0pcpp4mhxue69uhkummn9ekx7mqprdmhxue69uhkvet9v3ejumn0wd68ytnzv9hxgtmdv4kk2mxs3z0
|
// nostr:nevent1qqsve4ud5v8gjds2f2h7exlmjvhqayu4s520pge7frpwe22wezny0pcpp4mhxue69uhkummn9ekx7mqprdmhxue69uhkvet9v3ejumn0wd68ytnzv9hxgtmdv4kk2mxs3z0
|
||||||
export function renderWavlakeUrl(match: URL) {
|
export function renderWavlakeUrl(match: URL) {
|
||||||
if (match.hostname !== "wavlake.com") return null;
|
if (match.hostname !== "wavlake.com") return null;
|
||||||
@@ -13,7 +16,7 @@ export function renderWavlakeUrl(match: URL) {
|
|||||||
frameBorder="0"
|
frameBorder="0"
|
||||||
title="Wavlake Embed"
|
title="Wavlake Embed"
|
||||||
src={embedUrl.toString()}
|
src={embedUrl.toString()}
|
||||||
style={{ width: "100%", aspectRatio: 576 / 356, maxWidth: 573 }}
|
style={{ width: "100%", aspectRatio: 576 / 356, maxWidth: 573, ...setZIndex }}
|
||||||
></iframe>
|
></iframe>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -34,7 +37,7 @@ export function renderAppleMusicUrl(match: URL) {
|
|||||||
frameBorder="0"
|
frameBorder="0"
|
||||||
title={isList ? "Apple Music List Embed" : "Apple Music Embed"}
|
title={isList ? "Apple Music List Embed" : "Apple Music Embed"}
|
||||||
height={isList ? 450 : 175}
|
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()}
|
src={embedUrl.toString()}
|
||||||
></iframe>
|
></iframe>
|
||||||
);
|
);
|
||||||
@@ -55,7 +58,7 @@ export function renderSpotifyUrl(match: URL) {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<iframe
|
<iframe
|
||||||
style={{ borderRadius: "12px" }}
|
style={{ borderRadius: "12px", ...setZIndex }}
|
||||||
width="100%"
|
width="100%"
|
||||||
height={isList ? 400 : 152}
|
height={isList ? 400 : 152}
|
||||||
title={isList ? "Spotify List Embed" : "Spotify Embed"}
|
title={isList ? "Spotify List Embed" : "Spotify Embed"}
|
||||||
@@ -84,6 +87,7 @@ export function renderTidalUrl(match: URL) {
|
|||||||
width="100%"
|
width="100%"
|
||||||
height={isList ? 400 : 96}
|
height={isList ? 400 : 96}
|
||||||
title={isList ? "Tidal List Embed" : "Tidal Embed"}
|
title={isList ? "Tidal List Embed" : "Tidal Embed"}
|
||||||
|
style={setZIndex}
|
||||||
></iframe>
|
></iframe>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -102,6 +106,7 @@ export function renderSongDotLinkUrl(match: URL) {
|
|||||||
aspectRatio={16 / 10}
|
aspectRatio={16 / 10}
|
||||||
src={`https://odesli.co/embed/?url=${encodeURIComponent(match.href)}&theme=${colorMode}`}
|
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"
|
sandbox="allow-same-origin allow-scripts allow-presentation allow-popups allow-popups-to-escape-sandbox"
|
||||||
|
style={setZIndex}
|
||||||
></Box>
|
></Box>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
@@ -41,6 +41,7 @@ export function renderYoutubeUrl(match: URL) {
|
|||||||
frameBorder="0"
|
frameBorder="0"
|
||||||
allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture; web-share; fullscreen"
|
allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture; web-share; fullscreen"
|
||||||
width="100%"
|
width="100%"
|
||||||
|
style={{ zIndex: 1, position: "relative" }}
|
||||||
></iframe>
|
></iframe>
|
||||||
</AspectRatio>
|
</AspectRatio>
|
||||||
);
|
);
|
||||||
@@ -58,6 +59,7 @@ export function renderYoutubeUrl(match: URL) {
|
|||||||
frameBorder="0"
|
frameBorder="0"
|
||||||
allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture; fullscreen"
|
allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture; fullscreen"
|
||||||
width="100%"
|
width="100%"
|
||||||
|
style={{ zIndex: 1, position: "relative" }}
|
||||||
></iframe>
|
></iframe>
|
||||||
</AspectRatio>
|
</AspectRatio>
|
||||||
);
|
);
|
||||||
|
@@ -1,5 +1,5 @@
|
|||||||
import { useEffect, useState } from "react";
|
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 { getDecodedToken, Token } from "@cashu/cashu-ts";
|
||||||
|
|
||||||
import { CopyIconButton } from "./copy-icon-button";
|
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<CardProps, "children"> & { token: string }) {
|
||||||
const account = useCurrentAccount();
|
const account = useCurrentAccount();
|
||||||
|
|
||||||
const [cashu, setCashu] = useState<Token>();
|
const [cashu, setCashu] = useState<Token>();
|
||||||
@@ -39,7 +39,7 @@ export default function InlineCachuCard({ token }: { token: string }) {
|
|||||||
|
|
||||||
const amount = cashu?.token[0].proofs.reduce((acc, v) => acc + v.amount, 0);
|
const amount = cashu?.token[0].proofs.reduce((acc, v) => acc + v.amount, 0);
|
||||||
return (
|
return (
|
||||||
<Card p="4" flexDirection="row" borderColor="green.500" alignItems="center" gap="4" flexWrap="wrap">
|
<Card p="4" flexDirection="row" borderColor="green.500" alignItems="center" gap="4" flexWrap="wrap" {...props}>
|
||||||
<ECashIcon boxSize={10} color="green.500" />
|
<ECashIcon boxSize={10} color="green.500" />
|
||||||
<Box>
|
<Box>
|
||||||
<Heading size="md">{amount} Cashu sats</Heading>
|
<Heading size="md">{amount} Cashu sats</Heading>
|
||||||
|
@@ -2,7 +2,7 @@ import React, { useState } from "react";
|
|||||||
import { useAsync } from "react-use";
|
import { useAsync } from "react-use";
|
||||||
import dayjs from "dayjs";
|
import dayjs from "dayjs";
|
||||||
import { requestProvider } from "webln";
|
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 { parsePaymentRequest, readablizeSats } from "../helpers/bolt11";
|
||||||
import { CopyToClipboardIcon } from "./icons";
|
import { CopyToClipboardIcon } from "./icons";
|
||||||
@@ -10,7 +10,7 @@ import { CopyToClipboardIcon } from "./icons";
|
|||||||
export type InvoiceButtonProps = {
|
export type InvoiceButtonProps = {
|
||||||
paymentRequest: string;
|
paymentRequest: string;
|
||||||
};
|
};
|
||||||
export const InlineInvoiceCard = ({ paymentRequest }: InvoiceButtonProps) => {
|
export const InlineInvoiceCard = ({ paymentRequest, ...props }: Omit<BoxProps, "children"> & InvoiceButtonProps) => {
|
||||||
const { value: invoice, error } = useAsync(async () => parsePaymentRequest(paymentRequest));
|
const { value: invoice, error } = useAsync(async () => parsePaymentRequest(paymentRequest));
|
||||||
|
|
||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
@@ -50,6 +50,7 @@ export const InlineInvoiceCard = ({ paymentRequest }: InvoiceButtonProps) => {
|
|||||||
flexWrap="wrap"
|
flexWrap="wrap"
|
||||||
gap="4"
|
gap="4"
|
||||||
alignItems="center"
|
alignItems="center"
|
||||||
|
{...props}
|
||||||
>
|
>
|
||||||
<Box flexGrow={1}>
|
<Box flexGrow={1}>
|
||||||
<Text fontWeight="bold">Lightning Invoice</Text>
|
<Text fontWeight="bold">Lightning Invoice</Text>
|
||||||
|
109
src/views/signup/backup-step.tsx
Normal file
109
src/views/signup/backup-step.tsx
Normal file
@@ -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 (
|
||||||
|
<Flex gap="4" {...containerProps}>
|
||||||
|
<Heading>Confirm secret key</Heading>
|
||||||
|
<FormControl mb="4">
|
||||||
|
<FormLabel>Last four letters of secret key</FormLabel>
|
||||||
|
<Input value={last4} onChange={(e) => setLast4(e.target.value)} placeholder="xxxx" autoFocus />
|
||||||
|
<FormHelperText>This is the key to access your account, keep it secret.</FormHelperText>
|
||||||
|
</FormControl>
|
||||||
|
<Button w="full" maxW="sm" colorScheme="primary" onClick={onConfirm} isDisabled={last4 !== nsec.slice(-4)}>
|
||||||
|
Confirm
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="link"
|
||||||
|
onClick={() => {
|
||||||
|
setConfirmed(false);
|
||||||
|
setLast4("");
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Go back... I didn't save it
|
||||||
|
</Button>
|
||||||
|
</Flex>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Flex gap="4" {...containerProps} maxW="7in">
|
||||||
|
<Heading>Backup your keys</Heading>
|
||||||
|
|
||||||
|
<Blockquote>
|
||||||
|
<blockquote>“Keep It Secret, Keep it Safe.</blockquote>
|
||||||
|
<figcaption>— Gandalf, The Fellowship of the Ring</figcaption>
|
||||||
|
</Blockquote>
|
||||||
|
|
||||||
|
<Alert status="info">
|
||||||
|
<AlertIcon />
|
||||||
|
Your secret key is like your password, if anyone gets a hold of it they will have complete control over your
|
||||||
|
account
|
||||||
|
</Alert>
|
||||||
|
|
||||||
|
<FormControl mb="4">
|
||||||
|
<FormLabel>Secret Key</FormLabel>
|
||||||
|
<Flex gap="2">
|
||||||
|
<Input value={nsec} />
|
||||||
|
<CopyIconButton aria-label="Copy nsec" title="Copy nsec" text={nsec} />
|
||||||
|
</Flex>
|
||||||
|
<FormHelperText>This is the key to access your account, keep it secret.</FormHelperText>
|
||||||
|
</FormControl>
|
||||||
|
<Button w="full" maxW="sm" colorScheme="primary" onClick={() => setConfirmed(true)}>
|
||||||
|
I have saved my secret key
|
||||||
|
</Button>
|
||||||
|
</Flex>
|
||||||
|
);
|
||||||
|
}
|
11
src/views/signup/common.tsx
Normal file
11
src/views/signup/common.tsx
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
import { Avatar, FlexProps } from "@chakra-ui/react";
|
||||||
|
|
||||||
|
export const AppIcon = () => <Avatar src="/apple-touch-icon.png" size="lg" flexShrink={0} />;
|
||||||
|
|
||||||
|
export const containerProps: FlexProps = {
|
||||||
|
w: "full",
|
||||||
|
maxW: "sm",
|
||||||
|
mx: "4",
|
||||||
|
alignItems: "center",
|
||||||
|
direction: "column",
|
||||||
|
};
|
93
src/views/signup/create-step.tsx
Normal file
93
src/views/signup/create-step.tsx
Normal file
@@ -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 (
|
||||||
|
<Flex gap="4" {...containerProps}>
|
||||||
|
<Avatar size="xl" src={preview} />
|
||||||
|
<Flex direction="column" alignItems="center">
|
||||||
|
<Heading size="md">{metadata.display_name}</Heading>
|
||||||
|
{metadata.about && <Text>{metadata.about}</Text>}
|
||||||
|
</Flex>
|
||||||
|
<Button w="full" colorScheme="primary" isLoading={loading} onClick={createProfile} autoFocus>
|
||||||
|
Create profile
|
||||||
|
</Button>
|
||||||
|
<Button w="full" variant="link" onClick={onBack}>
|
||||||
|
Back
|
||||||
|
</Button>
|
||||||
|
</Flex>
|
||||||
|
);
|
||||||
|
}
|
61
src/views/signup/finished-step.tsx
Normal file
61
src/views/signup/finished-step.tsx
Normal file
@@ -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 ? <Text>{metadata.about}</Text> : 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<TrendingApi>);
|
||||||
|
});
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Flex gap="4" {...containerProps} maxW="6in">
|
||||||
|
<Heading>Follow a few others</Heading>
|
||||||
|
<Flex overflowX="hidden" overflowY="scroll" minH="4in" maxH="6in" direction="column" gap="2" w="full">
|
||||||
|
{trending?.profiles.map(({ pubkey, profile }) => (
|
||||||
|
<Card p="4" key={pubkey} variant="outline" gap="2">
|
||||||
|
<Flex direction="row" alignItems="center" gap="4">
|
||||||
|
<UserAvatarLink pubkey={pubkey} />
|
||||||
|
<Flex direction="column" overflow="hidden">
|
||||||
|
<UserLink pubkey={pubkey} fontWeight="bold" fontSize="lg" isTruncated />
|
||||||
|
<UserDnsIdentityIcon pubkey={pubkey} />
|
||||||
|
</Flex>
|
||||||
|
<UserFollowButton pubkey={pubkey} flexShrink={0} ml="auto" />
|
||||||
|
</Flex>
|
||||||
|
{profile && <About profile={profile} />}
|
||||||
|
</Card>
|
||||||
|
))}
|
||||||
|
</Flex>
|
||||||
|
<Button as={RouterLink} to="/" colorScheme="primary" maxW="sm" w="full">
|
||||||
|
Start exploring nostr
|
||||||
|
</Button>
|
||||||
|
</Flex>
|
||||||
|
);
|
||||||
|
}
|
@@ -1,216 +1,73 @@
|
|||||||
import { useEffect, useRef, useState } from "react";
|
import { useState } from "react";
|
||||||
import {
|
import { Center } from "@chakra-ui/react";
|
||||||
Avatar,
|
import { useNavigate, useParams } from "react-router-dom";
|
||||||
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 { Kind0ParsedContent } from "../../helpers/user-metadata";
|
import { Kind0ParsedContent } from "../../helpers/user-metadata";
|
||||||
import { useRelayInfo } from "../../hooks/use-relay-info";
|
import NameStep from "./name-step";
|
||||||
import { RelayFavicon } from "../../components/relay-favicon";
|
import ProfileImageStep from "./profile-image-step";
|
||||||
import ImagePlus from "../../components/icons/image-plus";
|
import RelayStep from "./relay-step";
|
||||||
|
import CreateStep from "./create-step";
|
||||||
const containerProps: FlexProps = {
|
import BackupStep from "./backup-step";
|
||||||
w: "full",
|
import FinishedStep from "./finished-step";
|
||||||
maxW: "sm",
|
|
||||||
mx: "4",
|
|
||||||
alignItems: "center",
|
|
||||||
direction: "column",
|
|
||||||
};
|
|
||||||
const AppIcon = () => <Avatar src="/apple-touch-icon.png" size="lg" flexShrink={0} />;
|
|
||||||
|
|
||||||
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 (
|
|
||||||
<Flex as="form" gap="2" onSubmit={submit} {...containerProps}>
|
|
||||||
<AppIcon />
|
|
||||||
<Heading size="lg" mb="2">
|
|
||||||
Sign up
|
|
||||||
</Heading>
|
|
||||||
<Text>What should we call you?</Text>
|
|
||||||
<Input placeholder="Jane" w="full" mb="2" {...register("name", { required: true })} autoComplete="off" />
|
|
||||||
<Button w="full" colorScheme="primary" mb="4">
|
|
||||||
Next
|
|
||||||
</Button>
|
|
||||||
<Text fontWeight="bold">Already have an account?</Text>
|
|
||||||
<Button as={RouterLink} to="/signin" state={location.state}>
|
|
||||||
Sign in
|
|
||||||
</Button>
|
|
||||||
</Flex>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function ProfileImageStep({ displayName, onSubmit }: { displayName?: string; onSubmit: (picture?: File) => void }) {
|
|
||||||
const [file, setFile] = useState<File>();
|
|
||||||
const uploadRef = useRef<HTMLInputElement | null>(null);
|
|
||||||
|
|
||||||
const [preview, setPreview] = useState("");
|
|
||||||
useEffect(() => {
|
|
||||||
if (file) {
|
|
||||||
const url = URL.createObjectURL(file);
|
|
||||||
setPreview(url);
|
|
||||||
return () => URL.revokeObjectURL(url);
|
|
||||||
}
|
|
||||||
}, [file]);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Flex gap="4" {...containerProps}>
|
|
||||||
<Heading size="lg" mb="2">
|
|
||||||
Add a profile image
|
|
||||||
</Heading>
|
|
||||||
<VisuallyHiddenInput
|
|
||||||
type="file"
|
|
||||||
accept="image/*"
|
|
||||||
ref={uploadRef}
|
|
||||||
onChange={(e) => setFile(e.target.files?.[0])}
|
|
||||||
/>
|
|
||||||
<Avatar
|
|
||||||
as="button"
|
|
||||||
size="xl"
|
|
||||||
src={preview}
|
|
||||||
onClick={() => uploadRef.current?.click()}
|
|
||||||
cursor="pointer"
|
|
||||||
icon={<ImagePlus boxSize={8} />}
|
|
||||||
/>
|
|
||||||
<Heading size="md">{displayName}</Heading>
|
|
||||||
<Button w="full" colorScheme="primary" mb="4" maxW="sm" onClick={() => onSubmit(file)}>
|
|
||||||
{file ? "Next" : "Skip for now"}
|
|
||||||
</Button>
|
|
||||||
</Flex>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function RelayButton({ url, selected, onClick }: { url: string; selected: boolean; onClick: () => void }) {
|
|
||||||
const { info } = useRelayInfo(url);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Card
|
|
||||||
variant="outline"
|
|
||||||
size="sm"
|
|
||||||
borderColor={selected ? "primary.500" : "gray.500"}
|
|
||||||
borderRadius="lg"
|
|
||||||
cursor="pointer"
|
|
||||||
onClick={onClick}
|
|
||||||
>
|
|
||||||
<CardBody>
|
|
||||||
<Flex gap="2" mb="2">
|
|
||||||
<RelayFavicon relay={url} />
|
|
||||||
<Box>
|
|
||||||
<Heading size="sm">{info?.name}</Heading>
|
|
||||||
<Text fontSize="sm">{url}</Text>
|
|
||||||
</Box>
|
|
||||||
</Flex>
|
|
||||||
<Text>{info?.description}</Text>
|
|
||||||
</CardBody>
|
|
||||||
</Card>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
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<string>(defaultRelaySelection);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Flex gap="4" {...containerProps} maxW="8in">
|
|
||||||
<Heading size="lg" mb="2">
|
|
||||||
Select some relays
|
|
||||||
</Heading>
|
|
||||||
|
|
||||||
<SimpleGrid columns={[1, 1, 2]} spacing="4">
|
|
||||||
{recommendedRelays.map((url) => (
|
|
||||||
<RelayButton key={url} url={url} selected={relays.has(url)} onClick={() => relayActions.toggle(url)} />
|
|
||||||
))}
|
|
||||||
</SimpleGrid>
|
|
||||||
|
|
||||||
{relays.size === 0 && <Text color="orange">You must select at least one relay</Text>}
|
|
||||||
<Button
|
|
||||||
w="full"
|
|
||||||
colorScheme="primary"
|
|
||||||
mb="4"
|
|
||||||
maxW="sm"
|
|
||||||
isDisabled={relays.size === 0}
|
|
||||||
onClick={() => onSubmit(Array.from(relays))}
|
|
||||||
>
|
|
||||||
Next
|
|
||||||
</Button>
|
|
||||||
</Flex>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export default function SignupView() {
|
export default function SignupView() {
|
||||||
const [step, setStep] = useState(0);
|
const step = useParams().step || "name";
|
||||||
|
const navigate = useNavigate();
|
||||||
|
|
||||||
const [metadata, setMetadata] = useState<Kind0ParsedContent>({});
|
const [metadata, setMetadata] = useState<Kind0ParsedContent>({});
|
||||||
const [profileImage, setProfileImage] = useState<File>();
|
const [profileImage, setProfileImage] = useState<File>();
|
||||||
const [relays, setRelays] = useState<string[]>([]);
|
const [relays, setRelays] = useState<string[]>([]);
|
||||||
|
const [secretKey, setSecretKey] = useState("");
|
||||||
|
|
||||||
const renderStep = () => {
|
const renderStep = () => {
|
||||||
const next = () => setStep((v) => v + 1);
|
|
||||||
switch (step) {
|
switch (step) {
|
||||||
case 0:
|
case "name":
|
||||||
return (
|
return (
|
||||||
<NameStep
|
<NameStep
|
||||||
onSubmit={(m) => {
|
onSubmit={(m) => {
|
||||||
setMetadata((v) => ({ ...v, ...m }));
|
setMetadata(m);
|
||||||
next();
|
navigate("/signup/profile");
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
case 1:
|
case "profile":
|
||||||
return (
|
return (
|
||||||
<ProfileImageStep
|
<ProfileImageStep
|
||||||
displayName={metadata.display_name}
|
displayName={metadata.display_name}
|
||||||
onSubmit={(file) => {
|
onSubmit={(file) => {
|
||||||
setProfileImage(file);
|
setProfileImage(file);
|
||||||
next();
|
navigate("/signup/relays");
|
||||||
}}
|
}}
|
||||||
|
onBack={() => navigate("/signup/name")}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
case 2:
|
case "relays":
|
||||||
return (
|
return (
|
||||||
<RelayStep
|
<RelayStep
|
||||||
onSubmit={(r) => {
|
onSubmit={(r) => {
|
||||||
setRelays(r);
|
setRelays(r);
|
||||||
next();
|
navigate("/signup/create");
|
||||||
|
}}
|
||||||
|
onBack={() => navigate("/signup/profile")}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
case "create":
|
||||||
|
return (
|
||||||
|
<CreateStep
|
||||||
|
metadata={metadata}
|
||||||
|
relays={relays}
|
||||||
|
profileImage={profileImage}
|
||||||
|
onBack={() => navigate("/signup/relays")}
|
||||||
|
onSubmit={(hex) => {
|
||||||
|
setSecretKey(hex);
|
||||||
|
navigate("/signup/backup");
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
|
case "backup":
|
||||||
|
return <BackupStep secretKey={secretKey} onConfirm={() => navigate("/signup/finished")} />;
|
||||||
|
case "finished":
|
||||||
|
return <FinishedStep />;
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
46
src/views/signup/name-step.tsx
Normal file
46
src/views/signup/name-step.tsx
Normal file
@@ -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 (
|
||||||
|
<Flex as="form" gap="2" onSubmit={submit} {...containerProps}>
|
||||||
|
<AppIcon />
|
||||||
|
<Heading size="lg" mb="2">
|
||||||
|
Sign up
|
||||||
|
</Heading>
|
||||||
|
<Text>What should we call you?</Text>
|
||||||
|
<Input placeholder="Jane" {...register("name", { required: true })} autoComplete="off" autoFocus />
|
||||||
|
<Textarea placeholder="Short description about yourself." w="full" mb="2" {...register("about")} />
|
||||||
|
<Button w="full" colorScheme="primary" mb="4" isDisabled={!formState.isValid} type="submit">
|
||||||
|
Next
|
||||||
|
</Button>
|
||||||
|
<Text fontWeight="bold">Already have an account?</Text>
|
||||||
|
<Button as={RouterLink} to="/signin" state={location.state}>
|
||||||
|
Sign in
|
||||||
|
</Button>
|
||||||
|
</Flex>
|
||||||
|
);
|
||||||
|
}
|
56
src/views/signup/profile-image-step.tsx
Normal file
56
src/views/signup/profile-image-step.tsx
Normal file
@@ -0,0 +1,56 @@
|
|||||||
|
import { useEffect, useRef, useState } from "react";
|
||||||
|
import { Avatar, Button, Flex, Heading, VisuallyHiddenInput } from "@chakra-ui/react";
|
||||||
|
|
||||||
|
import ImagePlus from "../../components/icons/image-plus";
|
||||||
|
import { containerProps } from "./common";
|
||||||
|
|
||||||
|
export default function ProfileImageStep({
|
||||||
|
displayName,
|
||||||
|
onSubmit,
|
||||||
|
onBack,
|
||||||
|
}: {
|
||||||
|
displayName?: string;
|
||||||
|
onSubmit: (picture?: File) => void;
|
||||||
|
onBack: () => void;
|
||||||
|
}) {
|
||||||
|
const [file, setFile] = useState<File>();
|
||||||
|
const uploadRef = useRef<HTMLInputElement | null>(null);
|
||||||
|
|
||||||
|
const [preview, setPreview] = useState("");
|
||||||
|
useEffect(() => {
|
||||||
|
if (file) {
|
||||||
|
const url = URL.createObjectURL(file);
|
||||||
|
setPreview(url);
|
||||||
|
return () => URL.revokeObjectURL(url);
|
||||||
|
}
|
||||||
|
}, [file]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Flex gap="4" {...containerProps}>
|
||||||
|
<Heading size="lg" mb="2">
|
||||||
|
Add a profile image
|
||||||
|
</Heading>
|
||||||
|
<VisuallyHiddenInput
|
||||||
|
type="file"
|
||||||
|
accept="image/*"
|
||||||
|
ref={uploadRef}
|
||||||
|
onChange={(e) => setFile(e.target.files?.[0])}
|
||||||
|
/>
|
||||||
|
<Avatar
|
||||||
|
as="button"
|
||||||
|
size="xl"
|
||||||
|
src={preview}
|
||||||
|
onClick={() => uploadRef.current?.click()}
|
||||||
|
icon={<ImagePlus boxSize={8} />}
|
||||||
|
autoFocus
|
||||||
|
/>
|
||||||
|
<Heading size="md">{displayName}</Heading>
|
||||||
|
<Button w="full" colorScheme="primary" maxW="sm" onClick={() => onSubmit(file)}>
|
||||||
|
{file ? "Next" : "Skip for now"}
|
||||||
|
</Button>
|
||||||
|
<Button w="full" variant="link" onClick={onBack}>
|
||||||
|
Back
|
||||||
|
</Button>
|
||||||
|
</Flex>
|
||||||
|
);
|
||||||
|
}
|
75
src/views/signup/relay-step.tsx
Normal file
75
src/views/signup/relay-step.tsx
Normal file
@@ -0,0 +1,75 @@
|
|||||||
|
import { Box, Button, Card, CardBody, Flex, Heading, SimpleGrid, Text } from "@chakra-ui/react";
|
||||||
|
import { useSet } from "react-use";
|
||||||
|
|
||||||
|
import { useRelayInfo } from "../../hooks/use-relay-info";
|
||||||
|
import { RelayFavicon } from "../../components/relay-favicon";
|
||||||
|
import { containerProps } from "./common";
|
||||||
|
|
||||||
|
function RelayButton({ url, selected, onClick }: { url: string; selected: boolean; onClick: () => void }) {
|
||||||
|
const { info } = useRelayInfo(url);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Card
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
borderColor={selected ? "primary.500" : "gray.500"}
|
||||||
|
borderRadius="lg"
|
||||||
|
cursor="pointer"
|
||||||
|
onClick={onClick}
|
||||||
|
>
|
||||||
|
<CardBody>
|
||||||
|
<Flex gap="2" mb="2">
|
||||||
|
<RelayFavicon relay={url} />
|
||||||
|
<Box>
|
||||||
|
<Heading size="sm">{info?.name}</Heading>
|
||||||
|
<Text fontSize="sm">{url}</Text>
|
||||||
|
</Box>
|
||||||
|
</Flex>
|
||||||
|
<Text>{info?.description}</Text>
|
||||||
|
</CardBody>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
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"]);
|
||||||
|
|
||||||
|
export default function RelayStep({ onSubmit, onBack }: { onSubmit: (relays: string[]) => void; onBack: () => void }) {
|
||||||
|
const [relays, relayActions] = useSet<string>(defaultRelaySelection);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Flex gap="4" {...containerProps} maxW="8in">
|
||||||
|
<Heading size="lg" mb="2">
|
||||||
|
Select some relays
|
||||||
|
</Heading>
|
||||||
|
|
||||||
|
<SimpleGrid columns={[1, 1, 2]} spacing="4">
|
||||||
|
{recommendedRelays.map((url) => (
|
||||||
|
<RelayButton key={url} url={url} selected={relays.has(url)} onClick={() => relayActions.toggle(url)} />
|
||||||
|
))}
|
||||||
|
</SimpleGrid>
|
||||||
|
|
||||||
|
{relays.size === 0 && <Text color="orange">You must select at least one relay</Text>}
|
||||||
|
<Button
|
||||||
|
w="full"
|
||||||
|
colorScheme="primary"
|
||||||
|
maxW="sm"
|
||||||
|
isDisabled={relays.size === 0}
|
||||||
|
onClick={() => onSubmit(Array.from(relays))}
|
||||||
|
autoFocus
|
||||||
|
>
|
||||||
|
Next
|
||||||
|
</Button>
|
||||||
|
<Button w="full" variant="link" onClick={onBack}>
|
||||||
|
Back
|
||||||
|
</Button>
|
||||||
|
</Flex>
|
||||||
|
);
|
||||||
|
}
|
Reference in New Issue
Block a user