import { Collapsible, CollapsibleContent, CollapsibleTrigger } from '@/components/ui/collapsible' import Uploader from '@/components/PostEditor/Uploader' import ProfileBanner from '@/components/ProfileBanner' import { Dialog, DialogContent, DialogFooter, DialogHeader, DialogTitle } from '@/components/ui/dialog' import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar' import { Button } from '@/components/ui/button' import { Input } from '@/components/ui/input' import { Label } from '@/components/ui/label' import { Textarea } from '@/components/ui/textarea' import SecondaryPageLayout from '@/layouts/SecondaryPageLayout' import { createPaymentInfoDraftEvent, createProfileDraftEvent } from '@/lib/draft-event' import { generateImageByPubkey } from '@/lib/pubkey' import { isEmail } from '@/lib/utils' import { useSecondaryPage } from '@/PageManager' import { useNostr } from '@/providers/NostrProvider' import client from '@/services/client.service' import { ChevronDown, Loader, Pencil, RefreshCw, Upload } from 'lucide-react' import type { Event } from 'nostr-tools' import { forwardRef, useCallback, useEffect, useMemo, useState } from 'react' import { useTranslation } from 'react-i18next' import { toast } from 'sonner' const ProfileEditorPage = forwardRef(({ index }: { index?: number }, ref) => { const { t } = useTranslation() const { pop } = useSecondaryPage() const { account, profile, profileEvent, publish, updateProfileEvent } = useNostr() const [banner, setBanner] = useState('') const [avatar, setAvatar] = useState('') const [username, setUsername] = useState('') const [about, setAbout] = useState('') const [website, setWebsite] = useState('') const [nip05, setNip05] = useState('') const [nip05Error, setNip05Error] = useState('') const [lightningAddress, setLightningAddress] = useState('') const [lightningAddressError, setLightningAddressError] = useState('') const [hasChanged, setHasChanged] = useState(false) const [saving, setSaving] = useState(false) const [uploadingBanner, setUploadingBanner] = useState(false) const [uploadingAvatar, setUploadingAvatar] = useState(false) const [paymentInfoEvent, setPaymentInfoEvent] = useState(null) const [paymentInfoEditOpen, setPaymentInfoEditOpen] = useState(false) const [paymentInfoEditContent, setPaymentInfoEditContent] = useState('') const [paymentInfoEditTagsJson, setPaymentInfoEditTagsJson] = useState('[]') const [savingPaymentInfo, setSavingPaymentInfo] = useState(false) /** Editable full profile event (whole event as JSON string); synced from profileEvent. */ const [profileEventJson, setProfileEventJson] = useState('') const [savingFullProfile, setSavingFullProfile] = useState(false) const [refreshingCache, setRefreshingCache] = useState(false) const defaultImage = useMemo( () => (account ? generateImageByPubkey(account.pubkey) : undefined), [account] ) useEffect(() => { if (profile) { setBanner(profile.banner ?? '') setAvatar(profile.avatar ?? '') setUsername(profile.original_username ?? '') setAbout(profile.about ?? '') setWebsite(profile.website ?? '') setNip05(profile.nip05 ?? '') setLightningAddress(profile.lightningAddress || '') } else { setBanner('') setAvatar('') setUsername('') setAbout('') setWebsite('') setNip05('') setLightningAddress('') } }, [profile]) // Sync editable full profile event (entire event as JSON) from profileEvent useEffect(() => { if (profileEvent) { setProfileEventJson(JSON.stringify(profileEvent, null, 2)) } else { setProfileEventJson('') } }, [profileEvent]) // Fetch payment info event (kind 10133) for current user useEffect(() => { if (!account?.pubkey) { setPaymentInfoEvent(null) return } let cancelled = false client .fetchPaymentInfoEvent(account.pubkey) .then((evt) => { if (!cancelled) setPaymentInfoEvent(evt ?? null) }) .catch(() => { if (!cancelled) setPaymentInfoEvent(null) }) return () => { cancelled = true } }, [account?.pubkey]) const openPaymentInfoEditor = useCallback(() => { if (paymentInfoEvent) { setPaymentInfoEditContent( typeof paymentInfoEvent.content === 'string' ? paymentInfoEvent.content : JSON.stringify(paymentInfoEvent.content ?? '', null, 2) ) setPaymentInfoEditTagsJson( JSON.stringify(paymentInfoEvent.tags ?? [], null, 2) ) } else { setPaymentInfoEditContent('{}') setPaymentInfoEditTagsJson('[]') } setPaymentInfoEditOpen(true) }, [paymentInfoEvent]) const savePaymentInfo = useCallback(async () => { let tags: string[][] try { tags = JSON.parse(paymentInfoEditTagsJson) if (!Array.isArray(tags)) throw new Error('Tags must be an array') tags.forEach((t, i) => { if (!Array.isArray(t)) throw new Error(`Tag at index ${i} must be an array of strings`) }) } catch (e) { toast.error(t('Invalid tags JSON')) return } setSavingPaymentInfo(true) try { const draft = createPaymentInfoDraftEvent(paymentInfoEditContent.trim(), tags) const published = await publish(draft) await client.updatePaymentInfoCache(published) setPaymentInfoEvent(published) setPaymentInfoEditOpen(false) toast.success(t('Payment info updated')) } catch (err) { toast.error(t('Failed to publish payment info')) } finally { setSavingPaymentInfo(false) } }, [paymentInfoEditContent, paymentInfoEditTagsJson, publish, t]) if (!account || !profile) return null const save = async () => { if (nip05 && !isEmail(nip05)) { setNip05Error(t('Invalid NIP-05 address')) return } const oldProfileContent = profileEvent ? JSON.parse(profileEvent.content) : {} const newProfileContent = { ...oldProfileContent, display_name: username, displayName: username, name: oldProfileContent.name ?? username, about, website, nip05, banner, picture: avatar } if (lightningAddress) { if (isEmail(lightningAddress)) { newProfileContent.lud16 = lightningAddress } else if (lightningAddress.startsWith('lnurl')) { newProfileContent.lud06 = lightningAddress } else { setLightningAddressError(t('Invalid Lightning Address')) return } } else { delete newProfileContent.lud16 } setSaving(true) setHasChanged(false) const profileDraftEvent = createProfileDraftEvent( JSON.stringify(newProfileContent), profileEvent?.tags ?? [] ) const newProfileEvent = await publish(profileDraftEvent) await updateProfileEvent(newProfileEvent) setSaving(false) pop() } const onBannerUploadSuccess = ({ url }: { url: string }) => { setBanner(url) setHasChanged(true) } const onAvatarUploadSuccess = ({ url }: { url: string }) => { setAvatar(url) setHasChanged(true) } const forceRefreshProfileAndPaymentCache = useCallback(async () => { if (!account?.pubkey) return setRefreshingCache(true) try { await client.forceRefreshProfileAndPaymentInfoCache(account.pubkey) const [profileEvt, paymentEvt] = await Promise.all([ client.fetchProfileEvent(account.pubkey), client.fetchPaymentInfoEvent(account.pubkey) ]) if (profileEvt) await updateProfileEvent(profileEvt) setPaymentInfoEvent(paymentEvt ?? null) toast.success(t('Profile and payment cache refreshed')) } catch { toast.error(t('Failed to refresh cache')) } finally { setRefreshingCache(false) } }, [account?.pubkey, updateProfileEvent, t]) const saveFullProfile = async () => { let parsed: { kind?: number; content?: string; tags?: string[][] } try { const raw = JSON.parse(profileEventJson.trim()) if (raw === null || typeof raw !== 'object') throw new Error('Must be a JSON object') parsed = raw if (parsed.kind !== 0) throw new Error('kind must be 0') if (typeof parsed.content !== 'string') throw new Error('content must be a string') if (!Array.isArray(parsed.tags)) throw new Error('tags must be an array') parsed.tags.forEach((t: unknown, i: number) => { if (!Array.isArray(t)) throw new Error(`tag at index ${i} must be an array`) }) } catch (e) { toast.error(e instanceof Error ? e.message : t('Invalid profile JSON')) return } setSavingFullProfile(true) try { const profileDraftEvent = createProfileDraftEvent( parsed.content!, parsed.tags ?? [] ) const newProfileEvent = await publish(profileDraftEvent) await updateProfileEvent(newProfileEvent) setProfileEventJson(JSON.stringify(newProfileEvent, null, 2)) setHasChanged(false) toast.success(t('Profile updated')) } catch (err) { toast.error(t('Failed to publish profile')) } finally { setSavingFullProfile(false) } } const controls = (
) return (
setUploadingBanner(true)} onUploadEnd={() => setUploadingBanner(false)} className="w-full relative cursor-pointer" >
{uploadingBanner ? : }
setUploadingAvatar(true)} onUploadEnd={() => setUploadingAvatar(false)} className="w-24 h-24 absolute bottom-0 left-4 translate-y-1/2 border-4 border-background cursor-pointer rounded-full" >
{uploadingAvatar ? : }
{ setUsername(e.target.value) setHasChanged(true) }} />