diff --git a/src/components/NoteStats/ZapButton.tsx b/src/components/NoteStats/ZapButton.tsx
index 1ed40cb0..089af2e7 100644
--- a/src/components/NoteStats/ZapButton.tsx
+++ b/src/components/NoteStats/ZapButton.tsx
@@ -16,6 +16,7 @@ import { MouseEvent, TouchEvent, useEffect, useMemo, useRef, useState } from 're
import { useTranslation } from 'react-i18next'
import { toast } from 'sonner'
import ZapDialog from '../ZapDialog'
+import TipPublicMessagePrompt from '../ZapDialog/TipPublicMessagePrompt'
type ZapButtonProps = {
event: Event
@@ -29,6 +30,7 @@ export function ZapButtonWithStats({ event, hideCount = false, noteStats }: ZapB
const { defaultZapSats, defaultZapComment, quickZap, includePublicZapReceipt } = useZap()
const [touchStart, setTouchStart] = useState<{ x: number; y: number } | null>(null)
const [openZapDialog, setOpenZapDialog] = useState(false)
+ const [tipNoticeOpen, setTipNoticeOpen] = useState(false)
const [zapping, setZapping] = useState(false)
const statsLoaded = noteStats?.updatedAt != null
const { zapAmount, hasZapped } = useMemo(() => {
@@ -85,6 +87,9 @@ export function ZapButtonWithStats({ event, hideCount = false, noteStats }: ZapB
defaultZapSats,
defaultZapComment
)
+ if (event.pubkey !== pubkey && !includePublicZapReceipt) {
+ setTipNoticeOpen(true)
+ }
} catch (error) {
toast.error(`${t('Zap failed')}: ${(error as Error).message}`)
} finally {
@@ -199,6 +204,11 @@ export function ZapButtonWithStats({ event, hideCount = false, noteStats }: ZapB
pubkey={event.pubkey}
event={event}
/>
+
>
)
}
diff --git a/src/components/Profile/index.tsx b/src/components/Profile/index.tsx
index 05dc79f9..ecc3bc2e 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
@@ -134,10 +135,20 @@ export default function Profile({
const { relayUrls: currentBrowsingRelayUrls } = useCurrentRelays()
const { relaySets, favoriteRelays } = useFavoriteRelays()
- const paymentMethodsByType = useMemo(() => {
- const list = sortMergedPaymentMethods(mergePaymentMethods(paymentInfo, profile ?? null))
- return groupPaymentMethodsByDisplayType(list)
- }, [paymentInfo, profile])
+ const mergedPaymentMethods = useMemo(
+ () => sortMergedPaymentMethods(mergePaymentMethods(paymentInfo, profile ?? null)),
+ [paymentInfo, profile]
+ )
+
+ const paymentMethodsByType = useMemo(
+ () => groupPaymentMethodsByDisplayType(mergedPaymentMethods),
+ [mergedPaymentMethods]
+ )
+
+ const alternativePaymentGroups = useMemo(() => {
+ const alts = getAlternativePaymentMethods(mergedPaymentMethods, profile?.lightningAddress)
+ return groupPaymentMethodsByDisplayType(alts)
+ }, [mergedPaymentMethods, profile?.lightningAddress])
const hasLightningForZap = useMemo(
() => paymentMethodsByType.some((g) => g.methods.some((m) => m.type === 'lightning')),
@@ -574,6 +585,7 @@ export default function Profile({
open={openZapDialog}
setOpen={setOpenZapDialog}
pubkey={pubkey}
+ alternativePaymentGroups={alternativePaymentGroups}
/>
diff --git a/src/components/ZapDialog/TipPublicMessagePrompt.tsx b/src/components/ZapDialog/TipPublicMessagePrompt.tsx
new file mode 100644
index 00000000..6fa7f88a
--- /dev/null
+++ b/src/components/ZapDialog/TipPublicMessagePrompt.tsx
@@ -0,0 +1,155 @@
+import { Button } from '@/components/ui/button'
+import {
+ Dialog,
+ DialogContent,
+ DialogDescription,
+ DialogFooter,
+ DialogHeader,
+ DialogTitle
+} from '@/components/ui/dialog'
+import {
+ Drawer,
+ DrawerContent,
+ DrawerFooter,
+ DrawerHeader,
+ DrawerTitle
+} from '@/components/ui/drawer'
+import { createPublicMessageDraftEvent } from '@/lib/draft-event'
+import { showSimplePublishSuccess } from '@/lib/publishing-feedback'
+import { pubkeyToNpub } from '@/lib/pubkey'
+import { useNostr } from '@/providers/NostrProvider'
+import { useScreenSize } from '@/providers/ScreenSizeProvider'
+import { LoginRequiredError } from '@/lib/nostr-errors'
+import { useEffect, useRef, useState } from 'react'
+import { useTranslation } from 'react-i18next'
+import { toast } from 'sonner'
+import UserAvatar from '../UserAvatar'
+import Username from '../Username'
+
+const TIP_NOTICE_DEFAULT_KEY = 'I just sent you a tip!'
+
+export default function TipPublicMessagePrompt({
+ open,
+ onOpenChange,
+ recipientPubkey
+}: {
+ open: boolean
+ onOpenChange: (open: boolean) => void
+ recipientPubkey: string | null
+}) {
+ const { t } = useTranslation()
+ const { isSmallScreen } = useScreenSize()
+ const { publish, checkLogin, pubkey: selfPubkey } = useNostr()
+ const [sending, setSending] = useState(false)
+ const cancelRef = useRef
(null)
+
+ const tipText = t(TIP_NOTICE_DEFAULT_KEY)
+ const npub = recipientPubkey ? pubkeyToNpub(recipientPubkey) : null
+ const previewContent = npub ? `nostr:${npub} ${tipText}` : tipText
+
+ useEffect(() => {
+ if (!open) return
+ const id = requestAnimationFrame(() => {
+ cancelRef.current?.focus()
+ })
+ return () => cancelAnimationFrame(id)
+ }, [open])
+
+ const handleSend = () => {
+ if (!recipientPubkey) return
+ checkLogin(async () => {
+ if (selfPubkey === recipientPubkey) {
+ onOpenChange(false)
+ return
+ }
+ setSending(true)
+ try {
+ const draft = await createPublicMessageDraftEvent(previewContent, [recipientPubkey], {
+ addClientTag: true
+ })
+ await publish(draft, { disableFallbacks: true })
+ showSimplePublishSuccess(t('Tip notice sent'))
+ onOpenChange(false)
+ } catch (error) {
+ if (error instanceof LoginRequiredError) return
+ toast.error(
+ t('Failed to send tip notice', {
+ error: error instanceof Error ? error.message : String(error)
+ })
+ )
+ } finally {
+ setSending(false)
+ }
+ })
+ }
+
+ const body = (
+ <>
+ {t('Tip notice success only note')}
+ {t('Tip notice prompt description')}
+ {previewContent}
+ >
+ )
+
+ const actions = (
+ <>
+
+
+ >
+ )
+
+ if (!recipientPubkey) return null
+
+ if (isSmallScreen) {
+ return (
+
+ e.preventDefault()}>
+
+
+ {t('Tip notice prompt title')}
+
+
+
+
+ {body}
+ {actions}
+
+
+ )
+ }
+
+ return (
+
+ )
+}
diff --git a/src/components/ZapDialog/index.tsx b/src/components/ZapDialog/index.tsx
index 44cc2379..3adafcee 100644
--- a/src/components/ZapDialog/index.tsx
+++ b/src/components/ZapDialog/index.tsx
@@ -26,6 +26,10 @@ import { NostrEvent } from 'nostr-tools'
import { Dispatch, SetStateAction, useEffect, useMemo, useRef, useState } from 'react'
import { useTranslation } from 'react-i18next'
import { toast } from 'sonner'
+import type { PaymentMethodGroup } from '@/lib/merge-payment-methods'
+import PaymentMethodsSection from '@/components/PaymentMethodsSection'
+import { useRecipientAlternativePayments } from '@/hooks/useRecipientAlternativePayments'
+import TipPublicMessagePrompt from './TipPublicMessagePrompt'
import UserAvatar from '../UserAvatar'
import Username from '../Username'
@@ -35,7 +39,8 @@ export default function ZapDialog({
pubkey,
event,
defaultAmount,
- defaultComment
+ defaultComment,
+ alternativePaymentGroups
}: {
open: boolean
setOpen: Dispatch>
@@ -43,10 +48,34 @@ export default function ZapDialog({
event?: NostrEvent
defaultAmount?: number
defaultComment?: string
+ /** Non-Lightning (and non-zap-duplicate) payto targets from kind 10133 / profile. */
+ alternativePaymentGroups?: PaymentMethodGroup[]
}) {
const { t } = useTranslation()
const { isSmallScreen } = useScreenSize()
const drawerContentRef = useRef(null)
+ const { pubkey: selfPubkey } = useNostr()
+ const fetchedAlternativeGroups = useRecipientAlternativePayments(pubkey, open)
+ const effectiveAlternativeGroups = alternativePaymentGroups ?? fetchedAlternativeGroups
+ const [tipNoticeOpen, setTipNoticeOpen] = useState(false)
+ const skipTipNoticeOnCloseRef = useRef(false)
+
+ const maybeOfferTipNoticeOnClose = () => {
+ if (skipTipNoticeOnCloseRef.current) return
+ if (selfPubkey && pubkey === selfPubkey) return
+ setTipNoticeOpen(true)
+ }
+
+ const handleZapDialogOpenChange: Dispatch> = (next) => {
+ const willOpen = typeof next === 'function' ? next(open) : next
+ if (!willOpen) {
+ maybeOfferTipNoticeOnClose()
+ skipTipNoticeOnCloseRef.current = false
+ } else {
+ skipTipNoticeOnCloseRef.current = false
+ }
+ setOpen(next)
+ }
useEffect(() => {
const handleResize = () => {
@@ -75,20 +104,19 @@ export default function ZapDialog({
if (isSmallScreen) {
return (
-
- setOpen(false)} />
+
+ handleZapDialogOpenChange(false)} />
e.preventDefault()}
ref={drawerContentRef}
- className="flex flex-col h-[80vh]"
+ className="flex max-h-[80vh] flex-col overflow-y-auto overscroll-contain"
style={{
maxHeight: 'calc(100vh - env(safe-area-inset-top) - env(safe-area-inset-bottom) - 2rem)',
- height: '80vh',
paddingBottom: '0' // Remove default padding since we handle it in the button container
}}
>
-
+
{t('Zap to')}
@@ -98,19 +126,29 @@ export default function ZapDialog({
{
+ if (withPublicReceipt) skipTipNoticeOnCloseRef.current = true
+ }}
/>
+
)
}
return (
-