Browse Source

handle missing lightning wallets gracefully

imwald
Silberengel 4 weeks ago
parent
commit
0e195ab66c
  1. 14
      src/components/NoteInteractions/index.tsx
  2. 12
      src/components/Profile/index.tsx
  3. 2
      src/components/ReplyNoteList/index.tsx
  4. 105
      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. 105
      src/pages/secondary/ProfileEditorPage/index.tsx

14
src/components/NoteInteractions/index.tsx

@ -39,20 +39,14 @@ export default function NoteInteractions({ @@ -39,20 +39,14 @@ export default function NoteInteractions({
return (
<>
<div className="flex flex-wrap items-center justify-between gap-2 min-w-0">
<div className="min-w-0 flex-1 basis-full sm:basis-0">
<div className="py-2 px-2 sm:px-4 md:px-6 font-semibold text-xs sm:text-sm md:text-base text-foreground">
<div className="flex items-center gap-2 min-w-0 px-2 sm:px-4 md:px-6 py-2">
<h2 className="min-w-0 flex-1 font-semibold text-xs sm:text-sm md:text-base text-foreground">
{t('Replies')}
</div>
</div>
<Separator orientation="vertical" className="h-6" />
</h2>
<div className="flex shrink-0 items-center gap-2">
{isDiscussion && (
<>
<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" />
</div>
</div>

12
src/components/Profile/index.tsx

@ -86,6 +86,7 @@ import { FAST_READ_RELAY_URLS, FAST_WRITE_RELAY_URLS } from '@/constants' @@ -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({ @@ -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({ @@ -522,7 +524,7 @@ export default function Profile({
)}
{!isSelf ? (
<>
{hasLightningForZap && (
{hasTipDialog && (
<ProfileZapButton
pubkey={pubkey}
openZapDialog={openZapDialog}

2
src/components/ReplyNoteList/index.tsx

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

105
src/components/ZapDialog/index.tsx

@ -28,10 +28,14 @@ import { useTranslation } from 'react-i18next' @@ -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({ @@ -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({ @@ -126,11 +150,11 @@ export default function ZapDialog({
>
<DrawerHeader className="shrink-0 px-4">
<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} />
<Username userId={pubkey} className="truncate flex-1 w-0 text-start h-5" />
</DrawerTitle>
<DialogDescription className="sr-only">{t('Send a Lightning payment to this user')}</DialogDescription>
<DialogDescription className="sr-only">{dialogDescription}</DialogDescription>
</DrawerHeader>
<ZapDialogContent
open={open}
@ -139,7 +163,9 @@ export default function ZapDialog({ @@ -139,7 +163,9 @@ export default function ZapDialog({
event={event}
defaultAmount={defaultAmount}
defaultComment={defaultComment}
defaultLightningAddress={defaultLightningAddress}
recipientPayment={recipientPayment}
lightningAddressOptions={lightningAddressOptions}
canLightningZap={canLightningZap}
onBeforeZapDialogClose={(withPublicReceipt) => {
if (withPublicReceipt) skipTipNoticeOnCloseRef.current = true
}}
@ -160,11 +186,11 @@ export default function ZapDialog({ @@ -160,11 +186,11 @@ export default function ZapDialog({
<DialogContent>
<DialogHeader>
<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} />
<Username userId={pubkey} className="truncate flex-1 max-w-fit text-start h-5" />
</DialogTitle>
<DialogDescription className="sr-only">{t('Send a Lightning payment to this user')}</DialogDescription>
<DialogDescription className="sr-only">{dialogDescription}</DialogDescription>
</DialogHeader>
<ZapDialogContent
open={open}
@ -173,7 +199,9 @@ export default function ZapDialog({ @@ -173,7 +199,9 @@ export default function ZapDialog({
event={event}
defaultAmount={defaultAmount}
defaultComment={defaultComment}
defaultLightningAddress={defaultLightningAddress}
recipientPayment={recipientPayment}
lightningAddressOptions={lightningAddressOptions}
canLightningZap={canLightningZap}
onBeforeZapDialogClose={(withPublicReceipt) => {
if (withPublicReceipt) skipTipNoticeOnCloseRef.current = true
}}
@ -196,7 +224,9 @@ function ZapDialogContent({ @@ -196,7 +224,9 @@ function ZapDialogContent({
event,
defaultAmount,
defaultComment,
defaultLightningAddress,
recipientPayment,
lightningAddressOptions,
canLightningZap,
onBeforeZapDialogClose
}: {
open: boolean
@ -205,7 +235,9 @@ function ZapDialogContent({ @@ -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({ @@ -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({ @@ -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({ @@ -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 (
<div>
<div className="space-y-4">
@ -377,7 +432,6 @@ function ZapDialogContent({ @@ -377,7 +432,6 @@ function ZapDialogContent({
/>
</div>
{lightningAddressOptions.length > 0 ? (
<div className="min-w-0 space-y-1.5">
<Label htmlFor="zap-lightning-address">{t('Lightning address for zap')}</Label>
<Select value={selectedLightning} onValueChange={setSelectedLightning}>
@ -407,14 +461,13 @@ function ZapDialogContent({ @@ -407,14 +461,13 @@ function ZapDialogContent({
</SelectContent>
</Select>
</div>
) : null}
<Button onClick={handleZap} className="w-full">
{zapping && <Skeleton className="mr-2 inline-block size-4 shrink-0 rounded-full align-middle" aria-hidden />}{' '}
{t('Zap n sats', { n: sats })}
</Button>
{zapAlternativePayments.groups.length > 0 ? (
{hasAlternativePayments ? (
<PaymentMethodsSection
groups={zapAlternativePayments.groups}
recipientPubkey={recipient}

4
src/i18n/locales/de.ts

@ -153,6 +153,8 @@ export default { @@ -153,6 +153,8 @@ export default {
"Failed to publish payment info": "Failed to publish payment info",
"Invalid tags JSON": "Invalid tags JSON",
"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",
"Lightning address for zap": "Lightning-Adresse für Zap",
"Select lightning address": "Lightning-Adresse wählen",
@ -802,7 +804,7 @@ export default { @@ -802,7 +804,7 @@ export default {
"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).",
"Consensus threshold": "Consensus threshold",
"Pay to": "Pay to",
"Pay to": "Zahlen an",
Recipient: "Recipient",
Option: "Option",
"Select option": "Select option",

2
src/i18n/locales/en.ts

@ -158,6 +158,8 @@ export default { @@ -158,6 +158,8 @@ export default {
"Failed to publish payment info": "Failed to publish payment info",
"Invalid tags JSON": "Invalid tags JSON",
"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",
"Lightning address for zap": "Lightning address for zap",
"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 }: @@ -568,7 +568,7 @@ const NotePage = forwardRef(({ id, index, hideTitlebar = false, initialEvent }:
/>
</div>
<Separator className="mt-4" />
<div className="px-4 pb-4 w-full">
<div className="px-4 pb-12 w-full">
<NoteInteractions
key={`note-interactions-${finalEvent.id}`}
pageIndex={index}

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

@ -41,6 +41,7 @@ import { ChevronDown, Fingerprint, Pencil, Plus, RefreshCw, Trash2, Upload } fro @@ -41,6 +41,7 @@ import { ChevronDown, Fingerprint, Pencil, Plus, RefreshCw, Trash2, Upload } fro
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. */
@ -126,6 +127,8 @@ const ProfileEditorPage = forwardRef(({ index }: { index?: number }, ref) => { @@ -126,6 +127,8 @@ const ProfileEditorPage = forwardRef(({ index }: { index?: number }, ref) => {
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)
@ -153,17 +156,27 @@ const ProfileEditorPage = forwardRef(({ index }: { index?: number }, ref) => { @@ -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,12 +315,7 @@ const ProfileEditorPage = forwardRef(({ index }: { index?: number }, ref) => { @@ -302,12 +315,7 @@ const ProfileEditorPage = forwardRef(({ index }: { index?: number }, ref) => {
}
}, [account?.pubkey, relayList, requestAccountNetworkHydrate, updateProfileEvent, t])
// ─── Guards ──────────────────────────────────────────────────────────────────
if (!account) return null
if (!profile) {
const loadingControls = (
const refreshCacheControl = (
<div className="pr-3 flex flex-wrap items-center justify-end gap-2 min-w-0">
<Button
variant="outline"
@ -315,6 +323,10 @@ const ProfileEditorPage = forwardRef(({ index }: { index?: number }, ref) => { @@ -315,6 +323,10 @@ const ProfileEditorPage = forwardRef(({ index }: { index?: number }, ref) => {
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 />
@ -325,8 +337,14 @@ const ProfileEditorPage = forwardRef(({ index }: { index?: number }, ref) => { @@ -325,8 +337,14 @@ const ProfileEditorPage = forwardRef(({ index }: { index?: number }, ref) => {
</Button>
</div>
)
// ─── Guards ──────────────────────────────────────────────────────────────────
if (!account) return null
if (!profile) {
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">
<Skeleton className="h-4 w-48 rounded" />
<p>
@ -381,9 +399,12 @@ const ProfileEditorPage = forwardRef(({ index }: { index?: number }, ref) => { @@ -381,9 +399,12 @@ const ProfileEditorPage = forwardRef(({ index }: { index?: number }, ref) => {
// ─── Save ─────────────────────────────────────────────────────────────────────
const save = async () => {
const save = () => {
if (savingRef.current) return
savingRef.current = true
setSaving(true)
setHasChanged(false)
const savePromise = (async () => {
try {
// Strip empty/incomplete rows, trim whitespace.
const validTags = profileTags
@ -437,13 +458,23 @@ const ProfileEditorPage = forwardRef(({ index }: { index?: number }, ref) => { @@ -437,13 +458,23 @@ const ProfileEditorPage = forwardRef(({ index }: { index?: number }, ref) => {
const draft = createProfileDraftEvent(JSON.stringify(content), sortedTags)
const published = await publish(draft)
await updateProfileEvent(published)
if (!mountedRef.current) return
setHasChanged(false)
pop()
} catch {
toast.error(t('Failed to publish profile'))
setHasChanged(true)
if (mountedRef.current) setHasChanged(true)
throw new Error(t('Failed to publish profile'))
} finally {
setSaving(false)
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 () => {
@ -477,38 +508,10 @@ const ProfileEditorPage = forwardRef(({ index }: { index?: number }, ref) => { @@ -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 ───────────────────────────────────────────────────────────────────
return (
<SecondaryPageLayout ref={ref} index={index} title={profile.username} controls={controls}>
<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. */}
@ -577,7 +580,7 @@ const ProfileEditorPage = forwardRef(({ index }: { index?: number }, ref) => { @@ -577,7 +580,7 @@ const ProfileEditorPage = forwardRef(({ index }: { index?: number }, ref) => {
</Uploader>
</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 ── */}
<Item>
<Label className="text-muted-foreground">{t('Tag list')}</Label>
@ -703,6 +706,16 @@ const ProfileEditorPage = forwardRef(({ index }: { index?: number }, ref) => { @@ -703,6 +706,16 @@ const ProfileEditorPage = forwardRef(({ index }: { index?: number }, ref) => {
</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>

Loading…
Cancel
Save