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, X, Plus, Bot, BadgeCheck, HelpCircle, Server } 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 { SkateNip05Input } from '@/components/SkateNip05Input'; import { SKATE_NIP05_DOMAIN, parseSkateNip05 } from '@/lib/skateNip05'; import { isValidNoffer, isValidSilentPayment, isValidLightningAddress } from '@/lib/types'; 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(); // 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(); 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 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); } }, [metadata, form]); // 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: 'Success', description: `${field === 'picture' ? 'Profile picture' : 'Banner'} uploaded successfully`, }); } catch (error) { console.error(`Failed to upload ${field}:`, error); toast({ title: 'Error', description: `Failed to upload ${field === 'picture' ? 'profile picture' : 'banner'}. Please try again.`, variant: 'destructive', }); } }; const onSubmit = async (values: NostrMetadata) => { if (!user) { toast({ title: 'Error', description: 'You must be logged in to update your profile', variant: 'destructive', }); return; } if (nofferInvalid) { toast({ title: 'Invalid CLINK code', description: 'That doesn\'t look like a valid noffer1… code. Clear it or paste a valid one.', variant: 'destructive', }); return; } if (lud16Invalid) { toast({ title: 'Invalid Lightning address', description: 'Use an email-like address such as you@walletofsatoshi.com.', variant: 'destructive', }); return; } if (spInvalid) { toast({ title: 'Invalid Silent Payment address', description: 'A Silent Payment address starts with "sp1" and is quite long. Clear it or paste a valid one.', variant: 'destructive', }); return; } if (cordnInvalid) { toast({ title: 'Invalid Cordn link', description: 'Paste a full https://… Cordn contact link.', 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 = nofferTrimmed.toLowerCase(); } 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; // 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; } // Invalidate queries to refresh the data queryClient.invalidateQueries({ queryKey: ['logins'] }); queryClient.invalidateQueries({ queryKey: ['author', user.pubkey] }); toast({ title: 'Success', description: 'Your profile has been updated', }); } catch (error) { console.error('Failed to update profile:', error); toast({ title: 'Error', description: 'Failed to publish profile. Check your relay connections in Settings > Relays', variant: 'destructive', }); } }; const saving = isPending || isUploading; return (
{/* ── Section: Identity ── */} } title="Identity" description="How you show up across SkateSpots." accent="from-[#ff0040] to-[#ff6600]" > ( Display name )} /> ( Bio