594 lines
20 KiB
TypeScript
594 lines
20 KiB
TypeScript
import { useState, useCallback, useEffect, type FormEvent } from 'react'
|
|
import { useRouter } from 'next/router'
|
|
import { useNostrAuth } from '@/hooks/useNostrAuth'
|
|
import { useAuthorPresentation } from '@/hooks/useAuthorPresentation'
|
|
import { ArticleField } from './ArticleField'
|
|
import { CreateAccountModal } from './CreateAccountModal'
|
|
import { RecoveryStep } from './CreateAccountModalSteps'
|
|
import { UnlockAccountModal } from './UnlockAccountModal'
|
|
import { ImageUploadField } from './ImageUploadField'
|
|
import { PresentationFormHeader } from './PresentationFormHeader'
|
|
import { extractPresentationData } from '@/lib/presentationParsing'
|
|
import type { Article } from '@/types/nostr'
|
|
import { t } from '@/lib/i18n'
|
|
import { userConfirm } from '@/lib/userConfirm'
|
|
|
|
interface AuthorPresentationDraft {
|
|
authorName: string
|
|
presentation: string
|
|
contentDescription: string
|
|
mainnetAddress: string
|
|
pictureUrl?: string
|
|
}
|
|
|
|
const ADDRESS_PATTERN = /^(1|3|bc1)[a-zA-Z0-9]{25,62}$/
|
|
|
|
function SuccessNotice({ pubkey }: { pubkey: string | null }): React.ReactElement {
|
|
return (
|
|
<div className="border border-neon-green/50 rounded-lg p-6 bg-neon-green/10">
|
|
<h3 className="text-lg font-semibold text-neon-green mb-2">{t('presentation.success')}</h3>
|
|
<p className="text-cyber-accent mb-4">
|
|
{t('presentation.successMessage')}
|
|
</p>
|
|
{pubkey && (
|
|
<div className="mt-4">
|
|
<a
|
|
href={`/author/${pubkey}`}
|
|
className="inline-block px-4 py-2 bg-neon-cyan/20 hover:bg-neon-cyan/30 text-neon-cyan rounded-lg font-medium transition-all border border-neon-cyan/50 hover:shadow-glow-cyan"
|
|
>
|
|
{t('presentation.manageSeries')}
|
|
</a>
|
|
</div>
|
|
)}
|
|
</div>
|
|
)
|
|
}
|
|
|
|
function ValidationError({ message }: { message: string | null }): React.ReactElement | null {
|
|
if (!message) {
|
|
return null
|
|
}
|
|
return (
|
|
<div className="bg-red-500/10 border border-red-500/50 rounded-lg p-3">
|
|
<p className="text-sm text-red-400">{message}</p>
|
|
</div>
|
|
)
|
|
}
|
|
|
|
function PresentationField({
|
|
draft,
|
|
onChange,
|
|
}: {
|
|
draft: AuthorPresentationDraft
|
|
onChange: (next: AuthorPresentationDraft) => void
|
|
}): React.ReactElement {
|
|
return (
|
|
<ArticleField
|
|
id="presentation"
|
|
label={t('presentation.field.presentation')}
|
|
value={draft.presentation}
|
|
onChange={(value) => onChange({ ...draft, presentation: value as string })}
|
|
required
|
|
type="textarea"
|
|
rows={6}
|
|
placeholder={t('presentation.field.presentation.placeholder')}
|
|
helpText={t('presentation.field.presentation.help')}
|
|
/>
|
|
)
|
|
}
|
|
|
|
function ContentDescriptionField({
|
|
draft,
|
|
onChange,
|
|
}: {
|
|
draft: AuthorPresentationDraft
|
|
onChange: (next: AuthorPresentationDraft) => void
|
|
}): React.ReactElement {
|
|
return (
|
|
<ArticleField
|
|
id="contentDescription"
|
|
label={t('presentation.field.contentDescription')}
|
|
value={draft.contentDescription}
|
|
onChange={(value) => onChange({ ...draft, contentDescription: value as string })}
|
|
required
|
|
type="textarea"
|
|
rows={6}
|
|
placeholder={t('presentation.field.contentDescription.placeholder')}
|
|
helpText={t('presentation.field.contentDescription.help')}
|
|
/>
|
|
)
|
|
}
|
|
|
|
function MainnetAddressField({
|
|
draft,
|
|
onChange,
|
|
}: {
|
|
draft: AuthorPresentationDraft
|
|
onChange: (next: AuthorPresentationDraft) => void
|
|
}): React.ReactElement {
|
|
return (
|
|
<ArticleField
|
|
id="mainnetAddress"
|
|
label={t('presentation.field.mainnetAddress')}
|
|
value={draft.mainnetAddress}
|
|
onChange={(value) => onChange({ ...draft, mainnetAddress: value as string })}
|
|
required
|
|
type="text"
|
|
placeholder={t('presentation.field.mainnetAddress.placeholder')}
|
|
helpText={t('presentation.field.mainnetAddress.help')}
|
|
/>
|
|
)
|
|
}
|
|
|
|
function AuthorNameField({
|
|
draft,
|
|
onChange,
|
|
}: {
|
|
draft: AuthorPresentationDraft
|
|
onChange: (next: AuthorPresentationDraft) => void
|
|
}): React.ReactElement {
|
|
return (
|
|
<ArticleField
|
|
id="authorName"
|
|
label={t('presentation.field.authorName')}
|
|
value={draft.authorName}
|
|
onChange={(value) => onChange({ ...draft, authorName: value as string })}
|
|
required
|
|
type="text"
|
|
placeholder={t('presentation.field.authorName.placeholder')}
|
|
helpText={t('presentation.field.authorName.help')}
|
|
/>
|
|
)
|
|
}
|
|
|
|
function PictureField({
|
|
draft,
|
|
onChange,
|
|
}: {
|
|
draft: AuthorPresentationDraft
|
|
onChange: (next: AuthorPresentationDraft) => void
|
|
}): React.ReactElement {
|
|
return (
|
|
<ImageUploadField
|
|
id="picture"
|
|
value={draft.pictureUrl}
|
|
onChange={(url) => onChange({ ...draft, pictureUrl: url })}
|
|
/>
|
|
)
|
|
}
|
|
|
|
const PresentationFields = ({
|
|
draft,
|
|
onChange,
|
|
}: {
|
|
draft: AuthorPresentationDraft
|
|
onChange: (next: AuthorPresentationDraft) => void
|
|
}): React.ReactElement => (
|
|
<div className="space-y-4">
|
|
<AuthorNameField draft={draft} onChange={onChange} />
|
|
<PictureField draft={draft} onChange={onChange} />
|
|
<PresentationField draft={draft} onChange={onChange} />
|
|
<ContentDescriptionField draft={draft} onChange={onChange} />
|
|
<MainnetAddressField draft={draft} onChange={onChange} />
|
|
</div>
|
|
)
|
|
|
|
function DeleteButton({ onDelete, deleting }: { onDelete: () => void; deleting: boolean }): React.ReactElement {
|
|
return (
|
|
<button
|
|
type="button"
|
|
onClick={onDelete}
|
|
disabled={deleting}
|
|
className="px-4 py-2 bg-red-500/20 hover:bg-red-500/30 text-red-400 rounded-lg text-sm font-medium transition-all border border-red-500/50 hover:shadow-glow-red disabled:opacity-50"
|
|
>
|
|
{deleting ? t('presentation.delete.deleting') : t('presentation.delete.button')}
|
|
</button>
|
|
)
|
|
}
|
|
|
|
type PresentationFormProps = {
|
|
draft: AuthorPresentationDraft
|
|
setDraft: (next: AuthorPresentationDraft) => void
|
|
validationError: string | null
|
|
error: string | null
|
|
loading: boolean
|
|
handleSubmit: (e: FormEvent<HTMLFormElement>) => Promise<void>
|
|
deleting: boolean
|
|
handleDelete: () => void
|
|
hasExistingPresentation: boolean
|
|
}
|
|
|
|
function PresentationForm(props: PresentationFormProps): React.ReactElement {
|
|
return (
|
|
<form
|
|
onSubmit={(e: FormEvent<HTMLFormElement>) => {
|
|
void props.handleSubmit(e)
|
|
}}
|
|
className="border border-neon-cyan/20 rounded-lg p-6 bg-cyber-dark space-y-4"
|
|
>
|
|
<PresentationFormHeader />
|
|
<PresentationFields draft={props.draft} onChange={props.setDraft} />
|
|
<ValidationError message={props.validationError ?? props.error} />
|
|
<div className="flex items-center gap-4">
|
|
<div className="flex-1">
|
|
<button
|
|
type="submit"
|
|
disabled={props.loading ?? props.deleting}
|
|
className="w-full px-4 py-2 bg-neon-cyan/20 hover:bg-neon-cyan/30 text-neon-cyan rounded-lg font-medium transition-all border border-neon-cyan/50 hover:shadow-glow-cyan disabled:opacity-50 disabled:cursor-not-allowed"
|
|
>
|
|
{getSubmitLabel(props)}
|
|
</button>
|
|
</div>
|
|
{props.hasExistingPresentation && (
|
|
<DeleteButton onDelete={() => { void props.handleDelete() }} deleting={props.deleting} />
|
|
)}
|
|
</div>
|
|
</form>
|
|
)
|
|
}
|
|
|
|
function getSubmitLabel(params: { loading: boolean; deleting: boolean; hasExistingPresentation: boolean }): string {
|
|
if (params.loading || params.deleting) {
|
|
return t('publish.publishing')
|
|
}
|
|
return params.hasExistingPresentation ? t('presentation.update.button') : t('publish.button')
|
|
}
|
|
|
|
function useAuthorPresentationState(pubkey: string | null, existingAuthorName?: string, existingPresentation?: Article | null): {
|
|
draft: AuthorPresentationDraft
|
|
setDraft: (next: AuthorPresentationDraft) => void
|
|
validationError: string | null
|
|
error: string | null
|
|
loading: boolean
|
|
handleSubmit: (e: FormEvent<HTMLFormElement>) => Promise<void>
|
|
deleting: boolean
|
|
handleDelete: () => Promise<void>
|
|
success: boolean
|
|
} {
|
|
const { loading, error, success, publishPresentation, deletePresentation } = useAuthorPresentation(pubkey)
|
|
const router = useRouter()
|
|
const [draft, setDraft] = useState<AuthorPresentationDraft>(() => buildInitialDraft(existingPresentation, existingAuthorName))
|
|
const [validationError, setValidationError] = useState<string | null>(null)
|
|
const [deleting, setDeleting] = useState(false)
|
|
|
|
// Update authorName when profile changes
|
|
useEffect(() => {
|
|
syncAuthorNameIntoDraft({ existingAuthorName, draftAuthorName: draft.authorName, hasExistingPresentation: Boolean(existingPresentation), setDraft })
|
|
}, [existingAuthorName, existingPresentation, draft.authorName])
|
|
|
|
const handleSubmit = useCallback(
|
|
async (e: FormEvent<HTMLFormElement>) => {
|
|
e.preventDefault()
|
|
await submitPresentationDraft({ draft, setValidationError, publishPresentation })
|
|
},
|
|
[draft, publishPresentation]
|
|
)
|
|
|
|
const handleDelete = useCallback(async () => {
|
|
await deletePresentationFlow({
|
|
existingPresentationId: existingPresentation?.id,
|
|
deletePresentation,
|
|
router,
|
|
setDeleting,
|
|
setValidationError,
|
|
})
|
|
}, [existingPresentation, deletePresentation, router])
|
|
|
|
return { loading, error, success, draft, setDraft, validationError, handleSubmit, deleting, handleDelete }
|
|
}
|
|
|
|
function buildInitialDraft(existingPresentation: Article | null | undefined, existingAuthorName: string | undefined): AuthorPresentationDraft {
|
|
if (existingPresentation) {
|
|
const { presentation, contentDescription } = extractPresentationData(existingPresentation)
|
|
const authorName = existingPresentation.title.replace(/^Présentation de /, '') ?? existingAuthorName ?? ''
|
|
return {
|
|
authorName,
|
|
presentation,
|
|
contentDescription,
|
|
mainnetAddress: existingPresentation.mainnetAddress ?? '',
|
|
...(existingPresentation.bannerUrl ? { pictureUrl: existingPresentation.bannerUrl } : {}),
|
|
}
|
|
}
|
|
return {
|
|
authorName: existingAuthorName ?? '',
|
|
presentation: '',
|
|
contentDescription: '',
|
|
mainnetAddress: '',
|
|
}
|
|
}
|
|
|
|
function syncAuthorNameIntoDraft(params: {
|
|
existingAuthorName: string | undefined
|
|
draftAuthorName: string
|
|
hasExistingPresentation: boolean
|
|
setDraft: (updater: (prev: AuthorPresentationDraft) => AuthorPresentationDraft) => void
|
|
}): void {
|
|
if (!params.existingAuthorName || params.hasExistingPresentation || params.existingAuthorName === params.draftAuthorName) {
|
|
return
|
|
}
|
|
params.setDraft((prev) => ({ ...prev, authorName: params.existingAuthorName as string }))
|
|
}
|
|
|
|
async function submitPresentationDraft(params: {
|
|
draft: AuthorPresentationDraft
|
|
setValidationError: (value: string | null) => void
|
|
publishPresentation: (draft: AuthorPresentationDraft) => Promise<void>
|
|
}): Promise<void> {
|
|
const error = validatePresentationDraft(params.draft)
|
|
if (error) {
|
|
params.setValidationError(error)
|
|
return
|
|
}
|
|
params.setValidationError(null)
|
|
await params.publishPresentation(params.draft)
|
|
}
|
|
|
|
function validatePresentationDraft(draft: AuthorPresentationDraft): string | null {
|
|
const address = draft.mainnetAddress.trim()
|
|
if (!ADDRESS_PATTERN.test(address)) {
|
|
return t('presentation.validation.invalidAddress')
|
|
}
|
|
if (!draft.authorName.trim()) {
|
|
return t('presentation.validation.authorNameRequired')
|
|
}
|
|
return null
|
|
}
|
|
|
|
async function deletePresentationFlow(params: {
|
|
existingPresentationId: string | undefined
|
|
deletePresentation: (articleId: string) => Promise<void>
|
|
router: ReturnType<typeof useRouter>
|
|
setDeleting: (value: boolean) => void
|
|
setValidationError: (value: string | null) => void
|
|
}): Promise<void> {
|
|
if (!params.existingPresentationId) {
|
|
return
|
|
}
|
|
const confirmed = await userConfirm(t('presentation.delete.confirm'))
|
|
if (!confirmed) {
|
|
return
|
|
}
|
|
params.setDeleting(true)
|
|
params.setValidationError(null)
|
|
try {
|
|
await params.deletePresentation(params.existingPresentationId)
|
|
await params.router.push('/')
|
|
} catch (e) {
|
|
params.setValidationError(e instanceof Error ? e.message : t('presentation.delete.error'))
|
|
} finally {
|
|
params.setDeleting(false)
|
|
}
|
|
}
|
|
|
|
function NoAccountActionButtons({
|
|
onGenerate,
|
|
onImport,
|
|
}: {
|
|
onGenerate: () => void
|
|
onImport: () => void
|
|
}): React.ReactElement {
|
|
return (
|
|
<div className="flex flex-col gap-3 w-full max-w-xs">
|
|
<button
|
|
onClick={onGenerate}
|
|
className="px-6 py-2 bg-neon-cyan/20 hover:bg-neon-cyan/30 text-neon-cyan rounded-lg font-medium transition-all border border-neon-cyan/50 hover:shadow-glow-cyan"
|
|
>
|
|
{t('account.create.generateButton')}
|
|
</button>
|
|
<button
|
|
onClick={onImport}
|
|
className="px-6 py-2 bg-gray-100 hover:bg-gray-200 text-gray-700 rounded-lg font-medium transition-colors"
|
|
>
|
|
{t('account.create.importButton')}
|
|
</button>
|
|
</div>
|
|
)
|
|
}
|
|
|
|
function NoAccountView(): React.ReactElement {
|
|
const [showImportModal, setShowImportModal] = useState(false)
|
|
const [showRecoveryStep, setShowRecoveryStep] = useState(false)
|
|
const [showUnlockModal, setShowUnlockModal] = useState(false)
|
|
const [recoveryPhrase, setRecoveryPhrase] = useState<string[]>([])
|
|
const [npub, setNpub] = useState('')
|
|
const [generating, setGenerating] = useState(false)
|
|
const [error, setError] = useState<string | null>(null)
|
|
|
|
const handleGenerate = (): Promise<void> => generateNoAccount({ setGenerating, setError, setRecoveryPhrase, setNpub, setShowRecoveryStep })
|
|
const handleRecoveryContinue = (): void => transitionToUnlock({ setShowRecoveryStep, setShowUnlockModal })
|
|
const handleUnlockSuccess = (): void => resetNoAccountAfterUnlock({ setShowUnlockModal, setRecoveryPhrase, setNpub })
|
|
const handleImportSuccess = (): void => {
|
|
setShowImportModal(false)
|
|
setShowUnlockModal(true)
|
|
}
|
|
|
|
return (
|
|
<NoAccountCard
|
|
error={error}
|
|
generating={generating}
|
|
onGenerate={() => { void handleGenerate() }}
|
|
onImport={() => setShowImportModal(true)}
|
|
modals={
|
|
<NoAccountModals
|
|
showImportModal={showImportModal}
|
|
onCloseImport={() => setShowImportModal(false)}
|
|
onImportSuccess={handleImportSuccess}
|
|
showRecoveryStep={showRecoveryStep}
|
|
recoveryPhrase={recoveryPhrase}
|
|
npub={npub}
|
|
onRecoveryContinue={handleRecoveryContinue}
|
|
showUnlockModal={showUnlockModal}
|
|
onUnlockSuccess={handleUnlockSuccess}
|
|
onCloseUnlock={() => setShowUnlockModal(false)}
|
|
/>
|
|
}
|
|
/>
|
|
)
|
|
}
|
|
|
|
async function generateNoAccount(params: {
|
|
setGenerating: (value: boolean) => void
|
|
setError: (value: string | null) => void
|
|
setRecoveryPhrase: (value: string[]) => void
|
|
setNpub: (value: string) => void
|
|
setShowRecoveryStep: (value: boolean) => void
|
|
}): Promise<void> {
|
|
params.setGenerating(true)
|
|
params.setError(null)
|
|
try {
|
|
const { nostrAuthService } = await import('@/lib/nostrAuth')
|
|
const result = await nostrAuthService.createAccount()
|
|
params.setRecoveryPhrase(result.recoveryPhrase)
|
|
params.setNpub(result.npub)
|
|
params.setShowRecoveryStep(true)
|
|
} catch (e) {
|
|
params.setError(e instanceof Error ? e.message : t('account.create.error.failed'))
|
|
} finally {
|
|
params.setGenerating(false)
|
|
}
|
|
}
|
|
|
|
function transitionToUnlock(params: { setShowRecoveryStep: (value: boolean) => void; setShowUnlockModal: (value: boolean) => void }): void {
|
|
params.setShowRecoveryStep(false)
|
|
params.setShowUnlockModal(true)
|
|
}
|
|
|
|
function resetNoAccountAfterUnlock(params: {
|
|
setShowUnlockModal: (value: boolean) => void
|
|
setRecoveryPhrase: (value: string[]) => void
|
|
setNpub: (value: string) => void
|
|
}): void {
|
|
params.setShowUnlockModal(false)
|
|
params.setRecoveryPhrase([])
|
|
params.setNpub('')
|
|
}
|
|
|
|
function NoAccountCard(params: {
|
|
error: string | null
|
|
generating: boolean
|
|
onGenerate: () => void
|
|
onImport: () => void
|
|
modals: React.ReactElement
|
|
}): React.ReactElement {
|
|
return (
|
|
<div className="border border-neon-cyan/20 rounded-lg p-6 bg-cyber-dark/50">
|
|
<div className="flex flex-col items-center gap-4">
|
|
<p className="text-center text-cyber-accent mb-2">Créez un compte ou importez votre clé secrète pour commencer</p>
|
|
{params.error && <p className="text-sm text-red-400">{params.error}</p>}
|
|
<NoAccountActionButtons onGenerate={params.onGenerate} onImport={params.onImport} />
|
|
{params.generating && <p className="text-cyber-accent text-sm">Génération du compte...</p>}
|
|
{params.modals}
|
|
</div>
|
|
</div>
|
|
)
|
|
}
|
|
|
|
function NoAccountModals(params: {
|
|
showImportModal: boolean
|
|
onImportSuccess: () => void
|
|
onCloseImport: () => void
|
|
showRecoveryStep: boolean
|
|
recoveryPhrase: string[]
|
|
npub: string
|
|
onRecoveryContinue: () => void
|
|
showUnlockModal: boolean
|
|
onUnlockSuccess: () => void
|
|
onCloseUnlock: () => void
|
|
}): React.ReactElement {
|
|
return (
|
|
<>
|
|
{params.showImportModal && <CreateAccountModal onSuccess={params.onImportSuccess} onClose={params.onCloseImport} initialStep="import" />}
|
|
{params.showRecoveryStep && <RecoveryStep recoveryPhrase={params.recoveryPhrase} npub={params.npub} onContinue={params.onRecoveryContinue} />}
|
|
{params.showUnlockModal && <UnlockAccountModal onSuccess={params.onUnlockSuccess} onClose={params.onCloseUnlock} />}
|
|
</>
|
|
)
|
|
}
|
|
|
|
function AuthorPresentationFormView(props: {
|
|
pubkey: string | null
|
|
profile: { name?: string; pubkey: string } | null
|
|
}): React.ReactElement {
|
|
const { checkPresentationExists } = useAuthorPresentation(props.pubkey)
|
|
const presentation = useExistingPresentation({ pubkey: props.pubkey, checkPresentationExists })
|
|
const state = useAuthorPresentationState(props.pubkey, props.profile?.name, presentation.existingPresentation)
|
|
|
|
if (!props.pubkey) {
|
|
return <NoAccountView />
|
|
}
|
|
if (presentation.loadingPresentation) {
|
|
return <LoadingNotice />
|
|
}
|
|
if (state.success) {
|
|
return <SuccessNotice pubkey={props.pubkey} />
|
|
}
|
|
return (
|
|
<PresentationForm
|
|
draft={state.draft}
|
|
setDraft={state.setDraft}
|
|
validationError={state.validationError}
|
|
error={state.error}
|
|
loading={state.loading}
|
|
handleSubmit={state.handleSubmit}
|
|
deleting={state.deleting}
|
|
handleDelete={() => { void state.handleDelete() }}
|
|
hasExistingPresentation={presentation.existingPresentation !== null}
|
|
/>
|
|
)
|
|
}
|
|
|
|
function LoadingNotice(): React.ReactElement {
|
|
return (
|
|
<div className="text-center py-12">
|
|
<p className="text-cyber-accent/70">{t('common.loading')}</p>
|
|
</div>
|
|
)
|
|
}
|
|
|
|
function useExistingPresentation(params: {
|
|
pubkey: string | null
|
|
checkPresentationExists: () => Promise<Article | null>
|
|
}): { existingPresentation: Article | null; loadingPresentation: boolean } {
|
|
const [existingPresentation, setExistingPresentation] = useState<Article | null>(null)
|
|
const [loadingPresentation, setLoadingPresentation] = useState(true)
|
|
const { pubkey, checkPresentationExists } = params
|
|
|
|
useEffect(() => {
|
|
void loadExistingPresentation({ pubkey, checkPresentationExists, setExistingPresentation, setLoadingPresentation })
|
|
}, [pubkey, checkPresentationExists])
|
|
|
|
return { existingPresentation, loadingPresentation }
|
|
}
|
|
|
|
async function loadExistingPresentation(params: {
|
|
pubkey: string | null
|
|
checkPresentationExists: () => Promise<Article | null>
|
|
setExistingPresentation: (value: Article | null) => void
|
|
setLoadingPresentation: (value: boolean) => void
|
|
}): Promise<void> {
|
|
if (!params.pubkey) {
|
|
params.setLoadingPresentation(false)
|
|
return
|
|
}
|
|
try {
|
|
params.setExistingPresentation(await params.checkPresentationExists())
|
|
} catch (e) {
|
|
console.error('Error loading presentation:', e)
|
|
} finally {
|
|
params.setLoadingPresentation(false)
|
|
}
|
|
}
|
|
|
|
function useAutoLoadPubkey(accountExists: boolean | null, pubkey: string | null, connect: () => Promise<void>): void {
|
|
useEffect(() => {
|
|
if (accountExists === true && !pubkey) {
|
|
void connect()
|
|
}
|
|
}, [accountExists, pubkey, connect])
|
|
}
|
|
|
|
export function AuthorPresentationEditor(): React.ReactElement {
|
|
const { pubkey, profile, accountExists, connect } = useNostrAuth()
|
|
useAutoLoadPubkey(accountExists, pubkey ?? null, connect)
|
|
return <AuthorPresentationFormView pubkey={pubkey ?? null} profile={profile} />
|
|
}
|