import React, { useEffect, useRef, useState } from 'react'; import { useForm } from 'react-hook-form'; import { zodResolver } from '@hookform/resolvers/zod'; import { useCurrentUser } from '@/hooks/useCurrentUser'; import { useNostrPublish } from '@/hooks/useNostrPublish'; import { useToast } from '@/hooks/useToast'; import { Button } from '@/components/ui/button'; import { useCoordinator } from '@/hooks/useCoordinator'; import { coordinatorAddUrl, parseCoordinatorInput } from '@/lib/coordinator'; import { Form, FormControl, FormDescription, FormField, FormItem, FormLabel, FormMessage, } from '@/components/ui/form'; import { Input } from '@/components/ui/input'; import { Textarea } from '@/components/ui/textarea'; import { Switch } from '@/components/ui/switch'; import { Label } from '@/components/ui/label'; import { Loader2, Upload, Zap, ShieldCheck, AlertCircle, Bitcoin, Award, MessageSquareLock, UserRound, Handshake, X, Plus, Bot, BadgeCheck, HelpCircle, Server, AtSign } from 'lucide-react'; import { NSchema as n, type NostrMetadata } from '@nostrify/nostrify'; import { useQueryClient } from '@tanstack/react-query'; import { useUploadFile } from '@/hooks/useUploadFile'; import { useCrewNip05 } from '@/hooks/useCrewNip05'; import { useProfileHandles, useSetProfileHandles } from '@/hooks/useProfileHandles'; import { Nip05HandlesEditor } from '@/components/Nip05HandlesEditor'; import { SkateNip05Input } from '@/components/SkateNip05Input'; import { SKATE_NIP05_DOMAIN, parseSkateNip05 } from '@/lib/skateNip05'; import { isValidNoffer, isValidSilentPayment, isValidLightningAddress } from '@/lib/types'; import { normalizeClinkCode } from '@/lib/clink'; import { useTranslation } from '@/contexts/language'; export const EditProfileForm: React.FC = () => { const queryClient = useQueryClient(); const { user, metadata } = useCurrentUser(); const { mutateAsync: publishEvent, isPending } = useNostrPublish(); const { mutateAsync: uploadFile, isPending: isUploading } = useUploadFile(); const { toast } = useToast(); const { t } = useTranslation(); // CLINK Offer (noffer1...) — a self-custodial Bitcoin tip code. Not part of // the standard NostrMetadata schema, so we keep it in its own state and merge // it into the kind 0 content JSON on save (preserved as a custom field). const [noffer, setNoffer] = useState(''); const nofferTrimmed = noffer.trim(); const nofferInvalid = nofferTrimmed.length > 0 && !isValidNoffer(nofferTrimmed); const nofferValid = isValidNoffer(nofferTrimmed); // Lightning address (LUD-16). Part of NostrMetadata but not in the default // form fields, so we manage it here and merge it on save. const [lud16, setLud16] = useState(''); const lud16Trimmed = lud16.trim().replace(/^lightning:\/\//i, '').replace(/^lightning:/i, ''); const lud16Invalid = lud16Trimmed.length > 0 && !isValidLightningAddress(lud16Trimmed); // Bitcoin Silent Payment address (BIP-352). Custom kind-0 field. const [silentPayment, setSilentPayment] = useState(''); const spTrimmed = silentPayment.trim(); const spInvalid = spTrimmed.length > 0 && !isValidSilentPayment(spTrimmed); const spValid = isValidSilentPayment(spTrimmed); // Badges — a label + optional link each, stored as a `badges` array in kind-0. // Used for sponsors/backers, and rendered as clickable chips on the profile. const [badgeList, setBadgeList] = useState>([ { label: '', url: '' }, ]); // Cordn contact link — private (MLS-encrypted) group messaging you can own. // The coordinator others can use to message you on Cordn. Accepts the same // pointer formats as Settings → Coordinator (nprofile / cordn.net link / // npub / hex) as well as an http(s) URL — this is the discovery field. const [cordn, setCordn] = useState(''); const [openToSponsorships, setOpenToSponsorships] = useState(false); const [sponsorNote, setSponsorNote] = useState(''); // Extra personal NIP-05 handles — stored in a separate kind-30078 record so // another client re-saving the profile can't drop them. const { data: savedHandles } = useProfileHandles(user?.pubkey); const { setHandles } = useSetProfileHandles(); const [extraHandles, setExtraHandles] = useState([]); const handlesSeededRef = useRef(false); const cordnTrimmed = cordn.trim(); const cordnParsed = cordnTrimmed ? parseCoordinatorInput(cordnTrimmed) : null; const cordnInvalid = cordnTrimmed.length > 0 && !cordnParsed && !/^https?:\/\/.+/i.test(cordnTrimmed); const { coordinator: myCoordinator } = useCoordinator(); // Initialize the form with default values const form = useForm({ resolver: zodResolver(n.metadata()), defaultValues: { name: '', about: '', picture: '', banner: '', website: '', nip05: '', bot: false, }, }); // Update form values when user data is loaded useEffect(() => { if (metadata) { form.reset({ name: metadata.name || '', about: metadata.about || '', picture: metadata.picture || '', banner: metadata.banner || '', website: metadata.website || '', nip05: metadata.nip05 || '', bot: metadata.bot || false, }); // Prefill custom / extra fields from the raw metadata. const raw = metadata as Record; if (typeof raw.noffer === 'string') setNoffer(raw.noffer); if (typeof metadata.lud16 === 'string') setLud16(metadata.lud16); if (typeof raw.bitcoin_silent_payment === 'string') setSilentPayment(raw.bitcoin_silent_payment); // Badges: prefer the structured array; fall back to legacy `sponsors` lines. const rawBadges = Array.isArray(raw.badges) ? raw.badges : null; if (rawBadges) { const rows = rawBadges .map((b) => (b && typeof b === 'object' ? (b as Record) : {})) .map((b) => ({ label: String(b.label ?? ''), url: String(b.url ?? '') })) .filter((b) => b.label.trim()); setBadgeList(rows.length ? rows : [{ label: '', url: '' }]); } else if (typeof raw.sponsors === 'string') { const lines = raw.sponsors.split('\n').map((s) => s.trim()).filter(Boolean); setBadgeList( lines.length ? lines.map((label) => ({ label, url: '' })) : [{ label: '', url: '' }], ); } if (typeof raw.cordn === 'string') setCordn(raw.cordn); setOpenToSponsorships(raw.sponsorships_open === true); if (typeof raw.sponsorships_note === 'string') setSponsorNote(raw.sponsorships_note); } }, [metadata, form]); // Seed the extra handles once from the separate record. useEffect(() => { if (handlesSeededRef.current || !savedHandles) return; handlesSeededRef.current = true; setExtraHandles(savedHandles); }, [savedHandles]); // Handle file uploads for profile picture and banner const uploadPicture = async (file: File, field: 'picture' | 'banner') => { try { // The first tuple in the array contains the URL const [[_, url]] = await uploadFile(file); form.setValue(field, url); toast({ title: t('settingsUi.editProfile.uploadSuccessTitle'), description: t('settingsUi.editProfile.uploadSuccessDesc', { field: field === 'picture' ? t('settingsUi.editProfile.profilePicture') : t('settingsUi.editProfile.bannerImage') }), }); } catch (error) { console.error(`Failed to upload ${field}:`, error); toast({ title: t('common.error'), description: t('settingsUi.editProfile.uploadErrorDesc', { field: field === 'picture' ? t('settingsUi.editProfile.profilePicture') : t('settingsUi.editProfile.bannerImage') }), variant: 'destructive', }); } }; const onSubmit = async (values: NostrMetadata) => { if (!user) { toast({ title: t('common.error'), description: t('settingsUi.editProfile.mustBeLoggedIn'), variant: 'destructive', }); return; } if (nofferInvalid) { toast({ title: t('settingsUi.editProfile.invalidClinkTitle'), description: t('settingsUi.editProfile.invalidClinkDesc'), variant: 'destructive', }); return; } if (lud16Invalid) { toast({ title: t('settingsUi.editProfile.invalidLightningTitle'), description: t('settingsUi.editProfile.invalidLightningDesc'), variant: 'destructive', }); return; } if (spInvalid) { toast({ title: t('settingsUi.editProfile.invalidSpTitle'), description: t('settingsUi.editProfile.invalidSpDesc'), variant: 'destructive', }); return; } if (cordnInvalid) { toast({ title: t('settingsUi.editProfile.invalidCordnTitle'), description: t('settingsUi.editProfile.invalidCordnDesc'), variant: 'destructive', }); return; } try { // Combine existing metadata with new values const data: Record = { ...metadata, ...values }; // Clean up empty values for (const key in data) { if (data[key] === '') { delete data[key]; } } // Merge the custom CLINK offer code (or remove it when cleared). if (nofferValid) { data.noffer = normalizeClinkCode(nofferTrimmed); } else { delete data.noffer; } // Lightning address (LUD-16). if (lud16Trimmed) { data.lud16 = lud16Trimmed; } else { delete data.lud16; } // Bitcoin Silent Payment address (custom field). if (spValid) { data.bitcoin_silent_payment = spTrimmed; } else { delete data.bitcoin_silent_payment; } // Badges (custom field) — label + optional link, shown as chips on profile. const cleanBadges = badgeList .map((b) => ({ label: b.label.trim(), url: b.url.trim() })) .filter((b) => b.label) .map((b) => (b.url ? { label: b.label, url: b.url } : { label: b.label })); if (cleanBadges.length) { data.badges = cleanBadges; } else { delete data.badges; } // Retire the legacy field now that badges supersede it. delete data.sponsors; // Sponsorship opt-in (custom kind-0 fields). The profile only claims // this when the skater explicitly turned it on. if (openToSponsorships) { data.sponsorships_open = true; if (sponsorNote.trim()) data.sponsorships_note = sponsorNote.trim(); else delete data.sponsorships_note; } else { delete data.sponsorships_open; delete data.sponsorships_note; } // Cordn contact link (custom field). if (cordnTrimmed) { // Normalise a bare pointer (nprofile/npub/hex) to Cordn's canonical // add-link, so the profile carries a human-clickable URL other clients // can parse back. A plain http(s) URL is stored as-is. data.cordn = cordnParsed ? coordinatorAddUrl(cordnParsed) : cordnTrimmed; } else { delete data.cordn; } // Publish the metadata event (kind 0) with retry logic let attempts = 0; let lastError; while (attempts < 3) { try { await publishEvent({ kind: 0, content: JSON.stringify(data), }); // Success! Break out of retry loop break; } catch (err) { lastError = err; attempts++; // Wait a bit before retrying if (attempts < 3) { await new Promise(resolve => setTimeout(resolve, 1000)); } } } // If all attempts failed, throw the last error if (attempts === 3 && lastError) { throw lastError; } // Extra handles live in a separate record — only publish when changed. const before = [...(savedHandles ?? [])].sort().join(','); const after = [...extraHandles].sort().join(','); if (before !== after) { await setHandles(extraHandles); } // Invalidate queries to refresh the data queryClient.invalidateQueries({ queryKey: ['logins'] }); queryClient.invalidateQueries({ queryKey: ['author', user.pubkey] }); toast({ title: t('settingsUi.editProfile.updateSuccessTitle'), description: t('settingsUi.editProfile.updateSuccessDesc'), }); } catch (error) { console.error('Failed to update profile:', error); toast({ title: t('common.error'), description: t('settingsUi.editProfile.updateErrorDesc'), variant: 'destructive', }); } }; const saving = isPending || isUploading; return (
{/* ── Section: Identity ── */} } title={t('settingsUi.editProfile.identityTitle')} description={t('settingsUi.editProfile.identityDesc')} accent="from-[#ff0040] to-[#ff6600]" > ( {t('settingsUi.editProfile.displayName')} )} /> ( {t('settingsUi.editProfile.bio')}