From cf439686349553609cfc9e1f6774b3ccbb74b2bc Mon Sep 17 00:00:00 2001 From: Silberengel Date: Mon, 16 Mar 2026 20:23:23 +0100 Subject: [PATCH] bug-fixes --- src/components/GifPicker/index.tsx | 81 ++++--- .../Note/MarkdownArticle/MarkdownArticle.tsx | 11 +- src/components/PostEditor/PostContent.tsx | 3 +- src/components/Profile/index.tsx | 82 +++++-- src/constants.ts | 4 +- src/i18n/locales/en.ts | 11 + src/lib/draft-event.ts | 74 +----- .../DiscussionsPage/CreateThreadDialog.tsx | 18 +- .../secondary/ProfileEditorPage/index.tsx | 219 +++++++++++++++--- src/providers/NostrProvider/index.tsx | 17 +- src/services/gif.service.ts | 5 +- src/types/index.d.ts | 2 + 12 files changed, 342 insertions(+), 185 deletions(-) diff --git a/src/components/GifPicker/index.tsx b/src/components/GifPicker/index.tsx index 69ac41ff..8bbf6466 100644 --- a/src/components/GifPicker/index.tsx +++ b/src/components/GifPicker/index.tsx @@ -126,9 +126,13 @@ export default function GifPicker({ const isLoggedIn = !!pubkey + /** In drawer mode we constrain height and make only the GIF grid scroll so the drawer doesn't "sink" */ + const isDrawer = isSmallScreen const content = ( -
-
+
+
{error && ( -

{error}

+

{error}

)} - - {loading ? ( -
- -
- ) : ( -
- {gifs.map((gif) => ( - - ))} -
- )} -
-
+
+ + {loading ? ( +
+ +
+ ) : ( +
+ {gifs.map((gif) => ( + + ))} +
+ )} +
+
+
0 && !text.includes('\n\n') + // For hashtags at start of line: text after on same line (e.g. "#pyramid 1.1 has..." - merge so no hard break) + let hasTextAfterOnSameLine = false // For hashtags: check if the line contains only hashtags (and spaces) // This handles cases like "#orly #devstr #progressreport" on one line @@ -1344,10 +1346,11 @@ function parseMarkdownContent( shouldMergeHashtag = lineHasOnlyHashtags || hasOtherHashtagsOnLine || hasHashtagsOnAdjacentLines || hasTextOnSameLine || hasTextBefore // If none of the above, but there's text after the hashtag on the same line, also merge - // This handles cases where hashtag is at start of line but followed by text + // This handles cases where hashtag is at start of line but followed by text (e.g. "#pyramid 1.1 has...") if (!shouldMergeHashtag) { const textAfterOnSameLine = content.substring(pattern.end, lineEndIndex) - if (textAfterOnSameLine.trim().length > 0) { + hasTextAfterOnSameLine = textAfterOnSameLine.trim().length > 0 + if (hasTextAfterOnSameLine) { shouldMergeHashtag = true } } @@ -1438,8 +1441,8 @@ function parseMarkdownContent( // Also update lastIndex immediately to prevent processing of patterns in this range lastIndex = textEndIndex - } else if (hasTextOnSameLine || hasTextBefore) { - // Hashtag is part of text - merge just this hashtag and text after it + } else if (hasTextOnSameLine || hasTextBefore || hasTextAfterOnSameLine) { + // Hashtag is part of text - merge just this hashtag and text after it (avoids hard break after #hashtag at start of line) const patternMarkdown = content.substring(pattern.index, pattern.end) const textAfterPattern = content.substring(pattern.end, lineEndIndex) text = text + patternMarkdown + textAfterPattern diff --git a/src/components/PostEditor/PostContent.tsx b/src/components/PostEditor/PostContent.tsx index f9b01ac2..7111a440 100644 --- a/src/components/PostEditor/PostContent.tsx +++ b/src/components/PostEditor/PostContent.tsx @@ -815,7 +815,8 @@ export default function PostContent({ specifiedRelayUrls: relayUrls, additionalRelayUrls: isPoll ? pollCreateData.relays : (isPrivateEvent ? privateRelayUrls : additionalRelayUrls), minPow, - disableFallbacks: additionalRelayUrls.length > 0 || isPrivateEvent // Don't use fallbacks if user explicitly selected relays or for private events + disableFallbacks: additionalRelayUrls.length > 0 || isPrivateEvent, // Don't use fallbacks if user explicitly selected relays or for private events + addClientTag }) // console.log('Published event:', newEvent) diff --git a/src/components/Profile/index.tsx b/src/components/Profile/index.tsx index 5249eaf9..518a3faa 100644 --- a/src/components/Profile/index.tsx +++ b/src/components/Profile/index.tsx @@ -55,10 +55,32 @@ import type { TProfile } from '@/types' type ProfileTabValue = 'posts' | 'pins' | 'bookmarks' | 'interests' | 'articles' | 'media' | 'you' | 'notes' -/** Normalize authority for deduplication (e.g. lightning addresses case-insensitive) */ +/** + * Normalize lightning/LUD-16 authority to a canonical form for deduplication. + * Handles "user@domain" and "user.domain" (dot variant) as the same address. + */ +function normalizeLightningAuthority(authority: string): string { + const s = authority.trim().toLowerCase() + if (!s) return s + if (s.includes('@')) return s + const firstDot = s.indexOf('.') + if (firstDot > 0) return s.slice(0, firstDot) + '@' + s.slice(firstDot + 1) + return s +} + +/** Normalize authority for deduplication (canonical key per type) */ function normalizePaymentAuthority(type: string, authority: string): string { - if (type === 'lightning' && authority) return authority.toLowerCase().trim() - return authority.trim() + const t = type.toLowerCase() + if (t === 'lightning' && authority) return normalizeLightningAuthority(authority) + return authority.trim().toLowerCase() +} + +/** Prefer displaying lightning address in canonical "user@domain" form when we have both variants */ +function preferCanonicalLightningAuthority(a: string, b: string): string { + const hasAt = (s: string) => s.trim().includes('@') + if (hasAt(a) && !hasAt(b)) return a + if (hasAt(b) && !hasAt(a)) return b + return a } type MergedPaymentMethod = { @@ -71,28 +93,48 @@ type MergedPaymentMethod = { maxAmount?: number } -/** Merge payment methods from kind 10133 and profile (kind 0 lightning), deduplicated */ +/** Merge payment methods from kind 10133 and profile (kind 0: JSON + tags), normalized and deduplicated */ function mergePaymentMethods( paymentInfo: ReturnType | null, profile: TProfile | null ): MergedPaymentMethod[] { - const seen = new Set() + const seen = new Map() const out: MergedPaymentMethod[] = [] const add = (type: string, authority: string, payto?: string, displayType?: string, extra?: { currency?: string; minAmount?: number; maxAmount?: number }) => { - const key = `${type}:${normalizePaymentAuthority(type, authority)}` - if (!authority || seen.has(key)) return - seen.add(key) - out.push({ - type, - authority, - payto: payto || (type && authority ? `payto://${type}/${authority}` : undefined), - displayType: displayType || (type === 'lightning' ? 'Lightning Network' : type === 'bitcoin' ? 'Bitcoin' : type || 'Payment'), + if (!authority?.trim()) return + const normType = type.toLowerCase() + const key = `${normType}:${normalizePaymentAuthority(normType, authority)}` + const existing = seen.get(key) + if (existing) { + if (normType === 'lightning') { + existing.authority = preferCanonicalLightningAuthority(existing.authority, authority.trim()) + existing.payto = existing.payto || payto || (normType && authority ? `payto://${normType}/${existing.authority}` : undefined) + } + return + } + const entry: MergedPaymentMethod = { + type: normType, + authority: authority.trim(), + payto: payto || (normType && authority ? `payto://${normType}/${authority.trim()}` : undefined), + displayType: displayType || (normType === 'lightning' ? 'Lightning Network' : normType === 'bitcoin' ? 'Bitcoin' : type || 'Payment'), ...extra - }) + } + seen.set(key, entry) + out.push(entry) } - // From kind 10133 + // Aggregate: profile (kind 0) first – from lightningAddressList (tags + JSON) and single lightningAddress + const fromProfile = profile?.lightningAddressList?.length + ? profile.lightningAddressList + : profile?.lightningAddress + ? [profile.lightningAddress] + : [] + fromProfile.forEach((addr) => { + if (addr) add('lightning', addr, `payto://lightning/${addr}`, 'Lightning Network') + }) + + // Then kind 10133 (payto tags and JSON content) if (paymentInfo?.methods?.length) { paymentInfo.methods.forEach((m) => { const authority = m.authority || m.address || '' @@ -110,16 +152,6 @@ function mergePaymentMethods( add(type, authority, paymentInfo.payto, type === 'lightning' ? 'Lightning Network' : paymentInfo.type || 'Payment') } - // From profile (kind 0) lightning addresses - const fromProfile = profile?.lightningAddressList?.length - ? profile.lightningAddressList - : profile?.lightningAddress - ? [profile.lightningAddress] - : [] - fromProfile.forEach((addr) => { - if (addr) add('lightning', addr, `payto://lightning/${addr}`, 'Lightning Network') - }) - return out } diff --git a/src/constants.ts b/src/constants.ts index b321e647..07975795 100644 --- a/src/constants.ts +++ b/src/constants.ts @@ -121,8 +121,10 @@ export const FAST_WRITE_RELAY_URLS = [ 'wss://nos.lol' ] -/** Relays used for NIP-94 file metadata (kind 1063) / GIF discovery and publish */ +/** Relays used for NIP-94 file metadata (kind 1063) / GIF discovery and publish. + * Include relay.gifbuddy.lol (GifBuddy) so we get many kind 1063 GIFs; damus/primal/thecitadel have fewer. */ export const GIF_RELAY_URLS = [ + 'wss://relay.gifbuddy.lol', 'wss://relay.damus.io', 'wss://relay.primal.net', 'wss://thecitadel.nostr1.com' diff --git a/src/i18n/locales/en.ts b/src/i18n/locales/en.ts index 4160a8cb..da8e94c8 100644 --- a/src/i18n/locales/en.ts +++ b/src/i18n/locales/en.ts @@ -105,6 +105,17 @@ export default { 'Payment info updated': 'Payment info updated', 'Failed to publish payment info': 'Failed to publish payment info', 'Invalid tags JSON': 'Invalid tags JSON', + 'Payment methods': 'Payment methods', + 'NIP-A3 payto tags: type (e.g. lightning) and authority (e.g. user@domain.com).': 'NIP-A3 payto tags: type (e.g. lightning) and authority (e.g. user@domain.com).', + 'Type (e.g. lightning)': 'Type (e.g. lightning)', + 'Authority (e.g. user@domain.com)': 'Authority (e.g. user@domain.com)', + 'Add payment method': 'Add payment method', + Remove: 'Remove', + 'Additional content (JSON)': 'Additional content (JSON)', + 'Show full event JSON': 'Show full event JSON', + 'Tag list': 'Tag list', + 'Profile event tags (e.g. lud16, nip05, website). Saved with kind 0.': 'Profile event tags (e.g. lud16, nip05, website). Saved with kind 0.', + 'Tag value': 'Tag value', 'Saving…': 'Saving…', 'Share with Jumble': 'Share with Jumble', 'Share with Alexandria': 'Share with Alexandria', diff --git a/src/lib/draft-event.ts b/src/lib/draft-event.ts index 92764dcb..b20c23ac 100644 --- a/src/lib/draft-event.ts +++ b/src/lib/draft-event.ts @@ -150,11 +150,6 @@ export async function createShortTextNoteDraftEvent( // p tags tags.push(...mentions.map((pubkey) => buildPTag(pubkey))) - if (options.addClientTag) { - tags.push(buildClientTag()) - tags.push(buildAltTag()) - } - if (options.isNsfw) { tags.push(buildNsfwTag()) } @@ -260,11 +255,6 @@ export async function createCommentDraftEvent( ] ) - if (options.addClientTag) { - tags.push(buildClientTag()) - tags.push(buildAltTag()) - } - if (options.isNsfw) { tags.push(buildNsfwTag()) } @@ -347,11 +337,6 @@ export async function createPublicMessageReplyDraftEvent( ...Array.from(recipients).map((pubkey) => buildPTag(pubkey)) ) - if (options.addClientTag) { - tags.push(buildClientTag()) - tags.push(buildAltTag()) - } - if (options.isNsfw) { tags.push(buildNsfwTag()) } @@ -415,11 +400,6 @@ export async function createPublicMessageDraftEvent( ...recipients.map((pubkey) => buildPTag(pubkey)) ) - if (options.addClientTag) { - tags.push(buildClientTag()) - tags.push(buildAltTag()) - } - if (options.isNsfw) { tags.push(buildNsfwTag()) } @@ -643,11 +623,6 @@ export async function createPollDraftEvent( }) } - if (addClientTag) { - tags.push(buildClientTag()) - tags.push(buildAltTag()) - } - if (isNsfw) { tags.push(buildNsfwTag()) } @@ -981,7 +956,7 @@ function buildResponseTag(value: string) { return ['response', value] } -function buildClientTag(handlerPubkey?: string, handlerIdentifier?: string, relay?: string) { +export function buildClientTag(handlerPubkey?: string, handlerIdentifier?: string, relay?: string) { // Use NIP-89 format if handler information is provided if (handlerPubkey && handlerIdentifier) { const aTag = `31990:${handlerPubkey}:${handlerIdentifier}` @@ -996,7 +971,7 @@ function buildClientTag(handlerPubkey?: string, handlerIdentifier?: string, rela return ['client', 'jumble'] } -function buildAltTag() { +export function buildAltTag() { return ['alt', 'This event was published by https://jumble.imwald.eu.'] } @@ -1164,11 +1139,6 @@ export async function createHighlightDraftEvent( } // Add optional tags - if (options?.addClientTag) { - tags.push(buildClientTag()) - tags.push(buildAltTag()) - } - if (options?.isNsfw) { tags.push(buildNsfwTag()) } @@ -1213,11 +1183,6 @@ export async function createVoiceDraftEvent( tags.push(...imetaTags) tags.push(...mentions.map((pubkey) => buildPTag(pubkey))) - if (options.addClientTag) { - tags.push(buildClientTag()) - tags.push(buildAltTag()) - } - if (options.isNsfw) { tags.push(buildNsfwTag()) } @@ -1300,11 +1265,6 @@ export async function createVoiceCommentDraftEvent( ] ) - if (options.addClientTag) { - tags.push(buildClientTag()) - tags.push(buildAltTag()) - } - if (options.isNsfw) { tags.push(buildNsfwTag()) } @@ -1354,11 +1314,6 @@ export async function createPictureDraftEvent( tags.push(...imetaTags) tags.push(...mentions.map((pubkey) => buildPTag(pubkey))) - if (options.addClientTag) { - tags.push(buildClientTag()) - tags.push(buildAltTag()) - } - if (options.isNsfw) { tags.push(buildNsfwTag()) } @@ -1405,11 +1360,6 @@ export async function createVideoDraftEvent( tags.push(...imetaTags) tags.push(...mentions.map((pubkey) => buildPTag(pubkey))) - if (options.addClientTag) { - tags.push(buildClientTag()) - tags.push(buildAltTag()) - } - if (options.isNsfw) { tags.push(buildNsfwTag()) } @@ -1485,11 +1435,6 @@ export async function createLongFormArticleDraftEvent( tags.push(...generateImetaTags(images)) } - if (options.addClientTag) { - tags.push(buildClientTag()) - tags.push(buildAltTag()) - } - if (options.isNsfw) { tags.push(buildNsfwTag()) } @@ -1560,11 +1505,6 @@ export async function createWikiArticleDraftEvent( } tags.push(...mentions.map((pubkey) => buildPTag(pubkey))) - if (options.addClientTag) { - tags.push(buildClientTag()) - tags.push(buildAltTag()) - } - if (options.isNsfw) { tags.push(buildNsfwTag()) } @@ -1626,11 +1566,6 @@ export async function createWikiArticleMarkdownDraftEvent( } tags.push(...mentions.map((pubkey) => buildPTag(pubkey))) - if (options.addClientTag) { - tags.push(buildClientTag()) - tags.push(buildAltTag()) - } - if (options.isNsfw) { tags.push(buildNsfwTag()) } @@ -1692,11 +1627,6 @@ export async function createPublicationContentDraftEvent( } tags.push(...mentions.map((pubkey) => buildPTag(pubkey))) - if (options.addClientTag) { - tags.push(buildClientTag()) - tags.push(buildAltTag()) - } - if (options.isNsfw) { tags.push(buildNsfwTag()) } diff --git a/src/pages/primary/DiscussionsPage/CreateThreadDialog.tsx b/src/pages/primary/DiscussionsPage/CreateThreadDialog.tsx index dea0d128..1446585b 100644 --- a/src/pages/primary/DiscussionsPage/CreateThreadDialog.tsx +++ b/src/pages/primary/DiscussionsPage/CreateThreadDialog.tsx @@ -45,15 +45,6 @@ function buildNsfwTag(): string[] { return ['content-warning', ''] } -function buildClientTag(): string[] { - return ['client', 'jumble'] -} - -function buildAltTag(): string[] { - return ['alt', 'This event was published by https://jumble.imwald.eu.'] -} - - interface DynamicTopic { id: string label: string @@ -430,11 +421,7 @@ export default function CreateThreadDialog({ tags.push(buildNsfwTag()) } - // Add client tag if enabled - if (addClientTag) { - tags.push(buildClientTag()) - tags.push(buildAltTag()) - } + // Client tag is added in publish() based on user preference // Create the thread event (kind 11) const threadEvent: TDraftEvent = { @@ -458,7 +445,8 @@ export default function CreateThreadDialog({ // Publish to all selected relays const publishedEvent = await publish(threadEvent, { specifiedRelayUrls: selectedRelayUrls, - minPow + minPow, + addClientTag }) diff --git a/src/pages/secondary/ProfileEditorPage/index.tsx b/src/pages/secondary/ProfileEditorPage/index.tsx index 63f6c01a..d45be64b 100644 --- a/src/pages/secondary/ProfileEditorPage/index.tsx +++ b/src/pages/secondary/ProfileEditorPage/index.tsx @@ -24,7 +24,7 @@ import { isEmail } from '@/lib/utils' import { useSecondaryPage } from '@/PageManager' import { useNostr } from '@/providers/NostrProvider' import client from '@/services/client.service' -import { ChevronDown, Loader, Pencil, RefreshCw, Upload } from 'lucide-react' +import { ChevronDown, Loader, 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' @@ -50,12 +50,16 @@ const ProfileEditorPage = forwardRef(({ index }: { index?: number }, ref) => { const [paymentInfoEvent, setPaymentInfoEvent] = useState(null) const [paymentInfoEditOpen, setPaymentInfoEditOpen] = useState(false) const [paymentInfoEditContent, setPaymentInfoEditContent] = useState('') - const [paymentInfoEditTagsJson, setPaymentInfoEditTagsJson] = 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]. */ + const [profileTags, setProfileTags] = useState([]) const defaultImage = useMemo( () => (account ? generateImageByPubkey(account.pubkey) : undefined), [account] @@ -90,6 +94,15 @@ const ProfileEditorPage = forwardRef(({ index }: { index?: number }, ref) => { } }, [profileEvent]) + // Sync tag list from profileEvent (kind 0 tags) + useEffect(() => { + if (profileEvent?.tags?.length) { + setProfileTags(profileEvent.tags.map((t) => [...t])) + } else { + setProfileTags([]) + } + }, [profileEvent]) + // Fetch payment info event (kind 10133) for current user useEffect(() => { if (!account?.pubkey) { @@ -117,31 +130,41 @@ const ProfileEditorPage = forwardRef(({ index }: { index?: number }, ref) => { ? paymentInfoEvent.content : JSON.stringify(paymentInfoEvent.content ?? '', null, 2) ) - setPaymentInfoEditTagsJson( - JSON.stringify(paymentInfoEvent.tags ?? [], null, 2) + const paytoTags = (paymentInfoEvent.tags ?? []).filter( + (tag) => Array.isArray(tag) && tag[0] === 'payto' && tag[1] != null + ) + setPaymentInfoEditMethods( + paytoTags.length > 0 + ? paytoTags.map((tag) => ({ + type: (tag[1] as string) || 'lightning', + authority: (tag[2] as string) || '' + })) + : [{ type: 'lightning', authority: '' }] ) } else { setPaymentInfoEditContent('{}') - setPaymentInfoEditTagsJson('[]') + setPaymentInfoEditMethods([{ type: 'lightning', authority: '' }]) } + setPaymentInfoShowFullJson(false) setPaymentInfoEditOpen(true) }, [paymentInfoEvent]) const savePaymentInfo = useCallback(async () => { - let tags: string[][] - try { - tags = JSON.parse(paymentInfoEditTagsJson) - if (!Array.isArray(tags)) throw new Error('Tags must be an array') - tags.forEach((t, i) => { - if (!Array.isArray(t)) throw new Error(`Tag at index ${i} must be an array of strings`) - }) - } catch (e) { - toast.error(t('Invalid tags JSON')) - return - } + const tags: string[][] = paymentInfoEditMethods + .filter((m) => m.authority.trim()) + .map((m) => ['payto', (m.type.trim() || 'lightning').toLowerCase(), m.authority.trim()]) setSavingPaymentInfo(true) try { - const draft = createPaymentInfoDraftEvent(paymentInfoEditContent.trim(), tags) + const contentStr = paymentInfoEditContent.trim() || '{}' + let content = contentStr + try { + JSON.parse(contentStr) + } catch { + toast.error(t('Invalid content JSON')) + setSavingPaymentInfo(false) + return + } + const draft = createPaymentInfoDraftEvent(content, tags) const published = await publish(draft) await client.updatePaymentInfoCache(published) setPaymentInfoEvent(published) @@ -152,9 +175,7 @@ const ProfileEditorPage = forwardRef(({ index }: { index?: number }, ref) => { } finally { setSavingPaymentInfo(false) } - }, [paymentInfoEditContent, paymentInfoEditTagsJson, publish, t]) - - if (!account || !profile) return null + }, [paymentInfoEditContent, paymentInfoEditMethods, publish, t]) const save = async () => { if (nip05 && !isEmail(nip05)) { @@ -188,11 +209,14 @@ const ProfileEditorPage = forwardRef(({ index }: { index?: number }, ref) => { delete newProfileContent.lud16 } + const tagsToSave = profileTags + .filter((tag) => Array.isArray(tag) && tag.length >= 2 && tag[0].trim() && tag[1].trim()) + .map((tag) => [tag[0].trim(), tag[1].trim(), ...(tag.slice(2) || [])]) setSaving(true) setHasChanged(false) const profileDraftEvent = createProfileDraftEvent( JSON.stringify(newProfileContent), - profileEvent?.tags ?? [] + tagsToSave ) const newProfileEvent = await publish(profileDraftEvent) await updateProfileEvent(newProfileEvent) @@ -229,6 +253,8 @@ const ProfileEditorPage = forwardRef(({ index }: { index?: number }, ref) => { } }, [account?.pubkey, updateProfileEvent, t]) + if (!account || !profile) return null + const saveFullProfile = async () => { let parsed: { kind?: number; content?: string; tags?: string[][] } try { @@ -381,6 +407,65 @@ const ProfileEditorPage = forwardRef(({ index }: { index?: number }, ref) => { )} + + +

+ {t('Profile event tags (e.g. lud16, nip05, website). Saved with kind 0.')} +

+
+ {profileTags.map((tag, idx) => ( +
+ { + const next = profileTags.map((t, i) => (i === idx ? [e.target.value, t[1] ?? '', ...(t.slice(2) ?? [])] : t)) + setProfileTags(next) + setHasChanged(true) + }} + className="flex-1 max-w-[140px] font-mono text-sm" + /> + { + const next = profileTags.map((t, i) => (i === idx ? [t[0] ?? '', e.target.value, ...(t.slice(2) ?? [])] : t)) + setProfileTags(next) + setHasChanged(true) + }} + className="flex-1 font-mono text-sm" + /> + +
+ ))} + +
+
+ {/* Full profile event (kind 0): editable entire event as JSON */} {profileEvent && ( @@ -464,22 +549,94 @@ const ProfileEditorPage = forwardRef(({ index }: { index?: number }, ref) => {
- -