Finish sign up view

This commit is contained in:
hzrd149
2023-10-16 15:20:46 -05:00
parent e02e63746d
commit 4efbc483a5
16 changed files with 532 additions and 193 deletions

View File

@@ -0,0 +1,5 @@
---
"nostrudel": minor
---
Add sign up view

View File

@@ -134,7 +134,24 @@ const router = createHashRouter([
},
{
path: "signup",
element: <SignupView />,
children: [
{
path: "",
element: (
<PageProviders>
<SignupView />
</PageProviders>
),
},
{
path: ":step",
element: (
<PageProviders>
<SignupView />
</PageProviders>
),
},
],
},
{
path: "streams/:naddr",

View File

@@ -8,7 +8,8 @@ export function embedCashuTokens(content: EmbedableContent) {
return embedJSX(content, {
regexp: getMatchCashu(),
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",
});

View File

@@ -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) => <InlineInvoiceCard paymentRequest={match[2]} />,
render: (match) => <InlineInvoiceCard paymentRequest={match[2]} zIndex={1} position="relative" />,
});
}

View File

@@ -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 }}
></iframe>
);
}
@@ -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()}
></iframe>
);
@@ -55,7 +58,7 @@ export function renderSpotifyUrl(match: URL) {
return (
<iframe
style={{ borderRadius: "12px" }}
style={{ borderRadius: "12px", ...setZIndex }}
width="100%"
height={isList ? 400 : 152}
title={isList ? "Spotify List Embed" : "Spotify Embed"}
@@ -84,6 +87,7 @@ export function renderTidalUrl(match: URL) {
width="100%"
height={isList ? 400 : 96}
title={isList ? "Tidal List Embed" : "Tidal Embed"}
style={setZIndex}
></iframe>
);
}
@@ -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}
></Box>
);
}

View File

@@ -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" }}
></iframe>
</AspectRatio>
);
@@ -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" }}
></iframe>
</AspectRatio>
);

View File

@@ -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<CardProps, "children"> & { token: string }) {
const account = useCurrentAccount();
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);
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" />
<Box>
<Heading size="md">{amount} Cashu sats</Heading>

View File

@@ -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<BoxProps, "children"> & 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}
>
<Box flexGrow={1}>
<Text fontWeight="bold">Lightning Invoice</Text>

View 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>&#8220;Keep It Secret, Keep it Safe.</blockquote>
<figcaption>&mdash; 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>
);
}

View 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",
};

View 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>
);
}

View 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>
);
}

View File

@@ -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 = () => <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>
);
}
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<Kind0ParsedContent>({});
const [profileImage, setProfileImage] = useState<File>();
const [relays, setRelays] = useState<string[]>([]);
const [secretKey, setSecretKey] = useState("");
const renderStep = () => {
const next = () => setStep((v) => v + 1);
switch (step) {
case 0:
case "name":
return (
<NameStep
onSubmit={(m) => {
setMetadata((v) => ({ ...v, ...m }));
next();
setMetadata(m);
navigate("/signup/profile");
}}
/>
);
case 1:
case "profile":
return (
<ProfileImageStep
displayName={metadata.display_name}
onSubmit={(file) => {
setProfileImage(file);
next();
navigate("/signup/relays");
}}
onBack={() => navigate("/signup/name")}
/>
);
case 2:
case "relays":
return (
<RelayStep
onSubmit={(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 />;
}
};

View 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>
);
}

View 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>
);
}

View 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>
);
}