add simple profile edit view

This commit is contained in:
hzrd149
2023-02-18 12:11:27 -06:00
parent 09dd6f055f
commit ec46eccd34
9 changed files with 182 additions and 50 deletions

View File

@@ -23,7 +23,7 @@
"react": "^18.2.0", "react": "^18.2.0",
"react-dom": "^18.2.0", "react-dom": "^18.2.0",
"react-error-boundary": "^3.1.4", "react-error-boundary": "^3.1.4",
"react-hook-form": "^7.41.2", "react-hook-form": "^7.43.1",
"react-router-dom": "^6.5.0", "react-router-dom": "^6.5.0",
"react-singleton-hook": "^4.0.1", "react-singleton-hook": "^4.0.1",
"react-use": "^17.4.0", "react-use": "^17.4.0",

View File

@@ -4,7 +4,7 @@ import { Link, useNavigate } from "react-router-dom";
import { useCurrentAccount } from "../../hooks/use-current-account"; import { useCurrentAccount } from "../../hooks/use-current-account";
import accountService from "../../services/account"; import accountService from "../../services/account";
import { ConnectedRelays } from "../connected-relays"; import { ConnectedRelays } from "../connected-relays";
import { FeedIcon, LogoutIcon, NotificationIcon, RelayIcon } from "../icons"; import { FeedIcon, LogoutIcon, NotificationIcon, ProfileIcon, RelayIcon } from "../icons";
import { ProfileButton } from "../profile-button"; import { ProfileButton } from "../profile-button";
import AccountSwitcher from "./account-switcher"; import AccountSwitcher from "./account-switcher";
@@ -27,6 +27,9 @@ export default function DesktopSideNav() {
<Button onClick={() => navigate("/notifications")} leftIcon={<NotificationIcon />}> <Button onClick={() => navigate("/notifications")} leftIcon={<NotificationIcon />}>
Notifications Notifications
</Button> </Button>
<Button onClick={() => navigate("/profile")} leftIcon={<ProfileIcon />}>
Profile
</Button>
<Button onClick={() => navigate("/relays")} leftIcon={<RelayIcon />}> <Button onClick={() => navigate("/relays")} leftIcon={<RelayIcon />}>
Relays Relays
</Button> </Button>

View File

@@ -17,6 +17,7 @@ import { useUserMetadata } from "../../hooks/use-user-metadata";
import accountService from "../../services/account"; import accountService from "../../services/account";
import { LogoutIcon, ProfileIcon, RelayIcon, SettingsIcon } from "../icons"; import { LogoutIcon, ProfileIcon, RelayIcon, SettingsIcon } from "../icons";
import { UserAvatar } from "../user-avatar"; import { UserAvatar } from "../user-avatar";
import { UserLink } from "../user-link";
import AccountSwitcher from "./account-switcher"; import AccountSwitcher from "./account-switcher";
export default function MobileSideDrawer({ ...props }: Omit<DrawerProps, "children">) { export default function MobileSideDrawer({ ...props }: Omit<DrawerProps, "children">) {
@@ -32,13 +33,13 @@ export default function MobileSideDrawer({ ...props }: Omit<DrawerProps, "childr
<DrawerHeader> <DrawerHeader>
<Flex gap="2"> <Flex gap="2">
<UserAvatar pubkey={account.pubkey} size="sm" /> <UserAvatar pubkey={account.pubkey} size="sm" />
<Text>{getUserDisplayName(metadata, account.pubkey)}</Text> <UserLink pubkey={account.pubkey} />
</Flex> </Flex>
</DrawerHeader> </DrawerHeader>
<DrawerBody padding={0} overflowY="auto" overflowX="hidden"> <DrawerBody padding={0} overflowY="auto" overflowX="hidden">
<AccountSwitcher /> <AccountSwitcher />
<Flex direction="column" gap="2" padding="2"> <Flex direction="column" gap="2" padding="2">
<Button onClick={() => navigate(`/u/${account.pubkey}`)} leftIcon={<ProfileIcon />}> <Button onClick={() => navigate(`/profile`)} leftIcon={<ProfileIcon />}>
Profile Profile
</Button> </Button>
<Button onClick={() => navigate("/relays")} leftIcon={<RelayIcon />}> <Button onClick={() => navigate("/relays")} leftIcon={<RelayIcon />}>

View File

@@ -5,14 +5,14 @@ import { unique } from "../helpers/array";
export type RelayUrlInputProps = Omit<InputProps, "type">; export type RelayUrlInputProps = Omit<InputProps, "type">;
export const RelayUrlInput = ({ ...props }: RelayUrlInputProps) => { export const RelayUrlInput = ({ ...props }: RelayUrlInputProps) => {
const { value: relaysJson, loading: loadingRelaysJson } = useAsync(async () => const { value: relaysJson } = useAsync(async () =>
fetch("https://api.nostr.watch/v1/online").then((res) => res.json() as Promise<string[]>) fetch("https://api.nostr.watch/v1/online").then((res) => res.json() as Promise<string[]>)
); );
const relaySuggestions = unique(relaysJson ?? []); const relaySuggestions = unique(relaysJson ?? []);
return ( return (
<> <>
<Input list="relay-suggestions" type="url" isDisabled={props.isDisabled ?? loadingRelaysJson} {...props} /> <Input list="relay-suggestions" type="url" {...props} />
<datalist id="relay-suggestions"> <datalist id="relay-suggestions">
{relaySuggestions.map((url) => ( {relaySuggestions.map((url) => (
<option key={url} value={url}> <option key={url} value={url}>

View File

@@ -4,3 +4,12 @@ export function encodeText(prefix: string, text: string) {
const words = bech32.toWords(new TextEncoder().encode(text)); const words = bech32.toWords(new TextEncoder().encode(text));
return bech32.encode(prefix, words, Infinity); return bech32.encode(prefix, words, Infinity);
} }
export function decodeText(encoded: string) {
const decoded = bech32.decode(encoded);
const text = new TextDecoder().decode(new Uint8Array(bech32.fromWords(decoded.words)));
return {
text,
prefix: decoded.prefix,
};
}

10
src/helpers/lnurl.ts Normal file
View File

@@ -0,0 +1,10 @@
import { decodeText } from "./bech32";
export function isLNURL(lnurl: string) {
try {
const parsed = decodeText(lnurl);
return parsed.prefix.toLowerCase() === "lnurl";
} catch (e) {
return false;
}
}

View File

@@ -12,13 +12,7 @@ export type RelayDirectory = Record<string, { read: boolean; write: boolean }>;
class ClientRelayService { class ClientRelayService {
bootstrapRelays = new Set<string>(); bootstrapRelays = new Set<string>();
relays = new PersistentSubject<RelayConfig[]>([ relays = new PersistentSubject<RelayConfig[]>([]);
//default relay list
{ url: "wss://relay.damus.io", mode: RelayMode.READ },
{ url: "wss://relay.snort.social", mode: RelayMode.READ },
{ url: "wss://nos.lol", mode: RelayMode.READ },
{ url: "wss://brb.io", mode: RelayMode.READ },
]);
writeRelays = new PersistentSubject<RelayConfig[]>([]); writeRelays = new PersistentSubject<RelayConfig[]>([]);
readRelays = new PersistentSubject<RelayConfig[]>([]); readRelays = new PersistentSubject<RelayConfig[]>([]);

View File

@@ -1,16 +1,32 @@
import { Avatar, Button, Flex, FormControl, FormLabel, Input, SkeletonText, Textarea } from "@chakra-ui/react"; import { Avatar, Button, Flex, FormControl, FormLabel, Input, Textarea, useToast } from "@chakra-ui/react";
import { useMemo } from "react"; import moment from "moment";
import { useEffect, useMemo } from "react";
import { useForm } from "react-hook-form"; import { useForm } from "react-hook-form";
import { nostrPostAction } from "../../classes/nostr-post-action";
import { isLNURL } from "../../helpers/lnurl";
import { Kind0ParsedContent } from "../../helpers/user-metadata";
import { useReadRelayUrls, useWriteRelayUrls } from "../../hooks/use-client-relays";
import { useCurrentAccount } from "../../hooks/use-current-account"; import { useCurrentAccount } from "../../hooks/use-current-account";
import useSubject from "../../hooks/use-subject"; import { useIsMobile } from "../../hooks/use-is-mobile";
import { useUserMetadata } from "../../hooks/use-user-metadata"; import { useUserMetadata } from "../../hooks/use-user-metadata";
import accountService from "../../services/account"; import signingService from "../../services/signing";
import userMetadataService from "../../services/user-metadata";
import { DraftNostrEvent } from "../../types/nostr-event";
const isEmail =
/^(([^<>()[\]\\.,;:\s@"]+(\.[^<>()[\]\\.,;:\s@"]+)*)|(".+"))@((\[[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}\])|(([a-zA-Z\-0-9]+\.)+[a-zA-Z]{2,}))$/;
function isLightningAddress(addr: string) {
return isEmail.test(addr);
}
type FormData = { type FormData = {
displayName?: string; displayName?: string;
username?: string; username?: string;
picture?: string; picture?: string;
about?: string; about?: string;
website?: string;
lightningAddress?: string;
}; };
type MetadataFormProps = { type MetadataFormProps = {
@@ -19,50 +35,112 @@ type MetadataFormProps = {
}; };
const MetadataForm = ({ defaultValues, onSubmit }: MetadataFormProps) => { const MetadataForm = ({ defaultValues, onSubmit }: MetadataFormProps) => {
const { register, reset, handleSubmit, getValues } = useForm<FormData>({ const isMobile = useIsMobile();
const {
register,
reset,
handleSubmit,
getValues,
formState: { errors, isSubmitting },
} = useForm<FormData>({
mode: "onBlur",
defaultValues, defaultValues,
}); });
const submit = handleSubmit(onSubmit); useEffect(() => {
reset(defaultValues);
}, [defaultValues]);
return ( return (
<form onSubmit={submit}> <Flex direction="column" pb="4" overflow="auto" px={isMobile ? "2" : 0}>
<Flex direction="column" gap="2" pt="4"> <form onSubmit={handleSubmit(onSubmit)}>
<Flex gap="2"> <Flex direction="column" gap="2" pt="4">
<Flex gap="2">
<FormControl>
<FormLabel>Display Name</FormLabel>
<Input autoComplete="off" isDisabled={isSubmitting} {...register("displayName", { maxLength: 50 })} />
</FormControl>
<FormControl>
<FormLabel>Username</FormLabel>
<Input
autoComplete="off"
isRequired
isDisabled={isSubmitting}
isInvalid={!!errors.username}
{...register("username", {
minLength: 2,
maxLength: 256,
required: true,
pattern: /^[a-zA-Z0-9_-]{4,16}$/,
})}
/>
{errors.username?.message}
</FormControl>
</Flex>
<Flex gap="2" alignItems="center">
<FormControl>
<FormLabel>Picture</FormLabel>
<Input autoComplete="off" isDisabled={isSubmitting} {...register("picture", { maxLength: 150 })} />
</FormControl>
<Avatar src={getValues("picture")} size="md" ignoreFallback />
</Flex>
<FormControl> <FormControl>
<FormLabel>Display Name</FormLabel> <FormLabel>Website</FormLabel>
<Input {...register("displayName", { maxLength: 100 })} /> <Input
type="url"
autoComplete="off"
placeholder="https://example.com"
isDisabled={isSubmitting}
isInvalid={!!errors.website}
{...register("website", { maxLength: 300 })}
/>
</FormControl> </FormControl>
<FormControl> <FormControl>
<FormLabel>Username</FormLabel> <FormLabel>About</FormLabel>
<Input {...register("username", { maxLength: 100 })} /> <Textarea
placeholder="A short description"
resize="vertical"
rows={6}
isDisabled={isSubmitting}
isInvalid={!!errors.about}
{...register("about")}
/>
</FormControl> </FormControl>
</Flex>
<Flex gap="2" alignItems="center">
<FormControl> <FormControl>
<FormLabel>Picture</FormLabel> <FormLabel>Lightning Address (or LNURL)</FormLabel>
<Input {...register("picture", { maxLength: 150 })} /> <Input
autoComplete="off"
isInvalid={!!errors.lightningAddress}
isDisabled={isSubmitting}
{...register("lightningAddress", {
validate: (v) => {
if (v && !isLNURL(v) && !isLightningAddress(v)) {
return "Must be lightning address or LNURL";
}
return true;
},
})}
/>
{/* <FormHelperText>Don't forget the https://</FormHelperText> */}
</FormControl> </FormControl>
<Avatar src={getValues("picture")} size="md" /> <Flex alignSelf="flex-end" gap="2">
<Button onClick={() => reset()}>Reset</Button>
<Button colorScheme="brand" isLoading={isSubmitting} type="submit">
Update
</Button>
</Flex>
</Flex> </Flex>
<FormControl> </form>
<FormLabel>About</FormLabel> </Flex>
<Textarea placeholder="A short description" resize="vertical" rows={6} {...register("about")} />
</FormControl>
<Flex alignSelf="flex-end" gap="2">
<Button onClick={() => reset()}>Reset</Button>
<Button colorScheme="brand" disabled>
Save
</Button>
</Flex>
</Flex>
</form>
); );
}; };
export const ProfileEditView = () => { export const ProfileEditView = () => {
const writeRelays = useWriteRelayUrls();
const readRelays = useReadRelayUrls();
const toast = useToast();
const account = useCurrentAccount(); const account = useCurrentAccount();
const metadata = useUserMetadata(account.pubkey); const metadata = useUserMetadata(account.pubkey, readRelays, true);
const defaultValues = useMemo<FormData>( const defaultValues = useMemo<FormData>(
() => ({ () => ({
@@ -70,13 +148,50 @@ export const ProfileEditView = () => {
username: metadata?.name, username: metadata?.name,
picture: metadata?.picture, picture: metadata?.picture,
about: metadata?.about, about: metadata?.about,
website: metadata?.website,
lightningAddress: metadata?.lud16 || metadata?.lud06,
}), }),
[metadata] [metadata]
); );
if (!metadata) return <SkeletonText />; const handleSubmit = async (data: FormData) => {
try {
const metadata: Kind0ParsedContent = {
name: data.username,
display_name: data.displayName,
picture: data.picture,
website: data.website,
};
const handleSubmit = (data: FormData) => {}; if (data.lightningAddress) {
if (isLNURL(data.lightningAddress)) {
metadata.lud06 = data.lightningAddress;
} else if (isLightningAddress(data.lightningAddress)) {
metadata.lud16 = data.lightningAddress;
}
}
const draft: DraftNostrEvent = {
created_at: moment().unix(),
kind: 0,
content: JSON.stringify(metadata),
tags: [],
};
const event = await signingService.requestSignature(draft, account);
const results = nostrPostAction(writeRelays, event);
userMetadataService.handleEvent(event);
await results.onComplete;
} catch (e) {
if (e instanceof Error) {
toast({
status: "error",
description: e.message,
});
}
}
};
return <MetadataForm defaultValues={defaultValues} onSubmit={handleSubmit} />; return <MetadataForm defaultValues={defaultValues} onSubmit={handleSubmit} />;
}; };

View File

@@ -3663,10 +3663,10 @@ react-focus-lock@^2.9.1:
use-callback-ref "^1.3.0" use-callback-ref "^1.3.0"
use-sidecar "^1.1.2" use-sidecar "^1.1.2"
react-hook-form@^7.41.2: react-hook-form@^7.43.1:
version "7.41.2" version "7.43.1"
resolved "https://registry.yarnpkg.com/react-hook-form/-/react-hook-form-7.41.2.tgz#db37b0bfd844b96d7b30d26ed3c55366e3c2c7cd" resolved "https://registry.yarnpkg.com/react-hook-form/-/react-hook-form-7.43.1.tgz#0d0d7822f3f7fc05ffc41d5f012b49b90fcfa0f0"
integrity sha512-Uz5kNlfkEZve0sr5y2LsxPDGN3eNDjjKb1XJgand1E5Ay0S3zWo8YcJQNGu88/Zs6JaFnrJqTXbSr6B2q7wllA== integrity sha512-+s3+s8LLytRMriwwuSqeLStVjRXFGxgjjx2jED7Z+wz1J/88vpxieRQGvJVvzrzVxshZ0BRuocFERb779m2kNg==
react-is@^16.13.1, react-is@^16.7.0: react-is@^16.13.1, react-is@^16.7.0:
version "16.13.1" version "16.13.1"