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-dom": "^18.2.0",
"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-singleton-hook": "^4.0.1",
"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 accountService from "../../services/account";
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 AccountSwitcher from "./account-switcher";
@@ -27,6 +27,9 @@ export default function DesktopSideNav() {
<Button onClick={() => navigate("/notifications")} leftIcon={<NotificationIcon />}>
Notifications
</Button>
<Button onClick={() => navigate("/profile")} leftIcon={<ProfileIcon />}>
Profile
</Button>
<Button onClick={() => navigate("/relays")} leftIcon={<RelayIcon />}>
Relays
</Button>

View File

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

View File

@@ -5,14 +5,14 @@ import { unique } from "../helpers/array";
export type RelayUrlInputProps = Omit<InputProps, "type">;
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[]>)
);
const relaySuggestions = unique(relaysJson ?? []);
return (
<>
<Input list="relay-suggestions" type="url" isDisabled={props.isDisabled ?? loadingRelaysJson} {...props} />
<Input list="relay-suggestions" type="url" {...props} />
<datalist id="relay-suggestions">
{relaySuggestions.map((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));
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 {
bootstrapRelays = new Set<string>();
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 },
]);
relays = new PersistentSubject<RelayConfig[]>([]);
writeRelays = 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 { useMemo } from "react";
import { Avatar, Button, Flex, FormControl, FormLabel, Input, Textarea, useToast } from "@chakra-ui/react";
import moment from "moment";
import { useEffect, useMemo } from "react";
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 useSubject from "../../hooks/use-subject";
import { useIsMobile } from "../../hooks/use-is-mobile";
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 = {
displayName?: string;
username?: string;
picture?: string;
about?: string;
website?: string;
lightningAddress?: string;
};
type MetadataFormProps = {
@@ -19,50 +35,112 @@ type 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,
});
const submit = handleSubmit(onSubmit);
useEffect(() => {
reset(defaultValues);
}, [defaultValues]);
return (
<form onSubmit={submit}>
<Flex direction="column" gap="2" pt="4">
<Flex gap="2">
<Flex direction="column" pb="4" overflow="auto" px={isMobile ? "2" : 0}>
<form onSubmit={handleSubmit(onSubmit)}>
<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>
<FormLabel>Display Name</FormLabel>
<Input {...register("displayName", { maxLength: 100 })} />
<FormLabel>Website</FormLabel>
<Input
type="url"
autoComplete="off"
placeholder="https://example.com"
isDisabled={isSubmitting}
isInvalid={!!errors.website}
{...register("website", { maxLength: 300 })}
/>
</FormControl>
<FormControl>
<FormLabel>Username</FormLabel>
<Input {...register("username", { maxLength: 100 })} />
<FormLabel>About</FormLabel>
<Textarea
placeholder="A short description"
resize="vertical"
rows={6}
isDisabled={isSubmitting}
isInvalid={!!errors.about}
{...register("about")}
/>
</FormControl>
</Flex>
<Flex gap="2" alignItems="center">
<FormControl>
<FormLabel>Picture</FormLabel>
<Input {...register("picture", { maxLength: 150 })} />
<FormLabel>Lightning Address (or LNURL)</FormLabel>
<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>
<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>
<FormControl>
<FormLabel>About</FormLabel>
<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>
</form>
</Flex>
);
};
export const ProfileEditView = () => {
const writeRelays = useWriteRelayUrls();
const readRelays = useReadRelayUrls();
const toast = useToast();
const account = useCurrentAccount();
const metadata = useUserMetadata(account.pubkey);
const metadata = useUserMetadata(account.pubkey, readRelays, true);
const defaultValues = useMemo<FormData>(
() => ({
@@ -70,13 +148,50 @@ export const ProfileEditView = () => {
username: metadata?.name,
picture: metadata?.picture,
about: metadata?.about,
website: metadata?.website,
lightningAddress: metadata?.lud16 || metadata?.lud06,
}),
[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} />;
};

View File

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