From 48421482178bd8980218b2e29d781c335b083e30 Mon Sep 17 00:00:00 2001 From: Silberengel Date: Mon, 6 Apr 2026 17:29:50 +0200 Subject: [PATCH] reform profile edit page --- .../secondary/ProfileEditorPage/index.tsx | 786 ++++++++++-------- 1 file changed, 462 insertions(+), 324 deletions(-) diff --git a/src/pages/secondary/ProfileEditorPage/index.tsx b/src/pages/secondary/ProfileEditorPage/index.tsx index 0f70f90f..0e7aa216 100644 --- a/src/pages/secondary/ProfileEditorPage/index.tsx +++ b/src/pages/secondary/ProfileEditorPage/index.tsx @@ -21,17 +21,56 @@ import { Skeleton } from '@/components/ui/skeleton' import SecondaryPageLayout from '@/layouts/SecondaryPageLayout' import { createPaymentInfoDraftEvent, createProfileDraftEvent } from '@/lib/draft-event' import { generateImageByPubkey } from '@/lib/pubkey' -import { isEmail } from '@/lib/utils' 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 { ChevronDown, Fingerprint, Pencil, Plus, RefreshCw, Trash2, 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' +/** 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', +} + const ProfileEditorPage = forwardRef(({ index }: { index?: number }, ref) => { const { t } = useTranslation() const { pop } = useSecondaryPage() @@ -44,15 +83,7 @@ const ProfileEditorPage = forwardRef(({ index }: { index?: number }, ref) => { relayList, requestAccountNetworkHydrate } = 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) @@ -60,82 +91,85 @@ const ProfileEditorPage = forwardRef(({ index }: { index?: number }, ref) => { const [paymentInfoEvent, setPaymentInfoEvent] = useState(null) const [paymentInfoEditOpen, setPaymentInfoEditOpen] = useState(false) const [paymentInfoEditContent, setPaymentInfoEditContent] = useState('') - /** Payment method rows for kind 10133: each is a payto tag ["payto", type, authority]. */ const [paymentInfoEditMethods, setPaymentInfoEditMethods] = useState>([]) const [paymentInfoShowFullJson, setPaymentInfoShowFullJson] = useState(false) 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) - /** Editable tag list for kind 0 (e.g. lud16, nip05, website). Each row is [name, value]. */ + /** Single source of truth: all profile tags (excluding auto-managed client/alt). */ const [profileTags, setProfileTags] = useState([]) - /** Dialog to set picture/banner URL from JSON fields (alternative to top uploaders). */ 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] ) - 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]) + /** Derived from profileTags so uploaders and visual preview stay in sync. */ + const avatar = profileTags.find((t) => t[0] === 'picture')?.[1] ?? '' + const banner = profileTags.find((t) => t[0] === 'banner')?.[1] ?? '' - // Sync editable full profile event (entire event as JSON) from profileEvent + // Rebuild tag list whenever the stored profile event changes. useEffect(() => { - if (profileEvent) { - setProfileEventJson(JSON.stringify(profileEvent, null, 2)) - } else { - setProfileEventJson('') - } + setProfileTags(buildTagListFromEvent(profileEvent ?? null)) }, [profileEvent]) - // Sync tag list from profileEvent (kind 0 tags) + // Sync full-event JSON editor. useEffect(() => { - if (profileEvent?.tags?.length) { - setProfileTags(profileEvent.tags.map((t) => [...t])) - } else { - setProfileTags([]) - } + setProfileEventJson(profileEvent ? JSON.stringify(profileEvent, null, 2) : '') }, [profileEvent]) - // Fetch payment info event (kind 10133) for current user + // Fetch payment info (kind 10133). useEffect(() => { - if (!account?.pubkey) { - setPaymentInfoEvent(null) - return - } + 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 - } + .then((evt) => { if (!cancelled) setPaymentInfoEvent(evt ?? null) }) + .catch(() => { if (!cancelled) setPaymentInfoEvent(null) }) + return () => { cancelled = true } }, [account?.pubkey]) + // ─── Tag list helpers ──────────────────────────────────────────────────────── + + const updateTagValue = (idx: number, value: string) => { + setProfileTags((prev) => prev.map((t, i) => (i === idx ? [t[0], value, ...t.slice(2)] : t))) + setHasChanged(true) + } + + const updateTagName = (idx: number, name: string) => { + // Prevent renaming to a name that may only appear once and is already occupied. + if (AT_MOST_ONE_NAMES.includes(name)) { + const existingIdx = profileTags.findIndex((t) => t[0] === name) + if (existingIdx !== -1 && existingIdx !== idx) { + toast.error(t('profileEditorDuplicateSingleton', { defaultValue: `"${name}" may only appear once` })) + return + } + } + setProfileTags((prev) => prev.map((t, i) => (i === idx ? [name, t[1] ?? '', ...t.slice(2)] : t))) + setHasChanged(true) + } + + const removeTag = (idx: number) => { + setProfileTags((prev) => prev.filter((_, i) => i !== idx)) + setHasChanged(true) + } + + const addTag = (name = '', value = '') => { + // Prevent adding a second row for any "at most one" tag. + if (name && AT_MOST_ONE_NAMES.includes(name) && profileTags.some((t) => t[0] === name)) { + toast.error(t('profileEditorDuplicateSingleton', { defaultValue: `"${name}" may only appear once` })) + return + } + setProfileTags((prev) => [...prev, [name, value]]) + setHasChanged(true) + } + + // ─── Payment info ──────────────────────────────────────────────────────────── + const openPaymentInfoEditor = useCallback(() => { if (paymentInfoEvent) { setPaymentInfoEditContent( @@ -169,86 +203,25 @@ const ProfileEditorPage = forwardRef(({ index }: { index?: number }, ref) => { setSavingPaymentInfo(true) try { const contentStr = paymentInfoEditContent.trim() || '{}' - let content = contentStr - try { - JSON.parse(contentStr) - } catch { + try { JSON.parse(contentStr) } catch { toast.error(t('Invalid content JSON')) setSavingPaymentInfo(false) return } - const draft = createPaymentInfoDraftEvent(content, tags) + 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 (err) { + } catch { toast.error(t('Failed to publish payment info')) } finally { setSavingPaymentInfo(false) } }, [paymentInfoEditContent, paymentInfoEditMethods, publish, t]) - 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 - } - - const tagsToSave = profileTags - .filter((tag) => Array.isArray(tag) && tag.length >= 2 && tag[0].trim() && tag[1].trim()) - .filter((tag) => !isPictureOrBannerTagName(tag[0])) - .map((tag) => [tag[0].trim(), tag[1].trim(), ...(tag.slice(2) || [])]) - if (avatar.trim()) tagsToSave.push(['picture', avatar.trim()]) - if (banner.trim()) tagsToSave.push(['banner', banner.trim()]) - setSaving(true) - setHasChanged(false) - const profileDraftEvent = createProfileDraftEvent( - JSON.stringify(newProfileContent), - tagsToSave - ) - 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) - } + // ─── Cache refresh ─────────────────────────────────────────────────────────── const forceRefreshProfileAndPaymentCache = useCallback(async () => { if (!account?.pubkey) return @@ -271,9 +244,10 @@ const ProfileEditorPage = forwardRef(({ index }: { index?: number }, ref) => { } }, [account?.pubkey, relayList, requestAccountNetworkHydrate, updateProfileEvent, t]) + // ─── Guards ────────────────────────────────────────────────────────────────── + if (!account) return null - // Profile still loading: show the header with the Refresh Cache button so the user isn't stuck. if (!profile) { const loadingControls = (
@@ -284,7 +258,11 @@ const ProfileEditorPage = forwardRef(({ index }: { index?: number }, ref) => { disabled={refreshingCache} className="gap-1.5" > - {refreshingCache ? : } + {refreshingCache ? ( + + ) : ( + + )} {t('Refresh cache')}
@@ -303,20 +281,96 @@ const ProfileEditorPage = forwardRef(({ index }: { index?: number }, ref) => { ) } + // ─── Handlers ──────────────────────────────────────────────────────────────── + const openImageUrlEditor = (field: 'picture' | 'banner') => { setImageUrlField(field) - setImageUrlDraft(field === 'picture' ? avatar : banner) + setImageUrlDraft(profileTags.find((t) => t[0] === field)?.[1] ?? '') } const applyImageUrlDraft = () => { if (!imageUrlField) return const v = imageUrlDraft.trim() - if (imageUrlField === 'picture') setAvatar(v) - else setBanner(v) + setProfileTags((prev) => { + const idx = prev.findIndex((t) => t[0] === imageUrlField) + return idx >= 0 + ? prev.map((t, i) => (i === idx ? [imageUrlField, v, ...t.slice(2)] : t)) + : [...prev, [imageUrlField, v]] + }) setHasChanged(true) setImageUrlField(null) } + const onBannerUploadSuccess = ({ url }: { url: string }) => { + setProfileTags((prev) => { + const idx = prev.findIndex((t) => t[0] === 'banner') + return idx >= 0 + ? prev.map((t, i) => (i === idx ? ['banner', url, ...t.slice(2)] : t)) + : [...prev, ['banner', url]] + }) + setHasChanged(true) + } + + const onAvatarUploadSuccess = ({ url }: { url: string }) => { + setProfileTags((prev) => { + const idx = prev.findIndex((t) => t[0] === 'picture') + return idx >= 0 + ? prev.map((t, i) => (i === idx ? ['picture', url, ...t.slice(2)] : t)) + : [...prev, ['picture', url]] + }) + setHasChanged(true) + } + + // ─── Save ───────────────────────────────────────────────────────────────────── + + const save = async () => { + setSaving(true) + setHasChanged(false) + try { + // Strip empty/incomplete rows, trim whitespace. + const validTags = profileTags + .filter((t) => Array.isArray(t) && t.length >= 2 && (t[0] ?? '').trim() && (t[1] ?? '').trim()) + .map((t) => [t[0].trim(), t[1].trim(), ...t.slice(2)]) + + // Sort alphabetically by tag name (stable: same-name tags keep their relative order). + const sortedTags = [...validTags] + .sort((a, b) => a[0].localeCompare(b[0])) + // Enforce at-most-one uniqueness: keep only the first occurrence. + .filter((() => { + const seen = new Set() + return (t: string[]) => { + if (!AT_MOST_ONE_NAMES.includes(t[0])) return true + if (seen.has(t[0])) return false + seen.add(t[0]) + return true + } + })()) + + // Derive content JSON: first occurrence of each known field. + const content: Record = {} + const seenContent = new Set() + for (const tag of sortedTags) { + const name = tag[0] + if (DISPLAY_ORDER.includes(name) && !seenContent.has(name)) { + content[name] = tag[1] + seenContent.add(name) + } + } + // Keep displayName alias for backward compatibility. + if (content['display_name']) content['displayName'] = content['display_name'] + + const draft = createProfileDraftEvent(JSON.stringify(content), sortedTags) + const published = await publish(draft) + await updateProfileEvent(published) + pop() + } catch { + toast.error(t('Failed to publish profile')) + setHasChanged(true) + } finally { + setSaving(false) + } + } + const saveFullProfile = async () => { let parsed: { kind?: number; content?: string; tags?: string[][] } try { @@ -326,8 +380,8 @@ const ProfileEditorPage = forwardRef(({ index }: { index?: number }, ref) => { 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`) + 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')) @@ -335,22 +389,21 @@ const ProfileEditorPage = forwardRef(({ index }: { index?: number }, ref) => { } setSavingFullProfile(true) try { - const profileDraftEvent = createProfileDraftEvent( - parsed.content!, - parsed.tags ?? [] - ) + 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) { + } catch { toast.error(t('Failed to publish profile')) } finally { setSavingFullProfile(false) } } + // ─── Controls ───────────────────────────────────────────────────────────────── + const controls = (
) + // ─── Render ─────────────────────────────────────────────────────────────────── + return ( + {/* Banner & avatar uploaders */}
{ >
- {uploadingBanner ? : } + {uploadingBanner ? ( + + ) : ( + + )}
{
- {uploadingAvatar ? : } + {uploadingAvatar ? ( + + ) : ( + + )}
-
- - - { - setUsername(e.target.value) - setHasChanged(true) - }} - /> - - - -