Browse Source

handle missing lightning wallets gracefully

imwald
Silberengel 4 weeks ago
parent
commit
0e195ab66c
  1. 20
      src/components/NoteInteractions/index.tsx
  2. 12
      src/components/Profile/index.tsx
  3. 2
      src/components/ReplyNoteList/index.tsx
  4. 159
      src/components/ZapDialog/index.tsx
  5. 4
      src/i18n/locales/de.ts
  6. 2
      src/i18n/locales/en.ts
  7. 2
      src/pages/secondary/NotePage/index.tsx
  8. 235
      src/pages/secondary/ProfileEditorPage/index.tsx

20
src/components/NoteInteractions/index.tsx

@ -39,20 +39,14 @@ export default function NoteInteractions({
return ( return (
<> <>
<div className="flex flex-wrap items-center justify-between gap-2 min-w-0"> <div className="flex items-center gap-2 min-w-0 px-2 sm:px-4 md:px-6 py-2">
<div className="min-w-0 flex-1 basis-full sm:basis-0"> <h2 className="min-w-0 flex-1 font-semibold text-xs sm:text-sm md:text-base text-foreground">
<div className="py-2 px-2 sm:px-4 md:px-6 font-semibold text-xs sm:text-sm md:text-base text-foreground"> {t('Replies')}
{t('Replies')} </h2>
</div> <div className="flex shrink-0 items-center gap-2">
</div> {isDiscussion && (
<Separator orientation="vertical" className="h-6" />
{isDiscussion && (
<>
<ReplySort selectedSort={replySort} onSortChange={setReplySort} /> <ReplySort selectedSort={replySort} onSortChange={setReplySort} />
<Separator orientation="vertical" className="h-6" /> )}
</>
)}
<div className="size-8 flex items-center justify-center shrink-0">
<HideUntrustedContentButton type="interactions" size="icon" /> <HideUntrustedContentButton type="interactions" size="icon" />
</div> </div>
</div> </div>

12
src/components/Profile/index.tsx

@ -86,6 +86,7 @@ import { FAST_READ_RELAY_URLS, FAST_WRITE_RELAY_URLS } from '@/constants'
import { nip66Service } from '@/services/nip66.service' import { nip66Service } from '@/services/nip66.service'
import PaymentMethodsSection from '@/components/PaymentMethodsSection' import PaymentMethodsSection from '@/components/PaymentMethodsSection'
import { import {
getAlternativePaymentMethods,
groupPaymentMethodsByDisplayType, groupPaymentMethodsByDisplayType,
mergePaymentMethods, mergePaymentMethods,
sortMergedPaymentMethods sortMergedPaymentMethods
@ -147,10 +148,11 @@ export default function Profile({
[mergedPaymentMethods] [mergedPaymentMethods]
) )
const hasLightningForZap = useMemo( const hasTipDialog = useMemo(() => {
() => paymentMethodsByType.some((g) => g.methods.some((m) => isLightningPaytoType(m.type))), const merged = sortMergedPaymentMethods(mergePaymentMethods(paymentInfo, profile ?? null))
[paymentMethodsByType] if (merged.some((m) => isLightningPaytoType(m.type))) return true
) return getAlternativePaymentMethods(merged).length > 0
}, [paymentInfo, profile])
const syncAuthorReplaceablesFromCache = useCallback(async (pubkey: string) => { const syncAuthorReplaceablesFromCache = useCallback(async (pubkey: string) => {
try { try {
@ -522,7 +524,7 @@ export default function Profile({
)} )}
{!isSelf ? ( {!isSelf ? (
<> <>
{hasLightningForZap && ( {hasTipDialog && (
<ProfileZapButton <ProfileZapButton
pubkey={pubkey} pubkey={pubkey}
openZapDialog={openZapDialog} openZapDialog={openZapDialog}

2
src/components/ReplyNoteList/index.tsx

@ -1456,7 +1456,7 @@ function ReplyNoteList({
return ( return (
<NoteFeedProfileContext.Provider value={threadNoteFeedProfileValue}> <NoteFeedProfileContext.Provider value={threadNoteFeedProfileValue}>
<div className="min-h-[80vh] pb-12"> <div className="pb-12">
{loading && <LoadingBar />} {loading && <LoadingBar />}
<div> <div>
{displayRows.map((row, ri) => { {displayRows.map((row, ri) => {

159
src/components/ZapDialog/index.tsx

@ -28,10 +28,14 @@ import { useTranslation } from 'react-i18next'
import { toast } from 'sonner' import { toast } from 'sonner'
import { import {
buildOrderedZapLightningAddresses, buildOrderedZapLightningAddresses,
prepareZapDialogAlternativePayments prepareZapDialogAlternativePayments,
ZAP_HIDE_BITCOIN_ALTS_MAX_SATS
} from '@/lib/merge-payment-methods' } from '@/lib/merge-payment-methods'
import PaymentMethodsSection from '@/components/PaymentMethodsSection' import PaymentMethodsSection from '@/components/PaymentMethodsSection'
import { useRecipientZapPaymentData } from '@/hooks/useRecipientAlternativePayments' import {
useRecipientZapPaymentData,
type RecipientZapPaymentData
} from '@/hooks/useRecipientAlternativePayments'
import { import {
Select, Select,
SelectContent, SelectContent,
@ -68,6 +72,26 @@ export default function ZapDialog({
const [tipNoticeOpen, setTipNoticeOpen] = useState(false) const [tipNoticeOpen, setTipNoticeOpen] = useState(false)
const skipTipNoticeOnCloseRef = useRef(false) const skipTipNoticeOnCloseRef = useRef(false)
const recipientPayment = useRecipientZapPaymentData(pubkey, open)
const lightningAddressOptions = useMemo(
() =>
buildOrderedZapLightningAddresses({
profileEvent: recipientPayment.profileEvent,
paymentInfo: recipientPayment.paymentInfo,
preferredAddress: defaultLightningAddress
}),
[
recipientPayment.profileEvent,
recipientPayment.paymentInfo,
defaultLightningAddress
]
)
const canLightningZap = lightningAddressOptions.length > 0
const dialogTitlePrefix = canLightningZap ? t('Zap to') : t('Pay to')
const dialogDescription = canLightningZap
? t('Send a Lightning payment to this user')
: t('Send a payment to this user')
const maybeOfferTipNoticeOnClose = () => { const maybeOfferTipNoticeOnClose = () => {
if (skipTipNoticeOnCloseRef.current) return if (skipTipNoticeOnCloseRef.current) return
if (selfPubkey && pubkey === selfPubkey) return if (selfPubkey && pubkey === selfPubkey) return
@ -126,11 +150,11 @@ export default function ZapDialog({
> >
<DrawerHeader className="shrink-0 px-4"> <DrawerHeader className="shrink-0 px-4">
<DrawerTitle className="flex gap-2 items-center"> <DrawerTitle className="flex gap-2 items-center">
<div className="shrink-0">{t('Zap to')}</div> <div className="shrink-0">{dialogTitlePrefix}</div>
<UserAvatar size="small" userId={pubkey} /> <UserAvatar size="small" userId={pubkey} />
<Username userId={pubkey} className="truncate flex-1 w-0 text-start h-5" /> <Username userId={pubkey} className="truncate flex-1 w-0 text-start h-5" />
</DrawerTitle> </DrawerTitle>
<DialogDescription className="sr-only">{t('Send a Lightning payment to this user')}</DialogDescription> <DialogDescription className="sr-only">{dialogDescription}</DialogDescription>
</DrawerHeader> </DrawerHeader>
<ZapDialogContent <ZapDialogContent
open={open} open={open}
@ -139,7 +163,9 @@ export default function ZapDialog({
event={event} event={event}
defaultAmount={defaultAmount} defaultAmount={defaultAmount}
defaultComment={defaultComment} defaultComment={defaultComment}
defaultLightningAddress={defaultLightningAddress} recipientPayment={recipientPayment}
lightningAddressOptions={lightningAddressOptions}
canLightningZap={canLightningZap}
onBeforeZapDialogClose={(withPublicReceipt) => { onBeforeZapDialogClose={(withPublicReceipt) => {
if (withPublicReceipt) skipTipNoticeOnCloseRef.current = true if (withPublicReceipt) skipTipNoticeOnCloseRef.current = true
}} }}
@ -160,11 +186,11 @@ export default function ZapDialog({
<DialogContent> <DialogContent>
<DialogHeader> <DialogHeader>
<DialogTitle className="flex gap-2 items-center"> <DialogTitle className="flex gap-2 items-center">
<div className="shrink-0">{t('Zap to')}</div> <div className="shrink-0">{dialogTitlePrefix}</div>
<UserAvatar size="small" userId={pubkey} /> <UserAvatar size="small" userId={pubkey} />
<Username userId={pubkey} className="truncate flex-1 max-w-fit text-start h-5" /> <Username userId={pubkey} className="truncate flex-1 max-w-fit text-start h-5" />
</DialogTitle> </DialogTitle>
<DialogDescription className="sr-only">{t('Send a Lightning payment to this user')}</DialogDescription> <DialogDescription className="sr-only">{dialogDescription}</DialogDescription>
</DialogHeader> </DialogHeader>
<ZapDialogContent <ZapDialogContent
open={open} open={open}
@ -173,7 +199,9 @@ export default function ZapDialog({
event={event} event={event}
defaultAmount={defaultAmount} defaultAmount={defaultAmount}
defaultComment={defaultComment} defaultComment={defaultComment}
defaultLightningAddress={defaultLightningAddress} recipientPayment={recipientPayment}
lightningAddressOptions={lightningAddressOptions}
canLightningZap={canLightningZap}
onBeforeZapDialogClose={(withPublicReceipt) => { onBeforeZapDialogClose={(withPublicReceipt) => {
if (withPublicReceipt) skipTipNoticeOnCloseRef.current = true if (withPublicReceipt) skipTipNoticeOnCloseRef.current = true
}} }}
@ -196,7 +224,9 @@ function ZapDialogContent({
event, event,
defaultAmount, defaultAmount,
defaultComment, defaultComment,
defaultLightningAddress, recipientPayment,
lightningAddressOptions,
canLightningZap,
onBeforeZapDialogClose onBeforeZapDialogClose
}: { }: {
open: boolean open: boolean
@ -205,7 +235,9 @@ function ZapDialogContent({
event?: NostrEvent event?: NostrEvent
defaultAmount?: number defaultAmount?: number
defaultComment?: string defaultComment?: string
defaultLightningAddress?: string | null recipientPayment: RecipientZapPaymentData
lightningAddressOptions: string[]
canLightningZap: boolean
/** Runs before the zap dialog closes (e.g. after payment); skip tip notice if a public receipt was sent. */ /** Runs before the zap dialog closes (e.g. after payment); skip tip notice if a public receipt was sent. */
onBeforeZapDialogClose?: (withPublicReceipt: boolean) => void onBeforeZapDialogClose?: (withPublicReceipt: boolean) => void
}) { }) {
@ -218,17 +250,7 @@ function ZapDialogContent({
const [zapping, setZapping] = useState(false) const [zapping, setZapping] = useState(false)
const [selectedLightning, setSelectedLightning] = useState('') const [selectedLightning, setSelectedLightning] = useState('')
const { paymentInfo, profileEvent, alternativeGroups } = useRecipientZapPaymentData(recipient, open) const { alternativeGroups } = recipientPayment
const lightningAddressOptions = useMemo(
() =>
buildOrderedZapLightningAddresses({
profileEvent,
paymentInfo,
preferredAddress: defaultLightningAddress
}),
[profileEvent, paymentInfo, defaultLightningAddress]
)
useEffect(() => { useEffect(() => {
if (!open) return if (!open) return
@ -236,10 +258,16 @@ function ZapDialogContent({
}, [open, lightningAddressOptions]) }, [open, lightningAddressOptions])
const zapAlternativePayments = useMemo( const zapAlternativePayments = useMemo(
() => prepareZapDialogAlternativePayments(alternativeGroups, sats), () =>
[alternativeGroups, sats] prepareZapDialogAlternativePayments(
alternativeGroups,
canLightningZap ? sats : ZAP_HIDE_BITCOIN_ALTS_MAX_SATS
),
[alternativeGroups, sats, canLightningZap]
) )
const hasAlternativePayments = zapAlternativePayments.groups.length > 0
const presetAmounts = useMemo(() => { const presetAmounts = useMemo(() => {
if (i18n.language.startsWith('zh')) { if (i18n.language.startsWith('zh')) {
return [ return [
@ -310,6 +338,33 @@ function ZapDialogContent({
} }
} }
if (!canLightningZap) {
return (
<div
className="px-4 pb-4"
style={{ paddingBottom: 'max(1rem, env(safe-area-inset-bottom))' }}
>
{hasAlternativePayments ? (
<PaymentMethodsSection
groups={zapAlternativePayments.groups}
recipientPubkey={recipient}
title={t('Payment methods')}
headerHelpText={
zapAlternativePayments.showBitcoinOnChainHint
? t('Tips above 10k sats can use Bitcoin on-chain.')
: undefined
}
className="rounded-lg border border-border bg-muted/40 p-3 min-w-0"
/>
) : (
<p className="py-8 text-center text-sm text-muted-foreground">
{t('No payment methods available for this profile')}
</p>
)}
</div>
)
}
return ( return (
<div> <div>
<div className="space-y-4"> <div className="space-y-4">
@ -377,44 +432,42 @@ function ZapDialogContent({
/> />
</div> </div>
{lightningAddressOptions.length > 0 ? ( <div className="min-w-0 space-y-1.5">
<div className="min-w-0 space-y-1.5"> <Label htmlFor="zap-lightning-address">{t('Lightning address for zap')}</Label>
<Label htmlFor="zap-lightning-address">{t('Lightning address for zap')}</Label> <Select value={selectedLightning} onValueChange={setSelectedLightning}>
<Select value={selectedLightning} onValueChange={setSelectedLightning}> <SelectTrigger id="zap-lightning-address" className="min-w-0 gap-2">
<SelectTrigger id="zap-lightning-address" className="min-w-0 gap-2"> <SelectValue placeholder={t('Select lightning address')}>
<SelectValue placeholder={t('Select lightning address')}> {selectedLightning ? (
{selectedLightning ? ( <span className="flex min-w-0 items-center gap-2">
<span className="flex min-w-0 items-center gap-2"> <span className="shrink-0 text-lg leading-none text-yellow-400" aria-hidden>
<span className="shrink-0 text-lg leading-none text-yellow-400" aria-hidden>
</span>
<span className="min-w-0 truncate">{selectedLightning}</span>
</span> </span>
) : null} <span className="min-w-0 truncate">{selectedLightning}</span>
</SelectValue> </span>
</SelectTrigger> ) : null}
<SelectContent> </SelectValue>
{lightningAddressOptions.map((addr) => ( </SelectTrigger>
<SelectItem key={addr} value={addr} className="break-all"> <SelectContent>
<span className="flex items-start gap-2"> {lightningAddressOptions.map((addr) => (
<span className="shrink-0 text-lg leading-none text-yellow-400" aria-hidden> <SelectItem key={addr} value={addr} className="break-all">
<span className="flex items-start gap-2">
</span> <span className="shrink-0 text-lg leading-none text-yellow-400" aria-hidden>
<span className="min-w-0 break-all">{addr}</span>
</span> </span>
</SelectItem> <span className="min-w-0 break-all">{addr}</span>
))} </span>
</SelectContent> </SelectItem>
</Select> ))}
</div> </SelectContent>
) : null} </Select>
</div>
<Button onClick={handleZap} className="w-full"> <Button onClick={handleZap} className="w-full">
{zapping && <Skeleton className="mr-2 inline-block size-4 shrink-0 rounded-full align-middle" aria-hidden />}{' '} {zapping && <Skeleton className="mr-2 inline-block size-4 shrink-0 rounded-full align-middle" aria-hidden />}{' '}
{t('Zap n sats', { n: sats })} {t('Zap n sats', { n: sats })}
</Button> </Button>
{zapAlternativePayments.groups.length > 0 ? ( {hasAlternativePayments ? (
<PaymentMethodsSection <PaymentMethodsSection
groups={zapAlternativePayments.groups} groups={zapAlternativePayments.groups}
recipientPubkey={recipient} recipientPubkey={recipient}

4
src/i18n/locales/de.ts

@ -153,6 +153,8 @@ export default {
"Failed to publish payment info": "Failed to publish payment info", "Failed to publish payment info": "Failed to publish payment info",
"Invalid tags JSON": "Invalid tags JSON", "Invalid tags JSON": "Invalid tags JSON",
"Payment methods": "Zahlungsmethoden", "Payment methods": "Zahlungsmethoden",
"Send a payment to this user": "Zahlung an diese Person senden",
"No payment methods available for this profile": "Keine Zahlungsmethoden für dieses Profil hinterlegt",
"Other payment methods": "Weitere Zahlungsmethoden", "Other payment methods": "Weitere Zahlungsmethoden",
"Lightning address for zap": "Lightning-Adresse für Zap", "Lightning address for zap": "Lightning-Adresse für Zap",
"Select lightning address": "Lightning-Adresse wählen", "Select lightning address": "Lightning-Adresse wählen",
@ -802,7 +804,7 @@ export default {
"Loading tally…": "Loading tally…", "Loading tally…": "Loading tally…",
"Zap poll no votes yet": "No zap votes found on the relays we queried (try Refresh tally, or votes may live on other relays).", "Zap poll no votes yet": "No zap votes found on the relays we queried (try Refresh tally, or votes may live on other relays).",
"Consensus threshold": "Consensus threshold", "Consensus threshold": "Consensus threshold",
"Pay to": "Pay to", "Pay to": "Zahlen an",
Recipient: "Recipient", Recipient: "Recipient",
Option: "Option", Option: "Option",
"Select option": "Select option", "Select option": "Select option",

2
src/i18n/locales/en.ts

@ -158,6 +158,8 @@ export default {
"Failed to publish payment info": "Failed to publish payment info", "Failed to publish payment info": "Failed to publish payment info",
"Invalid tags JSON": "Invalid tags JSON", "Invalid tags JSON": "Invalid tags JSON",
"Payment methods": "Payment methods", "Payment methods": "Payment methods",
"Send a payment to this user": "Send a payment to this user",
"No payment methods available for this profile": "No payment methods available for this profile",
"Other payment methods": "Other payment methods", "Other payment methods": "Other payment methods",
"Lightning address for zap": "Lightning address for zap", "Lightning address for zap": "Lightning address for zap",
"Select lightning address": "Select lightning address", "Select lightning address": "Select lightning address",

2
src/pages/secondary/NotePage/index.tsx

@ -568,7 +568,7 @@ const NotePage = forwardRef(({ id, index, hideTitlebar = false, initialEvent }:
/> />
</div> </div>
<Separator className="mt-4" /> <Separator className="mt-4" />
<div className="px-4 pb-4 w-full"> <div className="px-4 pb-12 w-full">
<NoteInteractions <NoteInteractions
key={`note-interactions-${finalEvent.id}`} key={`note-interactions-${finalEvent.id}`}
pageIndex={index} pageIndex={index}

235
src/pages/secondary/ProfileEditorPage/index.tsx

@ -41,6 +41,7 @@ import { ChevronDown, Fingerprint, Pencil, Plus, RefreshCw, Trash2, Upload } fro
import type { Event } from 'nostr-tools' import type { Event } from 'nostr-tools'
import { forwardRef, useCallback, useEffect, useMemo, useRef, useState } from 'react' import { forwardRef, useCallback, useEffect, useMemo, useRef, useState } from 'react'
import { useTranslation } from 'react-i18next' import { useTranslation } from 'react-i18next'
import { toastPublishPromise } from '@/lib/publishing-feedback'
import { toast } from 'sonner' import { toast } from 'sonner'
/** Required tag fields: always exactly one row, cannot be deleted. */ /** Required tag fields: always exactly one row, cannot be deleted. */
@ -126,6 +127,8 @@ const ProfileEditorPage = forwardRef(({ index }: { index?: number }, ref) => {
const [hasChanged, setHasChanged] = useState(false) const [hasChanged, setHasChanged] = useState(false)
const [saving, setSaving] = useState(false) const [saving, setSaving] = useState(false)
const savingRef = useRef(false)
const mountedRef = useRef(true)
const [uploadingBanner, setUploadingBanner] = useState(false) const [uploadingBanner, setUploadingBanner] = useState(false)
const [uploadingAvatar, setUploadingAvatar] = useState(false) const [uploadingAvatar, setUploadingAvatar] = useState(false)
const [paymentInfoEvent, setPaymentInfoEvent] = useState<Event | null>(null) const [paymentInfoEvent, setPaymentInfoEvent] = useState<Event | null>(null)
@ -153,17 +156,27 @@ const ProfileEditorPage = forwardRef(({ index }: { index?: number }, ref) => {
const avatar = profileTags.find((t) => t[0] === 'picture')?.[1] ?? '' const avatar = profileTags.find((t) => t[0] === 'picture')?.[1] ?? ''
const banner = profileTags.find((t) => t[0] === 'banner')?.[1] ?? '' const banner = profileTags.find((t) => t[0] === 'banner')?.[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. // Rebuild tag list when the stored profile event changes — not while the user is editing.
useEffect(() => { useEffect(() => {
if (hasChanged) return if (profileFormSyncLocked) return
setProfileTags(buildTagListFromEvent(profileEvent ?? null)) setProfileTags(buildTagListFromEvent(profileEvent ?? null))
}, [profileEvent, hasChanged]) }, [profileEvent, profileFormSyncLocked])
// Sync full-event JSON editor (same guard as tag list). // Sync full-event JSON editor (same guard as tag list).
useEffect(() => { useEffect(() => {
if (hasChanged) return if (profileFormSyncLocked) return
setProfileEventJson(profileEvent ? JSON.stringify(profileEvent, null, 2) : '') setProfileEventJson(profileEvent ? JSON.stringify(profileEvent, null, 2) : '')
}, [profileEvent, hasChanged]) }, [profileEvent, profileFormSyncLocked])
// Fetch payment info (kind 10133). // Fetch payment info (kind 10133).
useEffect(() => { useEffect(() => {
@ -302,31 +315,36 @@ const ProfileEditorPage = forwardRef(({ index }: { index?: number }, ref) => {
} }
}, [account?.pubkey, relayList, requestAccountNetworkHydrate, updateProfileEvent, t]) }, [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 ────────────────────────────────────────────────────────────────── // ─── Guards ──────────────────────────────────────────────────────────────────
if (!account) return null if (!account) return null
if (!profile) { if (!profile) {
const loadingControls = (
<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"
>
{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>
)
return ( return (
<SecondaryPageLayout ref={ref} index={index} title="…" controls={loadingControls}> <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"> <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" /> <Skeleton className="h-4 w-48 rounded" />
<p> <p>
@ -381,69 +399,82 @@ const ProfileEditorPage = forwardRef(({ index }: { index?: number }, ref) => {
// ─── Save ───────────────────────────────────────────────────────────────────── // ─── Save ─────────────────────────────────────────────────────────────────────
const save = async () => { const save = () => {
if (savingRef.current) return
savingRef.current = true
setSaving(true) setSaving(true)
setHasChanged(false)
try { const savePromise = (async () => {
// Strip empty/incomplete rows, trim whitespace. try {
const validTags = profileTags // Strip empty/incomplete rows, trim whitespace.
.filter((t) => { const validTags = profileTags
if (!Array.isArray(t) || !(t[0] ?? '').trim()) return false .filter((t) => {
const name = (t[0] ?? '').trim() if (!Array.isArray(t) || !(t[0] ?? '').trim()) return false
if (name === 'bot') return true const name = (t[0] ?? '').trim()
return t.length >= 2 && (t[1] ?? '').trim() if (name === 'bot') return true
}) return t.length >= 2 && (t[1] ?? '').trim()
.map((t) => { })
const name = (t[0] ?? '').trim() .map((t) => {
const v1 = (t[1] ?? '').trim() const name = (t[0] ?? '').trim()
if (name === 'bot') { const v1 = (t[1] ?? '').trim()
if (t.length === 1 || !v1) return ['bot'] if (name === 'bot') {
const low = v1.toLowerCase() if (t.length === 1 || !v1) return ['bot']
if (low === 'false') return ['bot', 'false'] const low = v1.toLowerCase()
if (low === 'true') return ['bot', 'true'] if (low === 'false') return ['bot', 'false']
return ['bot', v1] if (low === 'true') return ['bot', 'true']
} return ['bot', v1]
return [name, v1, ...t.slice(2)] }
}) return [name, v1, ...t.slice(2)]
})
// Sort alphabetically by tag name (stable: same-name tags keep their relative order).
const sortedTags = [...validTags] // Sort alphabetically by tag name (stable: same-name tags keep their relative order).
.sort((a, b) => a[0].localeCompare(b[0])) const sortedTags = [...validTags]
// Enforce at-most-one uniqueness: keep only the first occurrence. .sort((a, b) => a[0].localeCompare(b[0]))
.filter((() => { // Enforce at-most-one uniqueness: keep only the first occurrence.
const seen = new Set<string>() .filter((() => {
return (t: string[]) => { const seen = new Set<string>()
if (!AT_MOST_ONE_NAMES.includes(t[0])) return true return (t: string[]) => {
if (seen.has(t[0])) return false if (!AT_MOST_ONE_NAMES.includes(t[0])) return true
seen.add(t[0]) if (seen.has(t[0])) return false
return true seen.add(t[0])
return true
}
})())
// Derive content JSON: first occurrence of each known field.
const content: Record<string, string> = {}
const seenContent = new Set<string>()
for (const tag of sortedTags) {
const name = tag[0]
if (name === 'bot') continue
if (DISPLAY_ORDER.includes(name) && !seenContent.has(name)) {
content[name] = tag[1]
seenContent.add(name)
} }
})())
// Derive content JSON: first occurrence of each known field.
const content: Record<string, string> = {}
const seenContent = new Set<string>()
for (const tag of sortedTags) {
const name = tag[0]
if (name === 'bot') continue
if (DISPLAY_ORDER.includes(name) && !seenContent.has(name)) {
content[name] = tag[1]
seenContent.add(name)
} }
// Keep displayName alias for backward compatibility.
if (content['display_name']) content['displayName'] = content['display_name']
const draft = createProfileDraftEvent(JSON.stringify(content), sortedTags)
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)
} }
// Keep displayName alias for backward compatibility. })()
if (content['display_name']) content['displayName'] = content['display_name']
const draft = createProfileDraftEvent(JSON.stringify(content), sortedTags) toastPublishPromise(savePromise, {
const published = await publish(draft) loading: t('Saving…'),
await updateProfileEvent(published) success: t('Profile updated'),
pop() error: () => t('Failed to publish profile')
} catch { })
toast.error(t('Failed to publish profile'))
setHasChanged(true)
} finally {
setSaving(false)
}
} }
const saveFullProfile = async () => { const saveFullProfile = async () => {
@ -477,38 +508,10 @@ const ProfileEditorPage = forwardRef(({ index }: { index?: number }, ref) => {
} }
} }
// ─── Controls ─────────────────────────────────────────────────────────────────
const controls = (
<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>
<Button className="min-w-16 shrink-0 rounded-full" onClick={save} disabled={saving || !hasChanged}>
{saving ? <Skeleton className="mx-auto h-4 w-12 rounded-md" aria-hidden /> : t('Save')}
</Button>
</div>
)
// ─── Render ─────────────────────────────────────────────────────────────────── // ─── Render ───────────────────────────────────────────────────────────────────
return ( return (
<SecondaryPageLayout ref={ref} index={index} title={profile.username} controls={controls}> <SecondaryPageLayout ref={ref} index={index} title={profile.username} controls={refreshCacheControl}>
{/* Banner & avatar uploaders */} {/* Banner & avatar uploaders */}
<div className="relative isolate mb-2 bg-cover bg-center"> <div className="relative isolate mb-2 bg-cover bg-center">
{/* Banner under avatar in stacking order; fetchPriority still loads the pic first. */} {/* Banner under avatar in stacking order; fetchPriority still loads the pic first. */}
@ -577,7 +580,7 @@ const ProfileEditorPage = forwardRef(({ index }: { index?: number }, ref) => {
</Uploader> </Uploader>
</div> </div>
<div className="pt-14 px-4 flex flex-col gap-4"> <div className="pt-14 px-4 pb-16 flex flex-col gap-4 max-sm:pb-24">
{/* ── Unified tag list ── */} {/* ── Unified tag list ── */}
<Item> <Item>
<Label className="text-muted-foreground">{t('Tag list')}</Label> <Label className="text-muted-foreground">{t('Tag list')}</Label>
@ -703,6 +706,16 @@ const ProfileEditorPage = forwardRef(({ index }: { index?: number }, ref) => {
</div> </div>
</Item> </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) ── */} {/* ── Full profile event JSON (collapsible) ── */}
{profileEvent && ( {profileEvent && (
<Item> <Item>

Loading…
Cancel
Save