352 lines
14 KiB
TypeScript
352 lines
14 KiB
TypeScript
import { Dialog, Transition } from '@headlessui/react';
|
|
import { NDKEvent } from '@nostr-dev-kit/ndk';
|
|
import { useQueryClient } from '@tanstack/react-query';
|
|
import { fetch } from '@tauri-apps/plugin-http';
|
|
import { Fragment, useEffect, useState } from 'react';
|
|
import { useForm } from 'react-hook-form';
|
|
|
|
import { AvatarUploader } from '@shared/avatarUploader';
|
|
import { BannerUploader } from '@shared/bannerUploader';
|
|
import { CancelIcon, CheckCircleIcon, LoaderIcon, UnverifiedIcon } from '@shared/icons';
|
|
import { Image } from '@shared/image';
|
|
|
|
import { DEFAULT_AVATAR } from '@stores/constants';
|
|
|
|
import { useAccount } from '@utils/hooks/useAccount';
|
|
import { useNostr } from '@utils/hooks/useNostr';
|
|
|
|
export function EditProfileModal() {
|
|
const queryClient = useQueryClient();
|
|
|
|
const [isOpen, setIsOpen] = useState(false);
|
|
const [loading, setLoading] = useState(false);
|
|
const [picture, setPicture] = useState(DEFAULT_AVATAR);
|
|
const [banner, setBanner] = useState('');
|
|
const [nip05, setNIP05] = useState({ verified: false, text: '' });
|
|
|
|
const { publish } = useNostr();
|
|
const { account } = useAccount();
|
|
const {
|
|
register,
|
|
handleSubmit,
|
|
reset,
|
|
setError,
|
|
formState: { isValid, errors },
|
|
} = useForm({
|
|
defaultValues: async () => {
|
|
const res: any = queryClient.getQueryData(['user', account.pubkey]);
|
|
if (res.image) {
|
|
setPicture(res.image);
|
|
}
|
|
if (res.banner) {
|
|
setBanner(res.banner);
|
|
}
|
|
if (res.nip05) {
|
|
setNIP05((prev) => ({ ...prev, text: res.nip05 }));
|
|
}
|
|
return res;
|
|
},
|
|
});
|
|
|
|
const closeModal = () => {
|
|
setIsOpen(false);
|
|
};
|
|
|
|
const openModal = () => {
|
|
setIsOpen(true);
|
|
};
|
|
|
|
const verifyNIP05 = async (data: string) => {
|
|
if (data) {
|
|
const url = data.split('@');
|
|
const username = url[0];
|
|
const service = url[1];
|
|
const verifyURL = `https://${service}/.well-known/nostr.json?name=${username}`;
|
|
|
|
const res: any = await fetch(verifyURL, {
|
|
method: 'GET',
|
|
headers: {
|
|
'Content-Type': 'application/json; charset=utf-8',
|
|
},
|
|
});
|
|
|
|
if (!res.ok) return false;
|
|
if (res.data.names[username] === account.pubkey) {
|
|
setNIP05((prev) => ({ ...prev, verified: true }));
|
|
return true;
|
|
} else {
|
|
return false;
|
|
}
|
|
}
|
|
};
|
|
|
|
const onSubmit = async (data: any) => {
|
|
// start loading
|
|
setLoading(true);
|
|
|
|
let event: NDKEvent;
|
|
|
|
const content = {
|
|
...data,
|
|
username: data.name,
|
|
display_name: data.name,
|
|
bio: data.about,
|
|
image: data.picture,
|
|
};
|
|
|
|
if (data.nip05) {
|
|
const verify = await verifyNIP05(data.nip05);
|
|
if (verify) {
|
|
event = await publish({
|
|
content: JSON.stringify({ ...content, nip05: data.nip05 }),
|
|
kind: 0,
|
|
tags: [],
|
|
});
|
|
} else {
|
|
setNIP05((prev) => ({ ...prev, verified: false }));
|
|
setError('nip05', {
|
|
type: 'manual',
|
|
message: "Can't verify your Lume ID / NIP-05, please check again",
|
|
});
|
|
}
|
|
} else {
|
|
event = await publish({
|
|
content: JSON.stringify(content),
|
|
kind: 0,
|
|
tags: [],
|
|
});
|
|
}
|
|
|
|
if (event.id) {
|
|
setTimeout(() => {
|
|
// invalid cache
|
|
queryClient.invalidateQueries(['user', account.pubkey]);
|
|
// reset form
|
|
reset();
|
|
// reset state
|
|
setLoading(false);
|
|
setIsOpen(false);
|
|
setPicture(DEFAULT_AVATAR);
|
|
setBanner(null);
|
|
}, 1200);
|
|
} else {
|
|
setLoading(false);
|
|
}
|
|
};
|
|
|
|
useEffect(() => {
|
|
if (!nip05.verified && /\S+@\S+\.\S+/.test(nip05.text)) {
|
|
verifyNIP05(nip05.text);
|
|
}
|
|
}, [nip05.text]);
|
|
|
|
return (
|
|
<>
|
|
<button
|
|
type="button"
|
|
onClick={() => openModal()}
|
|
className="inline-flex h-10 w-36 items-center justify-center rounded-md bg-white/10 text-sm font-medium hover:bg-fuchsia-500"
|
|
>
|
|
Edit profile
|
|
</button>
|
|
<Transition appear show={isOpen} as={Fragment}>
|
|
<Dialog as="div" className="relative z-10" onClose={closeModal}>
|
|
<Transition.Child
|
|
as={Fragment}
|
|
enter="ease-out duration-300"
|
|
enterFrom="opacity-0"
|
|
enterTo="opacity-100"
|
|
leave="ease-in duration-200"
|
|
leaveFrom="opacity-100"
|
|
leaveTo="opacity-0"
|
|
>
|
|
<div className="fixed inset-0 z-50 bg-black bg-opacity-30 backdrop-blur-md" />
|
|
</Transition.Child>
|
|
<div className="fixed inset-0 z-50 flex min-h-full items-center justify-center">
|
|
<Transition.Child
|
|
as={Fragment}
|
|
enter="ease-out duration-300"
|
|
enterFrom="opacity-0 scale-95"
|
|
enterTo="opacity-100 scale-100"
|
|
leave="ease-in duration-200"
|
|
leaveFrom="opacity-100 scale-100"
|
|
leaveTo="opacity-0 scale-95"
|
|
>
|
|
<Dialog.Panel className="relative flex h-min w-full max-w-lg flex-col rounded-lg border-t border-zinc-800/50 bg-zinc-900">
|
|
<div className="h-min w-full shrink-0 border-b border-zinc-800 px-5 py-5">
|
|
<div className="flex items-center justify-between">
|
|
<Dialog.Title
|
|
as="h3"
|
|
className="text-lg font-semibold leading-none text-white"
|
|
>
|
|
Edit profile
|
|
</Dialog.Title>
|
|
<button
|
|
type="button"
|
|
onClick={closeModal}
|
|
className="inline-flex h-5 w-5 items-center justify-center rounded hover:bg-zinc-900"
|
|
>
|
|
<CancelIcon className="h-5 w-5 text-zinc-300" />
|
|
</button>
|
|
</div>
|
|
</div>
|
|
<div className="flex h-full w-full flex-col overflow-y-auto">
|
|
<form onSubmit={handleSubmit(onSubmit)} className="mb-0">
|
|
<input
|
|
type={'hidden'}
|
|
{...register('picture')}
|
|
value={picture}
|
|
className="shadow-input relative h-10 w-full rounded-lg border border-black/5 px-3 py-2 shadow-black/5 !outline-none placeholder:text-white/50 dark:bg-zinc-800 dark:text-white dark:shadow-black/10 dark:placeholder:text-white/50"
|
|
/>
|
|
<input
|
|
type={'hidden'}
|
|
{...register('banner')}
|
|
value={banner}
|
|
className="shadow-input relative h-10 w-full rounded-lg border border-black/5 px-3 py-2 shadow-black/5 !outline-none placeholder:text-white/50 dark:bg-zinc-800 dark:text-white dark:shadow-black/10 dark:placeholder:text-white/50"
|
|
/>
|
|
<div className="relative">
|
|
<div className="relative h-44 w-full bg-zinc-800">
|
|
<Image
|
|
src={banner}
|
|
fallback="https://void.cat/d/QY1myro5tkHVs2nY7dy74b.jpg"
|
|
alt="user's banner"
|
|
className="h-full w-full object-cover"
|
|
/>
|
|
<div className="absolute left-1/2 top-1/2 z-10 h-full w-full -translate-x-1/2 -translate-y-1/2 transform">
|
|
<BannerUploader setBanner={setBanner} />
|
|
</div>
|
|
</div>
|
|
<div className="mb-5 px-4">
|
|
<div className="relative z-10 -mt-7 h-14 w-14">
|
|
<Image
|
|
src={picture}
|
|
fallback={DEFAULT_AVATAR}
|
|
alt="user's avatar"
|
|
className="h-14 w-14 rounded-lg object-cover ring-2 ring-zinc-900"
|
|
/>
|
|
<div className="absolute left-1/2 top-1/2 z-10 h-full w-full -translate-x-1/2 -translate-y-1/2 transform">
|
|
<AvatarUploader setPicture={setPicture} />
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
<div className="flex flex-col gap-4 px-4 pb-4">
|
|
<div className="flex flex-col gap-1">
|
|
<label
|
|
htmlFor="name"
|
|
className="text-sm font-semibold uppercase tracking-wider text-white/50"
|
|
>
|
|
Name
|
|
</label>
|
|
<input
|
|
type={'text'}
|
|
{...register('name', {
|
|
required: true,
|
|
minLength: 4,
|
|
})}
|
|
spellCheck={false}
|
|
className="relative h-10 w-full rounded-lg bg-zinc-800 px-3 py-2 text-white !outline-none placeholder:text-white/50"
|
|
/>
|
|
</div>
|
|
<div className="flex flex-col gap-1">
|
|
<label
|
|
htmlFor="nip05"
|
|
className="text-sm font-semibold uppercase tracking-wider text-white/50"
|
|
>
|
|
Lume ID / NIP-05
|
|
</label>
|
|
<div className="relative">
|
|
<input
|
|
{...register('nip05', {
|
|
required: true,
|
|
minLength: 4,
|
|
})}
|
|
spellCheck={false}
|
|
className="relative h-10 w-full rounded-lg bg-zinc-800 px-3 py-2 text-white !outline-none placeholder:text-white/50"
|
|
/>
|
|
<div className="absolute right-2 top-1/2 -translate-y-1/2 transform">
|
|
{nip05.verified ? (
|
|
<span className="inline-flex h-6 items-center gap-1 rounded bg-green-500 px-2 text-sm font-medium">
|
|
<CheckCircleIcon className="h-4 w-4 text-white" />
|
|
Verified
|
|
</span>
|
|
) : (
|
|
<span className="inline-flex h-6 items-center gap-1 rounded bg-red-500 px-2 text-sm font-medium">
|
|
<UnverifiedIcon className="h-4 w-4 text-white" />
|
|
Unverified
|
|
</span>
|
|
)}
|
|
</div>
|
|
{errors.nip05 && (
|
|
<p className="mt-1 text-sm text-red-400">
|
|
{errors.nip05.message.toString()}
|
|
</p>
|
|
)}
|
|
</div>
|
|
</div>
|
|
<div className="flex flex-col gap-1">
|
|
<label
|
|
htmlFor="about"
|
|
className="text-sm font-semibold uppercase tracking-wider text-white/50"
|
|
>
|
|
Bio
|
|
</label>
|
|
<textarea
|
|
{...register('about')}
|
|
spellCheck={false}
|
|
className="relative h-20 w-full resize-none rounded-lg bg-zinc-800 px-3 py-2 text-white !outline-none placeholder:text-white/50"
|
|
/>
|
|
</div>
|
|
<div className="flex flex-col gap-1">
|
|
<label
|
|
htmlFor="website"
|
|
className="text-sm font-semibold uppercase tracking-wider text-white/50"
|
|
>
|
|
Website
|
|
</label>
|
|
<input
|
|
type={'text'}
|
|
{...register('website', { required: false })}
|
|
spellCheck={false}
|
|
className="relative h-10 w-full rounded-lg bg-zinc-800 px-3 py-2 text-white !outline-none placeholder:text-white/50"
|
|
/>
|
|
</div>
|
|
<div className="flex flex-col gap-1">
|
|
<label
|
|
htmlFor="website"
|
|
className="text-sm font-semibold uppercase tracking-wider text-white/50"
|
|
>
|
|
Lightning address
|
|
</label>
|
|
<input
|
|
type={'text'}
|
|
{...register('lud16', { required: false })}
|
|
spellCheck={false}
|
|
className="relative h-10 w-full rounded-lg bg-zinc-800 px-3 py-2 text-white !outline-none placeholder:text-white/50"
|
|
/>
|
|
</div>
|
|
<div>
|
|
<button
|
|
type="submit"
|
|
disabled={!isValid}
|
|
className="inline-flex h-11 w-full transform items-center justify-center gap-1 rounded-md bg-fuchsia-500 font-medium text-white hover:bg-fuchsia-600 focus:outline-none active:translate-y-1 disabled:pointer-events-none disabled:opacity-50"
|
|
>
|
|
{loading ? (
|
|
<LoaderIcon className="h-4 w-4 animate-spin text-black dark:text-white" />
|
|
) : (
|
|
'Update'
|
|
)}
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</form>
|
|
</div>
|
|
</Dialog.Panel>
|
|
</Transition.Child>
|
|
</div>
|
|
</Dialog>
|
|
</Transition>
|
|
</>
|
|
);
|
|
}
|