mirror of
https://github.com/hzrd149/nostrudel.git
synced 2025-09-26 11:37:40 +02:00
add simple profile edit view
This commit is contained in:
@@ -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",
|
||||
|
@@ -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>
|
||||
|
@@ -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 />}>
|
||||
|
@@ -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}>
|
||||
|
@@ -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
10
src/helpers/lnurl.ts
Normal 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;
|
||||
}
|
||||
}
|
@@ -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[]>([]);
|
||||
|
||||
|
@@ -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} />;
|
||||
};
|
||||
|
@@ -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"
|
||||
|
Reference in New Issue
Block a user