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, AvatarIdenticon, 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 { Skeleton } from '@/components/ui/skeleton' import SecondaryPageLayout from '@/layouts/SecondaryPageLayout' import { createPaymentInfoDraftEvent, createProfileDraftEvent } from '@/lib/draft-event' import { generateImageByPubkey } from '@/lib/pubkey' import { syncUserDeletionTombstones } from '@/lib/sync-user-deletions' import { useSecondaryPage } from '@/PageManager' import { useNostr } from '@/providers/NostrProvider' import client from '@/services/client.service' import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select' import { canUseNostrBuildThumb, toNostrBuildThumbUrl } from '@/lib/nostr-build' import { isVideo } from '@/lib/url' import { EditorSortableList, SortableEditorRow } from '@/components/EditorSortableList' import PaymentMethodRow from '@/components/ProfileEditor/PaymentMethodRow' import { arrayMove } from '@dnd-kit/sortable' import { PAYTO_EDITOR_OTHER_OPTION } from '@/lib/payto' import { normalizePaypalAuthority } from '@/lib/payto-paypal-url' import { ChevronDown, Fingerprint, Pencil, Plus, RefreshCw, Trash2, Upload } from 'lucide-react' import type { Event } from 'nostr-tools' import { forwardRef, useCallback, useEffect, useMemo, useRef, useState } from 'react' import { useTranslation } from 'react-i18next' import { toastPublishPromise } from '@/lib/publishing-feedback' import { toast } from 'sonner' /** Required tag fields: always exactly one row, cannot be deleted. */ const SINGLETON_NAMES = ['display_name', 'name', 'about', 'picture', 'banner'] /** * Optional fields that may appear at most once. * Rows are deletable but a second instance is blocked. */ const UNIQUE_NAMES = ['bot', 'birthday'] /** Tags that may appear multiple times (nip05, lud16, website). */ const MULTI_NAMES = ['nip05', 'lud16', 'website'] /** Tags managed automatically by the client – hidden from the editor. */ const AUTO_NAMES = new Set(['alt', 'client']) /** All "at most one" names (singletons + unique-optional). */ const AT_MOST_ONE_NAMES = [...SINGLETON_NAMES, ...UNIQUE_NAMES] /** All named tags the editor knows about (label + display order). */ const KNOWN_NAMES = [...SINGLETON_NAMES, ...UNIQUE_NAMES, ...MULTI_NAMES] /** Canonical display order for the tag list. */ const DISPLAY_ORDER = ['display_name', 'name', 'about', 'picture', 'banner', 'nip05', 'lud16', 'website', 'bot', 'birthday'] /** Options shown in the "add tag" dropdown, in this order. */ const ADD_TAG_OPTIONS = ['nip05', 'lud16', 'website', 'bot', 'birthday'] as const const TAG_LABELS: Record = { display_name: 'Display Name', name: 'Name', about: 'Bio', picture: 'Profile Picture', banner: 'Banner', nip05: 'Nostr Address (NIP-05)', lud16: 'Lightning Address', website: 'Website', bot: 'Bot', birthday: 'Birthday', } /** * Profile banner & avatar file picker: all images and videos the OS/browser exposes as * `image/*` or `video/*`, plus common extensions when `File.type` is empty (e.g. Linux). * Banner and avatar sizes are limited after compression (`maxCompressedSizeMb` on each uploader). */ const PROFILE_IMAGE_VIDEO_UPLOADER_ACCEPT = [ 'image/*', 'video/*', '.mkv', '.m4v', '.mov', '.webm', '.ogv', '.avi', '.mpeg', '.mpg', '.mp4', '.3gp', '.wmv', '.flv', '.heic', '.heif', '.avif', '.apng', '.svg', '.webp', '.gif', '.png', '.jpg', '.jpeg', '.bmp', '.ico', 'video/x-matroska' ].join(',') const ProfileEditorPage = forwardRef(({ index }: { index?: number }, ref) => { const { t } = useTranslation() const { pop } = useSecondaryPage() const { account, profile, profileEvent, publish, updateProfileEvent, relayList, requestAccountNetworkHydrate } = useNostr() const [hasChanged, setHasChanged] = useState(false) const [saving, setSaving] = useState(false) const savingRef = useRef(false) const mountedRef = useRef(true) const [uploadingBanner, setUploadingBanner] = useState(false) const [uploadingAvatar, setUploadingAvatar] = useState(false) const [paymentInfoEvent, setPaymentInfoEvent] = useState(null) const [paymentInfoEditOpen, setPaymentInfoEditOpen] = useState(false) const [paymentInfoEditMethods, setPaymentInfoEditMethods] = useState([]) /** Kind 10133 `content` preserved from the opened event; not edited in the UI (payto tags only). */ const paymentInfoDraftContentRef = useRef('{}') const [savingPaymentInfo, setSavingPaymentInfo] = useState(false) const savingPaymentInfoRef = useRef(false) const [profileEventJson, setProfileEventJson] = useState('') const [savingFullProfile, setSavingFullProfile] = useState(false) const [refreshingCache, setRefreshingCache] = useState(false) /** Single source of truth: all profile tags (excluding auto-managed client/alt). */ const [profileTagRows, setProfileTagRows] = useState([]) const [imageUrlField, setImageUrlField] = useState<'picture' | 'banner' | null>(null) const [imageUrlDraft, setImageUrlDraft] = useState('') const [tagToAdd, setTagToAdd] = useState(ADD_TAG_OPTIONS[0]) const defaultImage = useMemo( () => (account ? generateImageByPubkey(account.pubkey) : undefined), [account] ) /** Derived from profile tag rows so uploaders and visual preview stay in sync. */ const avatar = profileTagRows.find((r) => r.tag[0] === 'picture')?.tag[1] ?? '' const banner = profileTagRows.find((r) => r.tag[0] === 'banner')?.tag[1] ?? '' useEffect(() => { mountedRef.current = true return () => { mountedRef.current = false } }, []) /** Block profileEvent → form sync while editing or while publish is in flight. */ const profileFormSyncLocked = hasChanged || saving || savingFullProfile // Rebuild tag list when the stored profile event changes — not while the user is editing. useEffect(() => { if (profileFormSyncLocked) return setProfileTagRows(tagRowsFromTags(buildTagListFromEvent(profileEvent ?? null))) }, [profileEvent, profileFormSyncLocked]) // Live full-event JSON preview from the current tag list (reorder, edit, add, remove). useEffect(() => { if (!profileEvent) return setProfileEventJson(buildProfileEventJsonFromTagRows(profileEvent, profileTagRows)) }, [profileTagRows, profileEvent]) // Fetch payment info (kind 10133). 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]) // ─── Tag list helpers ──────────────────────────────────────────────────────── const updateTagValue = (id: string, value: string) => { setProfileTagRows((prev) => prev.map((row) => row.id === id ? { ...row, tag: [row.tag[0], value, ...row.tag.slice(2)] } : row ) ) setHasChanged(true) } const updateTagName = (id: string, name: string) => { if (AT_MOST_ONE_NAMES.includes(name)) { const existing = profileTagRows.find((row) => row.tag[0] === name) if (existing && existing.id !== id) { toast.error(t('profileEditorDuplicateSingleton', { defaultValue: `"${name}" may only appear once` })) return } } setProfileTagRows((prev) => prev.map((row) => row.id === id ? { ...row, tag: [name, row.tag[1] ?? '', ...row.tag.slice(2)] } : row ) ) setHasChanged(true) } const removeTag = (id: string) => { setProfileTagRows((prev) => prev.filter((row) => row.id !== id)) setHasChanged(true) } const reorderProfileTagRows = (fromIndex: number, toIndex: number) => { setProfileTagRows((prev) => arrayMove(prev, fromIndex, toIndex)) setHasChanged(true) } const addTag = (name = '', value = '') => { if (name && AT_MOST_ONE_NAMES.includes(name) && profileTagRows.some((row) => row.tag[0] === name)) { toast.error(t('profileEditorDuplicateSingleton', { defaultValue: `"${name}" may only appear once` })) return } setProfileTagRows((prev) => [...prev, { id: newEditorId(), tag: [name, value] }]) setHasChanged(true) } // ─── Payment info ──────────────────────────────────────────────────────────── const paymentInfoPreviewJson = useMemo( () => JSON.stringify( createPaymentInfoDraftEvent( paymentInfoDraftContentRef.current, paymentMethodsToPaytoTags(paymentInfoEditMethods) ), null, 2 ), [paymentInfoEditMethods, paymentInfoEditOpen] ) const openPaymentInfoEditor = useCallback(() => { if (paymentInfoEvent) { paymentInfoDraftContentRef.current = typeof paymentInfoEvent.content === 'string' ? paymentInfoEvent.content : JSON.stringify(paymentInfoEvent.content ?? '', null, 2) const paytoTags = (paymentInfoEvent.tags ?? []).filter( (tag) => Array.isArray(tag) && tag[0] === 'payto' && tag[1] != null ) setPaymentInfoEditMethods( paytoTags.length > 0 ? paytoTags.map((tag) => ({ id: newEditorId(), type: (tag[1] as string) || 'lightning', authority: (tag[2] as string) || '' })) : [{ id: newEditorId(), type: 'lightning', authority: '' }] ) } else { paymentInfoDraftContentRef.current = '{}' setPaymentInfoEditMethods([{ id: newEditorId(), type: 'lightning', authority: '' }]) } setPaymentInfoEditOpen(true) }, [paymentInfoEvent]) const savePaymentInfo = useCallback(async () => { if (savingPaymentInfoRef.current) return const tags = paymentMethodsToPaytoTags(paymentInfoEditMethods) savingPaymentInfoRef.current = true setSavingPaymentInfo(true) try { const contentStr = paymentInfoDraftContentRef.current.trim() || '{}' const draft = createPaymentInfoDraftEvent(contentStr, tags) const published = await publish(draft) await client.updatePaymentInfoCache(published) setPaymentInfoEvent(published) setPaymentInfoEditOpen(false) toast.success(t('Payment info updated')) } catch { toast.error(t('Failed to publish payment info')) } finally { savingPaymentInfoRef.current = false setSavingPaymentInfo(false) } }, [paymentInfoEditMethods, publish, t]) // ─── Cache refresh ─────────────────────────────────────────────────────────── const forceRefreshProfileAndPaymentCache = useCallback(async () => { if (!account?.pubkey) return setRefreshingCache(true) try { await requestAccountNetworkHydrate() await syncUserDeletionTombstones(account.pubkey, relayList) await client.forceRefreshProfileAndPaymentInfoCache(account.pubkey) const [profileEvt, paymentEvt] = await Promise.all([ client.fetchProfileEvent(account.pubkey, false, { allowWideRelayFallback: true }), client.fetchPaymentInfoEvent(account.pubkey) ]) if (profileEvt) { await updateProfileEvent(profileEvt) const refreshedRows = tagRowsFromTags(buildTagListFromEvent(profileEvt)) setProfileTagRows(refreshedRows) setProfileEventJson(buildProfileEventJsonFromTagRows(profileEvt, refreshedRows)) setHasChanged(false) } setPaymentInfoEvent(paymentEvt ?? null) toast.success(t('Profile and payment cache refreshed')) } catch { toast.error(t('Failed to refresh cache')) } finally { setRefreshingCache(false) } }, [account?.pubkey, relayList, requestAccountNetworkHydrate, updateProfileEvent, t]) const refreshCacheControl = (
) // ─── Guards ────────────────────────────────────────────────────────────────── if (!account) return null if (!profile) { return (

{t('profileEditorProfileNotLoaded', { defaultValue: 'Profile not loaded. Try refreshing the cache.' })}

) } // ─── Handlers ──────────────────────────────────────────────────────────────── const openImageUrlEditor = (field: 'picture' | 'banner') => { setImageUrlField(field) setImageUrlDraft(profileTagRows.find((r) => r.tag[0] === field)?.tag[1] ?? '') } const applyImageUrlDraft = () => { if (!imageUrlField) return const v = imageUrlDraft.trim() setProfileTagRows((prev) => { const idx = prev.findIndex((row) => row.tag[0] === imageUrlField) return idx >= 0 ? prev.map((row, i) => i === idx ? { ...row, tag: [imageUrlField, v, ...row.tag.slice(2)] } : row ) : [...prev, { id: newEditorId(), tag: [imageUrlField, v] }] }) setHasChanged(true) setImageUrlField(null) } const onBannerUploadSuccess = ({ url }: { url: string }) => { setProfileTagRows((prev) => { const idx = prev.findIndex((row) => row.tag[0] === 'banner') return idx >= 0 ? prev.map((row, i) => i === idx ? { ...row, tag: ['banner', url, ...row.tag.slice(2)] } : row ) : [...prev, { id: newEditorId(), tag: ['banner', url] }] }) setHasChanged(true) } const onAvatarUploadSuccess = ({ url }: { url: string }) => { setProfileTagRows((prev) => { const idx = prev.findIndex((row) => row.tag[0] === 'picture') return idx >= 0 ? prev.map((row, i) => i === idx ? { ...row, tag: ['picture', url, ...row.tag.slice(2)] } : row ) : [...prev, { id: newEditorId(), tag: ['picture', url] }] }) setHasChanged(true) } // ─── Save ───────────────────────────────────────────────────────────────────── const save = () => { if (savingRef.current) return savingRef.current = true setSaving(true) const savePromise = (async () => { try { const { contentJson, orderedTags } = profileTagsToSavePayload(profileTagRows) const draft = createProfileDraftEvent(contentJson, orderedTags) const published = await publish(draft) await updateProfileEvent(published) if (!mountedRef.current) return setHasChanged(false) pop() } catch { if (mountedRef.current) setHasChanged(true) throw new Error(t('Failed to publish profile')) } finally { savingRef.current = false if (mountedRef.current) setSaving(false) } })() toastPublishPromise(savePromise, { loading: t('Saving…'), success: t('Profile updated'), error: () => t('Failed to publish profile') }) } 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((tag: unknown, i: number) => { if (!Array.isArray(tag)) 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 { toast.error(t('Failed to publish profile')) } finally { setSavingFullProfile(false) } } // ─── Render ─────────────────────────────────────────────────────────────────── return ( {/* Banner & avatar uploaders */}
{/* Banner under avatar in stacking order; fetchPriority still loads the pic first. */} setUploadingBanner(true)} onUploadEnd={() => setUploadingBanner(false)} className="relative z-0 w-full cursor-pointer overflow-hidden" accept={PROFILE_IMAGE_VIDEO_UPLOADER_ACCEPT} maxCompressedSizeMb={5} >
{uploadingBanner ? ( ) : ( )}
setUploadingAvatar(true)} onUploadEnd={() => setUploadingAvatar(false)} className="absolute bottom-0 left-4 z-20 h-24 w-24 translate-y-1/2 cursor-pointer rounded-full border-4 border-background md:h-48 md:w-48" accept={PROFILE_IMAGE_VIDEO_UPLOADER_ACCEPT} maxCompressedSizeMb={2} >
{isVideo(avatar) ? (
{uploadingAvatar ? ( ) : ( )}
{/* ── Unified tag list ── */}

{t('profileEditorTagListHint', { defaultValue: 'All profile fields as tags. Drag rows to reorder; tag order is saved on publish. The first of each known field also populates the content JSON.' })}

row.id)} onReorder={reorderProfileTagRows} className="space-y-1.5" > {profileTagRows.map((row) => { const tag = row.tag const name = tag[0] ?? '' const value = tag[1] ?? '' const isSingleton = SINGLETON_NAMES.includes(name) const isKnown = KNOWN_NAMES.includes(name) const isPic = name === 'picture' const isBan = name === 'banner' if (isPic || isBan) { return ( openImageUrlEditor(name as 'picture' | 'banner')} onInsertThumb={() => { const next = insertNostrBuildThumbUrl(value) if (next) { updateTagValue(row.id, next) } }} showThumbButton={isPic && canInsertNostrBuildThumb(value)} t={t} /> ) } return (
{isKnown ? (

{TAG_LABELS[name] || name}

) : ( updateTagName(row.id, e.target.value)} /> )}
{name === 'about' ? (