You can not select more than 25 topics
Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
1094 lines
41 KiB
1094 lines
41 KiB
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<string, string> = { |
|
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<Event | null>(null) |
|
const [paymentInfoEditOpen, setPaymentInfoEditOpen] = useState(false) |
|
const [paymentInfoEditMethods, setPaymentInfoEditMethods] = useState<EditorPaymentMethodRow[]>([]) |
|
/** 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<string>('') |
|
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<EditorTagRow[]>([]) |
|
const [imageUrlField, setImageUrlField] = useState<'picture' | 'banner' | null>(null) |
|
const [imageUrlDraft, setImageUrlDraft] = useState('') |
|
const [tagToAdd, setTagToAdd] = useState<string>(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 = ( |
|
<div className="pr-3 flex flex-wrap items-center justify-end gap-2 min-w-0"> |
|
<Button |
|
variant="outline" |
|
size="sm" |
|
onClick={forceRefreshProfileAndPaymentCache} |
|
disabled={refreshingCache} |
|
className="gap-1.5 max-w-full" |
|
title={t('profileEditorRefreshCacheHint', { |
|
defaultValue: |
|
'Full account sync from relays (like Settings → Cache), deletion tombstones, then profile and payment info.' |
|
})} |
|
> |
|
{refreshingCache ? ( |
|
<Skeleton className="size-3.5 shrink-0 rounded-sm" aria-hidden /> |
|
) : ( |
|
<RefreshCw className="h-3.5 w-3.5" /> |
|
)} |
|
{t('Refresh cache')} |
|
</Button> |
|
</div> |
|
) |
|
|
|
// ─── Guards ────────────────────────────────────────────────────────────────── |
|
|
|
if (!account) return null |
|
|
|
if (!profile) { |
|
return ( |
|
<SecondaryPageLayout ref={ref} index={index} title="…" controls={refreshCacheControl}> |
|
<div className="flex flex-col items-center justify-center gap-3 py-16 text-muted-foreground text-sm"> |
|
<Skeleton className="h-4 w-48 rounded" /> |
|
<p> |
|
{t('profileEditorProfileNotLoaded', { |
|
defaultValue: 'Profile not loaded. Try refreshing the cache.' |
|
})} |
|
</p> |
|
</div> |
|
</SecondaryPageLayout> |
|
) |
|
} |
|
|
|
// ─── 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 ( |
|
<SecondaryPageLayout ref={ref} index={index} title={profile.username} controls={refreshCacheControl}> |
|
{/* Banner & avatar uploaders */} |
|
<div className="relative isolate mb-2 bg-cover bg-center"> |
|
{/* Banner under avatar in stacking order; fetchPriority still loads the pic first. */} |
|
<Uploader |
|
onUploadSuccess={onBannerUploadSuccess} |
|
onUploadStart={() => setUploadingBanner(true)} |
|
onUploadEnd={() => setUploadingBanner(false)} |
|
className="relative z-0 w-full cursor-pointer overflow-hidden" |
|
accept={PROFILE_IMAGE_VIDEO_UPLOADER_ACCEPT} |
|
maxCompressedSizeMb={5} |
|
> |
|
<ProfileBanner |
|
banner={banner} |
|
pubkey={account.pubkey} |
|
className="w-full aspect-[3/1]" |
|
imageFetchPriority="low" |
|
/> |
|
<div className="absolute inset-0 flex flex-col items-center justify-center bg-muted/30"> |
|
{uploadingBanner ? ( |
|
<Skeleton className="size-9 shrink-0 rounded-md" aria-hidden /> |
|
) : ( |
|
<Upload size={36} /> |
|
)} |
|
</div> |
|
</Uploader> |
|
<Uploader |
|
onUploadSuccess={onAvatarUploadSuccess} |
|
onUploadStart={() => 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} |
|
> |
|
<div className="h-full w-full overflow-hidden rounded-full bg-muted"> |
|
{isVideo(avatar) ? ( |
|
<video |
|
src={avatar} |
|
className="h-full w-full object-cover object-center" |
|
autoPlay |
|
muted |
|
loop |
|
playsInline |
|
fetchPriority="high" |
|
/> |
|
) : ( |
|
<Avatar className="h-full w-full"> |
|
<AvatarImage |
|
src={avatar || defaultImage} |
|
className="object-cover object-center" |
|
fetchPriority="high" |
|
loading="eager" |
|
/> |
|
<AvatarFallback delayMs={0}> |
|
{defaultImage ? <AvatarIdenticon src={defaultImage} /> : null} |
|
</AvatarFallback> |
|
</Avatar> |
|
)} |
|
</div> |
|
<div className="absolute inset-0 flex flex-col items-center justify-center rounded-full bg-muted/30"> |
|
{uploadingAvatar ? ( |
|
<Skeleton className="size-4 shrink-0 rounded-sm" aria-hidden /> |
|
) : ( |
|
<Upload /> |
|
)} |
|
</div> |
|
</Uploader> |
|
</div> |
|
|
|
<div className="pt-14 px-4 pb-16 flex flex-col gap-4 max-sm:pb-24"> |
|
{/* ── Unified tag list ── */} |
|
<Item> |
|
<Label className="text-muted-foreground">{t('Tag list')}</Label> |
|
<p className="text-xs text-muted-foreground"> |
|
{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.' |
|
})} |
|
</p> |
|
<EditorSortableList |
|
itemIds={profileTagRows.map((row) => 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 ( |
|
<SortableEditorRow key={row.id} id={row.id}> |
|
<ProfileImageTagRow |
|
tagName={name as 'picture' | 'banner'} |
|
value={value} |
|
onEdit={() => openImageUrlEditor(name as 'picture' | 'banner')} |
|
onInsertThumb={() => { |
|
const next = insertNostrBuildThumbUrl(value) |
|
if (next) { |
|
updateTagValue(row.id, next) |
|
} |
|
}} |
|
showThumbButton={isPic && canInsertNostrBuildThumb(value)} |
|
t={t} |
|
/> |
|
</SortableEditorRow> |
|
) |
|
} |
|
|
|
return ( |
|
<SortableEditorRow key={row.id} id={row.id}> |
|
<div className="flex flex-wrap gap-2 items-start min-w-0"> |
|
<div className="w-full shrink-0 sm:w-28 sm:flex-none"> |
|
{isKnown ? ( |
|
<p |
|
className="text-xs font-medium text-muted-foreground pt-2 truncate" |
|
title={TAG_LABELS[name] || name} |
|
> |
|
{TAG_LABELS[name] || name} |
|
</p> |
|
) : ( |
|
<Input |
|
value={name} |
|
placeholder={t('Tag name')} |
|
className="font-mono text-xs h-8" |
|
onChange={(e) => updateTagName(row.id, e.target.value)} |
|
/> |
|
)} |
|
</div> |
|
|
|
{name === 'about' ? ( |
|
<Textarea |
|
className="flex-1 text-sm min-h-[5rem] resize-y" |
|
value={value} |
|
onChange={(e) => updateTagValue(row.id, e.target.value)} |
|
/> |
|
) : ( |
|
<Input |
|
className="flex-1 font-mono text-sm" |
|
value={value} |
|
onChange={(e) => updateTagValue(row.id, e.target.value)} |
|
/> |
|
)} |
|
|
|
{!isSingleton && ( |
|
<Button |
|
type="button" |
|
variant="ghost" |
|
size="icon" |
|
className="shrink-0 text-muted-foreground hover:text-destructive mt-0.5" |
|
onClick={() => removeTag(row.id)} |
|
aria-label={t('Remove')} |
|
> |
|
<Trash2 className="h-4 w-4" /> |
|
</Button> |
|
)} |
|
</div> |
|
</SortableEditorRow> |
|
) |
|
})} |
|
</EditorSortableList> |
|
|
|
{/* Add-tag row: dropdown + single + button */} |
|
<div className="flex flex-wrap gap-2 pt-1 items-center min-w-0"> |
|
<Select value={tagToAdd} onValueChange={setTagToAdd}> |
|
<SelectTrigger className="min-w-0 flex-1 basis-full h-8 text-sm sm:basis-0"> |
|
<SelectValue /> |
|
</SelectTrigger> |
|
<SelectContent> |
|
{ADD_TAG_OPTIONS.map((name) => ( |
|
<SelectItem key={name} value={name}> |
|
{TAG_LABELS[name] || name} |
|
</SelectItem> |
|
))} |
|
<SelectItem value="__custom__">{t('Custom tag…')}</SelectItem> |
|
</SelectContent> |
|
</Select> |
|
<Button |
|
type="button" |
|
variant="outline" |
|
size="icon" |
|
className="h-8 w-8 shrink-0" |
|
onClick={() => { |
|
const name = tagToAdd === '__custom__' ? '' : tagToAdd |
|
addTag(name, name === 'bot' ? 'true' : '') |
|
}} |
|
aria-label={t('Add tag')} |
|
> |
|
<Plus className="h-4 w-4" /> |
|
</Button> |
|
</div> |
|
</Item> |
|
|
|
<div className="pb-2"> |
|
<Button |
|
className="w-full rounded-full" |
|
onClick={save} |
|
disabled={saving || !hasChanged} |
|
> |
|
{saving ? <Skeleton className="mx-auto h-4 w-12 rounded-md" aria-hidden /> : t('Save')} |
|
</Button> |
|
</div> |
|
|
|
{/* ── Full profile event JSON (collapsible) ── */} |
|
{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 && ( |
|
<Skeleton className="size-4 shrink-0 rounded-sm" aria-hidden /> |
|
)} |
|
{savingFullProfile ? t('Saving…') : t('Save full profile')} |
|
</Button> |
|
</CollapsibleContent> |
|
</Collapsible> |
|
</Item> |
|
)} |
|
|
|
{/* ── Payment info (kind 10133) ── */} |
|
<Item> |
|
<div className="flex flex-wrap items-center justify-between gap-2"> |
|
<Label className="text-muted-foreground shrink-0">{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> |
|
</Item> |
|
</div> |
|
|
|
{/* ── Dialogs ── */} |
|
|
|
{/* Edit picture/banner URL */} |
|
<Dialog |
|
open={imageUrlField !== null} |
|
onOpenChange={(open) => { |
|
if (!open) setImageUrlField(null) |
|
}} |
|
> |
|
<DialogContent className="max-w-lg"> |
|
<DialogHeader> |
|
<DialogTitle> |
|
{imageUrlField === 'picture' |
|
? t('profileEditorEditPictureUrl', { defaultValue: 'Edit profile picture URL' }) |
|
: t('profileEditorEditBannerUrl', { defaultValue: 'Edit banner URL' })} |
|
</DialogTitle> |
|
</DialogHeader> |
|
<div className="space-y-2"> |
|
<Label htmlFor="profile-image-url-draft">{t('URL')}</Label> |
|
<Input |
|
id="profile-image-url-draft" |
|
className="font-mono text-sm" |
|
value={imageUrlDraft} |
|
onChange={(e) => setImageUrlDraft(e.target.value)} |
|
placeholder="https://" |
|
/> |
|
<p className="text-xs text-muted-foreground"> |
|
{t('profileEditorImageUrlHint', { |
|
defaultValue: |
|
'Saved in kind 0 tags as picture or banner. You can paste a link from a previous upload instead of using the uploader above.' |
|
})} |
|
</p> |
|
</div> |
|
<DialogFooter> |
|
<Button variant="outline" onClick={() => setImageUrlField(null)}> |
|
{t('Cancel')} |
|
</Button> |
|
<Button onClick={applyImageUrlDraft}>{t('Save')}</Button> |
|
</DialogFooter> |
|
</DialogContent> |
|
</Dialog> |
|
|
|
{/* Edit payment info */} |
|
<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 pb-6"> |
|
<Item> |
|
<Label className="text-muted-foreground">{t('Payment targets')}</Label> |
|
<p className="text-xs text-muted-foreground"> |
|
{t('paytoEditor.intro', { |
|
defaultValue: |
|
'Choose a payment type, then enter the address or username shown in the hint below each field. Drag rows to reorder payto tags on save.' |
|
})} |
|
</p> |
|
<div className="space-y-3"> |
|
<EditorSortableList |
|
itemIds={paymentInfoEditMethods.map((row) => row.id)} |
|
onReorder={(from, to) => { |
|
setPaymentInfoEditMethods((prev) => arrayMove(prev, from, to)) |
|
}} |
|
className="space-y-3" |
|
> |
|
{paymentInfoEditMethods.map((row) => ( |
|
<SortableEditorRow key={row.id} id={row.id}> |
|
<PaymentMethodRow |
|
row={row} |
|
onChange={(next) => { |
|
setPaymentInfoEditMethods((prev) => |
|
prev.map((m) => (m.id === row.id ? { ...m, ...next } : m)) |
|
) |
|
}} |
|
onRemove={() => |
|
setPaymentInfoEditMethods((prev) => prev.filter((m) => m.id !== row.id)) |
|
} |
|
/> |
|
</SortableEditorRow> |
|
))} |
|
</EditorSortableList> |
|
<Button |
|
type="button" |
|
variant="outline" |
|
size="sm" |
|
className="gap-1" |
|
onClick={() => |
|
setPaymentInfoEditMethods((prev) => [ |
|
...prev, |
|
{ id: newEditorId(), type: 'lightning', authority: '' } |
|
]) |
|
} |
|
> |
|
<Plus className="h-3.5 w-3.5" /> |
|
{t('Add payment target')} |
|
</Button> |
|
</div> |
|
</Item> |
|
<Item> |
|
<Label className="text-muted-foreground">{t('Event (JSON)')}</Label> |
|
<p className="text-xs text-muted-foreground"> |
|
{t('paytoEditor.jsonPreviewHint', { |
|
defaultValue: |
|
'Live preview of the kind 10133 event that will be published. Payto tag order matches the list above.' |
|
})} |
|
</p> |
|
<pre className="mt-2 p-3 rounded-md bg-muted text-xs overflow-auto max-h-64 break-all whitespace-pre-wrap border font-mono"> |
|
{paymentInfoPreviewJson} |
|
</pre> |
|
</Item> |
|
</div> |
|
<DialogFooter> |
|
<Button variant="outline" onClick={() => setPaymentInfoEditOpen(false)}> |
|
{t('Cancel')} |
|
</Button> |
|
<Button type="button" onClick={savePaymentInfo} disabled={savingPaymentInfo} className="gap-2"> |
|
{savingPaymentInfo && <Skeleton className="size-4 shrink-0 rounded-sm" aria-hidden />} |
|
{savingPaymentInfo ? t('Saving…') : t('Save')} |
|
</Button> |
|
</DialogFooter> |
|
</DialogContent> |
|
</Dialog> |
|
</SecondaryPageLayout> |
|
) |
|
}) |
|
ProfileEditorPage.displayName = 'ProfileEditorPage' |
|
export default ProfileEditorPage |
|
|
|
// ─── Pure helpers (no React) ────────────────────────────────────────────────── |
|
|
|
type EditorTagRow = { id: string; tag: string[] } |
|
type EditorPaymentMethodRow = { id: string; type: string; authority: string } |
|
|
|
function newEditorId(): string { |
|
return crypto.randomUUID() |
|
} |
|
|
|
function tagRowsFromTags(tags: string[][]): EditorTagRow[] { |
|
return tags.map((tag) => ({ id: newEditorId(), tag })) |
|
} |
|
|
|
/** Valid tags + content JSON from editor rows (tag list order is preserved). */ |
|
function profileTagsToSavePayload(rows: EditorTagRow[]): { |
|
contentJson: string |
|
orderedTags: string[][] |
|
} { |
|
const validTags = rows |
|
.map((row) => row.tag) |
|
.filter((t) => { |
|
if (!Array.isArray(t) || !(t[0] ?? '').trim()) return false |
|
const name = (t[0] ?? '').trim() |
|
if (name === 'bot') return true |
|
return t.length >= 2 && (t[1] ?? '').trim() |
|
}) |
|
.map((t) => { |
|
const name = (t[0] ?? '').trim() |
|
const v1 = (t[1] ?? '').trim() |
|
if (name === 'bot') { |
|
if (t.length === 1 || !v1) return ['bot'] |
|
const low = v1.toLowerCase() |
|
if (low === 'false') return ['bot', 'false'] |
|
if (low === 'true') return ['bot', 'true'] |
|
return ['bot', v1] |
|
} |
|
return [name, v1, ...t.slice(2)] |
|
}) |
|
|
|
const orderedTags = validTags.filter((() => { |
|
const seen = new Set<string>() |
|
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 |
|
} |
|
})()) |
|
|
|
const content: Record<string, string> = {} |
|
const seenContent = new Set<string>() |
|
for (const tag of orderedTags) { |
|
const name = tag[0] |
|
if (name === 'bot') continue |
|
if (DISPLAY_ORDER.includes(name) && !seenContent.has(name)) { |
|
content[name] = tag[1] |
|
seenContent.add(name) |
|
} |
|
} |
|
if (content['display_name']) content['displayName'] = content['display_name'] |
|
|
|
return { contentJson: JSON.stringify(content), orderedTags } |
|
} |
|
|
|
function buildProfileEventJsonFromTagRows(baseEvent: Event, rows: EditorTagRow[]): string { |
|
const { contentJson, orderedTags } = profileTagsToSavePayload(rows) |
|
return JSON.stringify( |
|
{ ...baseEvent, content: contentJson, tags: orderedTags }, |
|
null, |
|
2 |
|
) |
|
} |
|
|
|
function paymentMethodsToPaytoTags(methods: EditorPaymentMethodRow[]): string[][] { |
|
return methods |
|
.filter((m) => { |
|
const type = m.type.trim() |
|
return m.authority.trim() && type && type !== PAYTO_EDITOR_OTHER_OPTION |
|
}) |
|
.map((m) => { |
|
const type = m.type.trim().toLowerCase() |
|
const authority = |
|
type === 'paypal' ? normalizePaypalAuthority(m.authority) : m.authority.trim() |
|
return ['payto', type, authority] |
|
}) |
|
} |
|
|
|
/** |
|
* Build the unified tag list from a stored profile event. |
|
* |
|
* Merge strategy: |
|
* 1. Event `tags` (non-auto) in event order (top to bottom). |
|
* 2. Content JSON fields not already covered (DISPLAY_ORDER for known keys). |
|
* 3. Empty placeholder rows for any singleton still missing after steps 1–2. |
|
*/ |
|
function buildTagListFromEvent(event: Event | null): string[][] { |
|
let content: Record<string, unknown> = {} |
|
if (event?.content) { |
|
try { content = JSON.parse(event.content) } catch { /* ignore */ } |
|
} |
|
|
|
const normalizeTagName = (n: string) => |
|
n === 'displayName' ? 'display_name' : n === 'username' ? 'name' : n |
|
|
|
const result: string[][] = [] |
|
const dedup = new Set<string>() |
|
const singletonNamesFromTags = new Set<string>() |
|
|
|
const push = (tag: string[]) => { |
|
const name = tag[0] |
|
if (SINGLETON_NAMES.includes(name)) { |
|
if (dedup.has(name)) return |
|
dedup.add(name) |
|
singletonNamesFromTags.add(name) |
|
} else { |
|
const key = `${name}\0${tag[1] ?? ''}` |
|
if (dedup.has(key)) return |
|
dedup.add(key) |
|
} |
|
result.push([...tag]) |
|
} |
|
|
|
for (const rawTag of event?.tags ?? []) { |
|
if (!Array.isArray(rawTag) || AUTO_NAMES.has(rawTag[0])) continue |
|
const norm = normalizeTagName(rawTag[0]) |
|
const tag = |
|
norm !== rawTag[0] ? [norm, ...rawTag.slice(1)] : ([...rawTag] as string[]) |
|
push(tag) |
|
} |
|
|
|
for (const name of DISPLAY_ORDER) { |
|
if (AUTO_NAMES.has(name) || name === 'bot') continue |
|
const rawVal = content[name] |
|
if (typeof rawVal !== 'string' || !rawVal.trim()) continue |
|
if (SINGLETON_NAMES.includes(name) && singletonNamesFromTags.has(name)) continue |
|
push([name, rawVal.trim()]) |
|
} |
|
|
|
for (const [rawKey, val] of Object.entries(content)) { |
|
if (typeof val !== 'string' || !val.trim()) continue |
|
const name = normalizeTagName(rawKey) |
|
if (AUTO_NAMES.has(name) || DISPLAY_ORDER.includes(name)) continue |
|
push([name, val.trim()]) |
|
} |
|
|
|
for (const name of SINGLETON_NAMES) { |
|
if (!result.some((t) => t[0] === name)) push([name, '']) |
|
} |
|
|
|
return result |
|
} |
|
|
|
// nostr.build thumb helpers are provided by @/lib/nostr-build. |
|
// Thin local aliases keep the JSX call-sites readable. |
|
const canInsertNostrBuildThumb = canUseNostrBuildThumb |
|
function insertNostrBuildThumbUrl(url: string): string | null { |
|
if (!canUseNostrBuildThumb(url)) return null |
|
return toNostrBuildThumbUrl(url) |
|
} |
|
|
|
// ─── Sub-components ─────────────────────────────────────────────────────────── |
|
|
|
function ProfileImageTagRow({ |
|
tagName, |
|
value, |
|
onEdit, |
|
onInsertThumb, |
|
showThumbButton, |
|
t |
|
}: { |
|
tagName: 'picture' | 'banner' |
|
value: string |
|
onEdit: () => void |
|
onInsertThumb: () => void |
|
showThumbButton: boolean |
|
t: (key: string, opts?: { defaultValue?: string }) => string |
|
}) { |
|
const label = TAG_LABELS[tagName] || tagName |
|
return ( |
|
<div className="flex flex-wrap gap-2 items-center min-w-0"> |
|
<p className="w-full shrink-0 text-xs font-medium text-muted-foreground truncate sm:w-28" title={label}> |
|
{label} |
|
</p> |
|
<Input |
|
readOnly |
|
value={value} |
|
className="flex-1 font-mono text-sm bg-muted/40" |
|
tabIndex={-1} |
|
title={value || undefined} |
|
/> |
|
{showThumbButton && ( |
|
<Button |
|
type="button" |
|
variant="ghost" |
|
size="icon" |
|
className="shrink-0 text-muted-foreground" |
|
onClick={onInsertThumb} |
|
title={t('profileEditorNostrBuildThumbHint', { |
|
defaultValue: 'Use nostr.build thumbnail URL (/thumb/…)' |
|
})} |
|
aria-label={t('profileEditorNostrBuildThumbHint', { |
|
defaultValue: 'Use nostr.build thumbnail URL (/thumb/…)' |
|
})} |
|
> |
|
<Fingerprint className="h-4 w-4" /> |
|
</Button> |
|
)} |
|
<Button |
|
type="button" |
|
variant="ghost" |
|
size="icon" |
|
className="shrink-0 text-muted-foreground" |
|
onClick={onEdit} |
|
aria-label={t('Edit')} |
|
title={t('Edit')} |
|
> |
|
<Pencil className="h-4 w-4" /> |
|
</Button> |
|
</div> |
|
) |
|
} |
|
|
|
function Item({ children }: { children: React.ReactNode }) { |
|
return <div className="grid gap-2">{children}</div> |
|
}
|
|
|