@ -1,19 +1,34 @@
@@ -1,19 +1,34 @@
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 { createProfileDraftEvent } from '@/lib/draft-event'
import { createPaymentInfoDraftEvent , createP rofileDraftEvent } from '@/lib/draft-event'
import { generateImageByPubkey } from '@/lib/pubkey'
import { isEmail } from '@/lib/utils'
import { useSecondaryPage } from '@/PageManager'
import { useNostr } from '@/providers/NostrProvider'
import { Loader , Upload } from 'lucide-react'
import { forwardRef , useEffect , useMemo , useState } from 'react'
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 ( )
@ -32,6 +47,15 @@ const ProfileEditorPage = forwardRef(({ index }: { index?: number }, ref) => {
@@ -32,6 +47,15 @@ const ProfileEditorPage = forwardRef(({ index }: { index?: number }, ref) => {
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 ]
@ -57,6 +81,79 @@ const ProfileEditorPage = forwardRef(({ index }: { index?: number }, ref) => {
@@ -57,6 +81,79 @@ const ProfileEditorPage = forwardRef(({ index }: { index?: number }, ref) => {
}
} , [ 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 ( ) = > {
@ -95,7 +192,7 @@ const ProfileEditorPage = forwardRef(({ index }: { index?: number }, ref) => {
@@ -95,7 +192,7 @@ const ProfileEditorPage = forwardRef(({ index }: { index?: number }, ref) => {
setHasChanged ( false )
const profileDraftEvent = createProfileDraftEvent (
JSON . stringify ( newProfileContent ) ,
profileEvent ? . tags
profileEvent ? . tags ? ? [ ]
)
const newProfileEvent = await publish ( profileDraftEvent )
await updateProfileEvent ( newProfileEvent )
@ -113,8 +210,72 @@ const ProfileEditorPage = forwardRef(({ index }: { index?: number }, ref) => {
@@ -113,8 +210,72 @@ const ProfileEditorPage = forwardRef(({ index }: { index?: number }, ref) => {
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" >
< 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 >
@ -219,7 +380,119 @@ const ProfileEditorPage = forwardRef(({ index }: { index?: number }, ref) => {
@@ -219,7 +380,119 @@ const ProfileEditorPage = forwardRef(({ index }: { index?: number }, ref) => {
< 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 >
)
} )