diff --git a/src/components/NoteInteractions/index.tsx b/src/components/NoteInteractions/index.tsx index 886eb6dc..221f0002 100644 --- a/src/components/NoteInteractions/index.tsx +++ b/src/components/NoteInteractions/index.tsx @@ -39,20 +39,14 @@ export default function NoteInteractions({ return ( <> -
-
-
- {t('Replies')} -
-
- - {isDiscussion && ( - <> +
+

+ {t('Replies')} +

+
+ {isDiscussion && ( - - - )} -
+ )}
diff --git a/src/components/Profile/index.tsx b/src/components/Profile/index.tsx index 2c70895a..11fe6604 100644 --- a/src/components/Profile/index.tsx +++ b/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 PaymentMethodsSection from '@/components/PaymentMethodsSection' import { + getAlternativePaymentMethods, groupPaymentMethodsByDisplayType, mergePaymentMethods, sortMergedPaymentMethods @@ -147,10 +148,11 @@ export default function Profile({ [mergedPaymentMethods] ) - const hasLightningForZap = useMemo( - () => paymentMethodsByType.some((g) => g.methods.some((m) => isLightningPaytoType(m.type))), - [paymentMethodsByType] - ) + const hasTipDialog = useMemo(() => { + const merged = sortMergedPaymentMethods(mergePaymentMethods(paymentInfo, profile ?? null)) + if (merged.some((m) => isLightningPaytoType(m.type))) return true + return getAlternativePaymentMethods(merged).length > 0 + }, [paymentInfo, profile]) const syncAuthorReplaceablesFromCache = useCallback(async (pubkey: string) => { try { @@ -522,7 +524,7 @@ export default function Profile({ )} {!isSelf ? ( <> - {hasLightningForZap && ( + {hasTipDialog && ( -
+
{loading && }
{displayRows.map((row, ri) => { diff --git a/src/components/ZapDialog/index.tsx b/src/components/ZapDialog/index.tsx index e610e308..607f234a 100644 --- a/src/components/ZapDialog/index.tsx +++ b/src/components/ZapDialog/index.tsx @@ -28,10 +28,14 @@ import { useTranslation } from 'react-i18next' import { toast } from 'sonner' import { buildOrderedZapLightningAddresses, - prepareZapDialogAlternativePayments + prepareZapDialogAlternativePayments, + ZAP_HIDE_BITCOIN_ALTS_MAX_SATS } from '@/lib/merge-payment-methods' import PaymentMethodsSection from '@/components/PaymentMethodsSection' -import { useRecipientZapPaymentData } from '@/hooks/useRecipientAlternativePayments' +import { + useRecipientZapPaymentData, + type RecipientZapPaymentData +} from '@/hooks/useRecipientAlternativePayments' import { Select, SelectContent, @@ -68,6 +72,26 @@ export default function ZapDialog({ const [tipNoticeOpen, setTipNoticeOpen] = useState(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 = () => { if (skipTipNoticeOnCloseRef.current) return if (selfPubkey && pubkey === selfPubkey) return @@ -126,11 +150,11 @@ export default function ZapDialog({ > -
{t('Zap to')}
+
{dialogTitlePrefix}
- {t('Send a Lightning payment to this user')} + {dialogDescription}
{ if (withPublicReceipt) skipTipNoticeOnCloseRef.current = true }} @@ -160,11 +186,11 @@ export default function ZapDialog({ -
{t('Zap to')}
+
{dialogTitlePrefix}
- {t('Send a Lightning payment to this user')} + {dialogDescription}
{ if (withPublicReceipt) skipTipNoticeOnCloseRef.current = true }} @@ -196,7 +224,9 @@ function ZapDialogContent({ event, defaultAmount, defaultComment, - defaultLightningAddress, + recipientPayment, + lightningAddressOptions, + canLightningZap, onBeforeZapDialogClose }: { open: boolean @@ -205,7 +235,9 @@ function ZapDialogContent({ event?: NostrEvent defaultAmount?: number 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. */ onBeforeZapDialogClose?: (withPublicReceipt: boolean) => void }) { @@ -218,17 +250,7 @@ function ZapDialogContent({ const [zapping, setZapping] = useState(false) const [selectedLightning, setSelectedLightning] = useState('') - const { paymentInfo, profileEvent, alternativeGroups } = useRecipientZapPaymentData(recipient, open) - - const lightningAddressOptions = useMemo( - () => - buildOrderedZapLightningAddresses({ - profileEvent, - paymentInfo, - preferredAddress: defaultLightningAddress - }), - [profileEvent, paymentInfo, defaultLightningAddress] - ) + const { alternativeGroups } = recipientPayment useEffect(() => { if (!open) return @@ -236,10 +258,16 @@ function ZapDialogContent({ }, [open, lightningAddressOptions]) 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(() => { if (i18n.language.startsWith('zh')) { return [ @@ -310,6 +338,33 @@ function ZapDialogContent({ } } + if (!canLightningZap) { + return ( +
+ {hasAlternativePayments ? ( + + ) : ( +

+ {t('No payment methods available for this profile')} +

+ )} +
+ ) + } + return (
@@ -377,44 +432,42 @@ function ZapDialogContent({ />
- {lightningAddressOptions.length > 0 ? ( -
- - + + + {selectedLightning ? ( + + + ⚡ - ) : null} - - - - {lightningAddressOptions.map((addr) => ( - - - - ⚡ - - {addr} + {selectedLightning} + + ) : null} + + + + {lightningAddressOptions.map((addr) => ( + + + + ⚡ - - ))} - - -
- ) : null} + {addr} + + + ))} + + +
- {zapAlternativePayments.groups.length > 0 ? ( + {hasAlternativePayments ? (
-
+
{ 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(null) @@ -153,17 +156,27 @@ const ProfileEditorPage = forwardRef(({ index }: { index?: number }, ref) => { const avatar = profileTags.find((t) => t[0] === 'picture')?.[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. useEffect(() => { - if (hasChanged) return + if (profileFormSyncLocked) return setProfileTags(buildTagListFromEvent(profileEvent ?? null)) - }, [profileEvent, hasChanged]) + }, [profileEvent, profileFormSyncLocked]) // Sync full-event JSON editor (same guard as tag list). useEffect(() => { - if (hasChanged) return + if (profileFormSyncLocked) return setProfileEventJson(profileEvent ? JSON.stringify(profileEvent, null, 2) : '') - }, [profileEvent, hasChanged]) + }, [profileEvent, profileFormSyncLocked]) // Fetch payment info (kind 10133). useEffect(() => { @@ -302,31 +315,36 @@ const ProfileEditorPage = forwardRef(({ index }: { index?: number }, ref) => { } }, [account?.pubkey, relayList, requestAccountNetworkHydrate, updateProfileEvent, t]) + const refreshCacheControl = ( +
+ +
+ ) + // ─── Guards ────────────────────────────────────────────────────────────────── if (!account) return null if (!profile) { - const loadingControls = ( -
- -
- ) return ( - +

@@ -381,69 +399,82 @@ const ProfileEditorPage = forwardRef(({ index }: { index?: number }, ref) => { // ─── Save ───────────────────────────────────────────────────────────────────── - const save = async () => { + const save = () => { + if (savingRef.current) return + savingRef.current = true setSaving(true) - setHasChanged(false) - try { - // Strip empty/incomplete rows, trim whitespace. - const validTags = profileTags - .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)] - }) - - // Sort alphabetically by tag name (stable: same-name tags keep their relative order). - const sortedTags = [...validTags] - .sort((a, b) => a[0].localeCompare(b[0])) - // Enforce at-most-one uniqueness: keep only the first occurrence. - .filter((() => { - const seen = new Set() - 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 savePromise = (async () => { + try { + // Strip empty/incomplete rows, trim whitespace. + const validTags = profileTags + .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)] + }) + + // Sort alphabetically by tag name (stable: same-name tags keep their relative order). + const sortedTags = [...validTags] + .sort((a, b) => a[0].localeCompare(b[0])) + // Enforce at-most-one uniqueness: keep only the first occurrence. + .filter((() => { + const seen = new Set() + 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 + } + })()) + + // Derive content JSON: first occurrence of each known field. + const content: Record = {} + const seenContent = new Set() + 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 = {} - const seenContent = new Set() - 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) - const published = await publish(draft) - await updateProfileEvent(published) - pop() - } catch { - toast.error(t('Failed to publish profile')) - setHasChanged(true) - } finally { - setSaving(false) - } + toastPublishPromise(savePromise, { + loading: t('Saving…'), + success: t('Profile updated'), + error: () => t('Failed to publish profile') + }) } const saveFullProfile = async () => { @@ -477,38 +508,10 @@ const ProfileEditorPage = forwardRef(({ index }: { index?: number }, ref) => { } } - // ─── Controls ───────────────────────────────────────────────────────────────── - - const controls = ( -

- - -
- ) - // ─── Render ─────────────────────────────────────────────────────────────────── return ( - + {/* Banner & avatar uploaders */}
{/* Banner under avatar in stacking order; fetchPriority still loads the pic first. */} @@ -577,7 +580,7 @@ const ProfileEditorPage = forwardRef(({ index }: { index?: number }, ref) => {
-
+
{/* ── Unified tag list ── */} @@ -703,6 +706,16 @@ const ProfileEditorPage = forwardRef(({ index }: { index?: number }, ref) => {
+
+ +
+ {/* ── Full profile event JSON (collapsible) ── */} {profileEvent && (