You can not select more than 25 topics
Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
504 lines
19 KiB
504 lines
19 KiB
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<string>('') |
|
const [avatar, setAvatar] = useState<string>('') |
|
const [username, setUsername] = useState<string>('') |
|
const [about, setAbout] = useState<string>('') |
|
const [website, setWebsite] = useState<string>('') |
|
const [nip05, setNip05] = useState<string>('') |
|
const [nip05Error, setNip05Error] = useState<string>('') |
|
const [lightningAddress, setLightningAddress] = useState<string>('') |
|
const [lightningAddressError, setLightningAddressError] = useState<string>('') |
|
const [hasChanged, setHasChanged] = useState(false) |
|
const [saving, setSaving] = useState(false) |
|
const [uploadingBanner, setUploadingBanner] = useState(false) |
|
const [uploadingAvatar, setUploadingAvatar] = useState(false) |
|
const [paymentInfoEvent, setPaymentInfoEvent] = useState<Event | null>(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<string>('') |
|
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 = ( |
|
<div className="pr-3 flex items-center gap-2"> |
|
<Button |
|
variant="outline" |
|
size="sm" |
|
onClick={forceRefreshProfileAndPaymentCache} |
|
disabled={refreshingCache} |
|
className="gap-1.5" |
|
title={t('Force-refresh profile and payment info from relays')} |
|
> |
|
{refreshingCache ? <Loader className="h-3.5 w-3.5 animate-spin" /> : <RefreshCw className="h-3.5 w-3.5" />} |
|
{t('Refresh cache')} |
|
</Button> |
|
<Button className="w-16 rounded-full" onClick={save} disabled={saving || !hasChanged}> |
|
{saving ? <Loader className="animate-spin" /> : t('Save')} |
|
</Button> |
|
</div> |
|
) |
|
|
|
return ( |
|
<SecondaryPageLayout ref={ref} index={index} title={profile.username} controls={controls}> |
|
<div className="relative bg-cover bg-center mb-2"> |
|
<Uploader |
|
onUploadSuccess={onBannerUploadSuccess} |
|
onUploadStart={() => setUploadingBanner(true)} |
|
onUploadEnd={() => setUploadingBanner(false)} |
|
className="w-full relative cursor-pointer" |
|
> |
|
<ProfileBanner banner={banner} pubkey={account.pubkey} className="w-full aspect-[3/1]" /> |
|
<div className="absolute top-0 bg-muted/30 w-full h-full flex flex-col justify-center items-center"> |
|
{uploadingBanner ? <Loader size={36} className="animate-spin" /> : <Upload size={36} />} |
|
</div> |
|
</Uploader> |
|
<Uploader |
|
onUploadSuccess={onAvatarUploadSuccess} |
|
onUploadStart={() => 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" |
|
> |
|
<Avatar className="w-full h-full"> |
|
<AvatarImage src={avatar} className="object-cover object-center" /> |
|
<AvatarFallback> |
|
<img src={defaultImage} /> |
|
</AvatarFallback> |
|
</Avatar> |
|
<div className="absolute top-0 bg-muted/30 w-full h-full rounded-full flex flex-col justify-center items-center"> |
|
{uploadingAvatar ? <Loader className="animate-spin" /> : <Upload />} |
|
</div> |
|
</Uploader> |
|
</div> |
|
<div className="pt-14 px-4 flex flex-col gap-4"> |
|
<Item> |
|
<Label htmlFor="profile-username-input">{t('Display Name')}</Label> |
|
<Input |
|
id="profile-username-input" |
|
value={username} |
|
onChange={(e) => { |
|
setUsername(e.target.value) |
|
setHasChanged(true) |
|
}} |
|
/> |
|
</Item> |
|
<Item> |
|
<Label htmlFor="profile-about-textarea">{t('Bio')}</Label> |
|
<Textarea |
|
id="profile-about-textarea" |
|
className="h-44" |
|
value={about} |
|
onChange={(e) => { |
|
setAbout(e.target.value) |
|
setHasChanged(true) |
|
}} |
|
/> |
|
</Item> |
|
<Item> |
|
<Label htmlFor="profile-website-input">{t('Website')}</Label> |
|
<Input |
|
id="profile-website-input" |
|
value={website} |
|
onChange={(e) => { |
|
setWebsite(e.target.value) |
|
setHasChanged(true) |
|
}} |
|
/> |
|
</Item> |
|
<Item> |
|
<Label htmlFor="profile-nip05-input">{t('Nostr Address (NIP-05)')}</Label> |
|
<Input |
|
id="profile-nip05-input" |
|
value={nip05} |
|
onChange={(e) => { |
|
setNip05Error('') |
|
setNip05(e.target.value) |
|
setHasChanged(true) |
|
}} |
|
className={nip05Error ? 'border-destructive' : ''} |
|
/> |
|
{nip05Error && <div className="text-xs text-destructive pl-3">{nip05Error}</div>} |
|
</Item> |
|
<Item> |
|
<Label htmlFor="profile-lightning-address-input"> |
|
{t('Lightning Address (or LNURL)')} |
|
</Label> |
|
<Input |
|
id="profile-lightning-address-input" |
|
value={lightningAddress} |
|
onChange={(e) => { |
|
setLightningAddressError('') |
|
setLightningAddress(e.target.value) |
|
setHasChanged(true) |
|
}} |
|
className={lightningAddressError ? 'border-destructive' : ''} |
|
/> |
|
{lightningAddressError && ( |
|
<div className="text-xs text-destructive pl-3">{lightningAddressError}</div> |
|
)} |
|
</Item> |
|
|
|
{/* Full profile event (kind 0): editable entire event as JSON */} |
|
{profileEvent && ( |
|
<Item> |
|
<Collapsible defaultOpen={false}> |
|
<CollapsibleTrigger className="flex items-center gap-2 font-medium"> |
|
<ChevronDown className="h-4 w-4 transition-transform [[data-state=open]_&]:rotate-180" /> |
|
{t('Full profile event')} |
|
</CollapsibleTrigger> |
|
<CollapsibleContent className="pt-4 space-y-4"> |
|
<div> |
|
<Label htmlFor="profile-event-json" className="text-muted-foreground"> |
|
{t('Event (JSON)')} |
|
</Label> |
|
<Textarea |
|
id="profile-event-json" |
|
className="mt-1 font-mono text-xs min-h-64" |
|
value={profileEventJson} |
|
onChange={(e) => { |
|
setProfileEventJson(e.target.value) |
|
setHasChanged(true) |
|
}} |
|
placeholder='{"id":"...","pubkey":"...","created_at":0,"kind":0,"tags":[],"content":"{}","sig":"..."}' |
|
/> |
|
</div> |
|
<Button |
|
onClick={saveFullProfile} |
|
disabled={savingFullProfile || !hasChanged} |
|
className="gap-2" |
|
> |
|
{savingFullProfile && <Loader className="h-4 w-4 animate-spin" />} |
|
{savingFullProfile ? t('Saving…') : t('Save full profile')} |
|
</Button> |
|
</CollapsibleContent> |
|
</Collapsible> |
|
</Item> |
|
)} |
|
|
|
{/* Payment info (kind 10133): stringified content + tags + Edit button */} |
|
<Item> |
|
<div className="flex items-center justify-between gap-2"> |
|
<Label className="text-muted-foreground">{t('Payment info')} (kind 10133)</Label> |
|
<Button variant="outline" size="sm" onClick={openPaymentInfoEditor} className="shrink-0"> |
|
<Pencil className="h-3.5 w-3.5 mr-1" /> |
|
{paymentInfoEvent ? t('Edit payment info') : t('Add payment info')} |
|
</Button> |
|
</div> |
|
<Collapsible> |
|
<CollapsibleTrigger className="flex items-center gap-2 text-sm text-muted-foreground mt-1"> |
|
<ChevronDown className="h-4 w-4 transition-transform [[data-state=open]_&]:rotate-180" /> |
|
{t('Raw payment info event')} |
|
</CollapsibleTrigger> |
|
<CollapsibleContent className="pt-2 space-y-2"> |
|
{paymentInfoEvent ? ( |
|
<> |
|
<div> |
|
<Label className="text-muted-foreground text-xs">{t('Content (JSON)')}</Label> |
|
<pre className="mt-1 p-3 rounded-md bg-muted text-xs overflow-auto max-h-48 break-all whitespace-pre-wrap"> |
|
{paymentInfoEvent.content || '{}'} |
|
</pre> |
|
</div> |
|
<div> |
|
<Label className="text-muted-foreground text-xs">{t('Tags')}</Label> |
|
<pre className="mt-1 p-3 rounded-md bg-muted text-xs overflow-auto max-h-48"> |
|
{JSON.stringify(paymentInfoEvent.tags ?? [], null, 2)} |
|
</pre> |
|
</div> |
|
</> |
|
) : ( |
|
<p className="text-sm text-muted-foreground">{t('No payment info event yet. Click "Add payment info" to create one.')}</p> |
|
)} |
|
</CollapsibleContent> |
|
</Collapsible> |
|
</Item> |
|
</div> |
|
|
|
{/* Edit payment info dialog */} |
|
<Dialog open={paymentInfoEditOpen} onOpenChange={setPaymentInfoEditOpen}> |
|
<DialogContent className="max-w-2xl max-h-[90vh] overflow-hidden flex flex-col"> |
|
<DialogHeader> |
|
<DialogTitle>{t('Edit payment info')} (kind 10133)</DialogTitle> |
|
</DialogHeader> |
|
<div className="flex-1 overflow-auto space-y-4"> |
|
<Item> |
|
<Label htmlFor="payment-info-content">{t('Content (JSON)')}</Label> |
|
<Textarea |
|
id="payment-info-content" |
|
className="font-mono text-sm min-h-32" |
|
value={paymentInfoEditContent} |
|
onChange={(e) => setPaymentInfoEditContent(e.target.value)} |
|
/> |
|
</Item> |
|
<Item> |
|
<Label htmlFor="payment-info-tags">{t('Tags (JSON array of arrays, e.g. [["payto","lightning","user@domain.com"]])')}</Label> |
|
<Textarea |
|
id="payment-info-tags" |
|
className="font-mono text-sm min-h-24" |
|
value={paymentInfoEditTagsJson} |
|
onChange={(e) => setPaymentInfoEditTagsJson(e.target.value)} |
|
/> |
|
</Item> |
|
</div> |
|
<DialogFooter> |
|
<Button variant="outline" onClick={() => setPaymentInfoEditOpen(false)}> |
|
{t('Cancel')} |
|
</Button> |
|
<Button onClick={savePaymentInfo} disabled={savingPaymentInfo} className="gap-2"> |
|
{savingPaymentInfo && <Loader className="h-4 w-4 animate-spin" />} |
|
{savingPaymentInfo ? t('Saving…') : t('Save')} |
|
</Button> |
|
</DialogFooter> |
|
</DialogContent> |
|
</Dialog> |
|
</SecondaryPageLayout> |
|
) |
|
}) |
|
ProfileEditorPage.displayName = 'ProfileEditorPage' |
|
export default ProfileEditorPage |
|
|
|
function Item({ children }: { children: React.ReactNode }) { |
|
return <div className="grid gap-2">{children}</div> |
|
}
|
|
|