mirror of
https://github.com/hzrd149/nostrudel.git
synced 2025-10-10 12:53:14 +02:00
add simple profile edit view
This commit is contained in:
@@ -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",
|
||||||
|
@@ -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>
|
||||||
|
@@ -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 />}>
|
||||||
|
@@ -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}>
|
||||||
|
@@ -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
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 {
|
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[]>([]);
|
||||||
|
|
||||||
|
@@ -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} />;
|
||||||
};
|
};
|
||||||
|
@@ -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"
|
||||||
|
Reference in New Issue
Block a user