Browse Source

bug-fixes

imwald
Silberengel 3 weeks ago
parent
commit
9c373a67b6
  1. 48
      src/PageManager.tsx
  2. 7
      src/components/Note/Superchat.tsx
  3. 9
      src/components/Note/Zap.tsx
  4. 8
      src/components/Note/index.tsx
  5. 41
      src/components/TurnIntoSuperchatButton/index.tsx
  6. 34
      src/components/ZapDialog/SuperchatRequestForm.tsx
  7. 9
      src/hooks/usePaymentAttestationStatus.tsx
  8. 2
      src/i18n/locales/en.ts
  9. 1
      src/layouts/SecondaryPageLayout/index.tsx
  10. 33
      src/lib/mobile-swipe-back.test.ts
  11. 94
      src/lib/mobile-swipe-back.ts
  12. 35
      src/lib/superchat.test.ts
  13. 33
      src/lib/superchat.ts

48
src/PageManager.tsx

@ -8,9 +8,8 @@ import { Button } from '@/components/ui/button' @@ -8,9 +8,8 @@ import { Button } from '@/components/ui/button'
import { cn } from '@/lib/utils'
import logger from '@/lib/logger'
import {
MOBILE_SWIPE_BACK_DOMINANCE,
MOBILE_SWIPE_BACK_EDGE_PX,
MOBILE_SWIPE_BACK_MIN_PX,
tryMobileSwipeBackFromGesture,
useMobileSwipeBackOnElement
} from '@/lib/mobile-swipe-back'
import { preventRadixSheetCloseForPortaledOverlay } from '@/lib/sheet-dismiss-guard'
@ -1132,8 +1131,10 @@ export function PageManager({ maxStackSize = 5 }: { maxStackSize?: number }) { @@ -1132,8 +1131,10 @@ export function PageManager({ maxStackSize = 5 }: { maxStackSize?: number }) {
}, [currentPrimaryPage])
const navigationCounterRef = useRef(0)
const goBackRef = useRef<() => void>(() => {})
const popSecondaryPageRef = useRef<() => void>(() => {})
const drawerOpenRef = useRef(drawerOpen)
const [mobilePrimarySwipeRoot, setMobilePrimarySwipeRoot] = useState<HTMLElement | null>(null)
const [mobileSecondarySwipeRoot, setMobileSecondarySwipeRoot] = useState<HTMLElement | null>(null)
useLayoutEffect(() => {
drawerOpenRef.current = drawerOpen
}, [drawerOpen])
@ -2219,6 +2220,16 @@ export function PageManager({ maxStackSize = 5 }: { maxStackSize?: number }) { @@ -2219,6 +2220,16 @@ export function PageManager({ maxStackSize = 5 }: { maxStackSize?: number }) {
hardCloseSecondaryPanel()
}
popSecondaryPageRef.current = popSecondaryPage
const mobileSecondaryPanelOpen =
isSmallScreen && secondaryStack.length > 0 && !primaryNoteView
useMobileSwipeBackOnElement(mobileSecondaryPanelOpen ? mobileSecondarySwipeRoot : null, () =>
popSecondaryPageRef.current()
, {
enabled: mobileSecondaryPanelOpen
})
const mobileSecondaryOpen = isSmallScreen && (drawerOpen || secondaryStack.length > 0)
useEffect(() => {
if (!mobileSecondaryOpen) return
@ -2226,36 +2237,30 @@ export function PageManager({ maxStackSize = 5 }: { maxStackSize?: number }) { @@ -2226,36 +2237,30 @@ export function PageManager({ maxStackSize = 5 }: { maxStackSize?: number }) {
let grab: { x: number; y: number; pointerId: number } | null = null
const onPointerDown = (e: PointerEvent) => {
if (e.button !== 0 || e.clientX > MOBILE_SWIPE_BACK_EDGE_PX) return
if ((e.button !== 0 && e.button !== -1) || e.clientX > MOBILE_SWIPE_BACK_EDGE_PX) return
grab = { x: e.clientX, y: e.clientY, pointerId: e.pointerId }
}
const onPointerUp = (e: PointerEvent) => {
if (!grab || grab.pointerId !== e.pointerId) return
const dx = e.clientX - grab.x
const dy = e.clientY - grab.y
tryMobileSwipeBackFromGesture(grab, e.clientX, e.clientY, e.pointerId, () =>
popSecondaryPageRef.current()
)
grab = null
const ax = Math.abs(dx)
const ay = Math.abs(dy)
if (dx < MOBILE_SWIPE_BACK_MIN_PX || ax < ay * MOBILE_SWIPE_BACK_DOMINANCE) return
if (secondaryStackRef.current.length > 1) {
window.history.back()
} else {
hardCloseSecondaryPanel()
}
}
const onPointerCancel = () => {
grab = null
}
document.addEventListener('pointerdown', onPointerDown, { capture: true })
document.addEventListener('pointerup', onPointerUp, { capture: true })
document.addEventListener('pointercancel', onPointerCancel, { capture: true })
const capture = { capture: true } as const
document.addEventListener('pointerdown', onPointerDown, capture)
document.addEventListener('pointerup', onPointerUp, capture)
document.addEventListener('pointercancel', onPointerCancel, capture)
return () => {
document.removeEventListener('pointerdown', onPointerDown, { capture: true })
document.removeEventListener('pointerup', onPointerUp, { capture: true })
document.removeEventListener('pointercancel', onPointerCancel, { capture: true })
document.removeEventListener('pointerdown', onPointerDown, capture)
document.removeEventListener('pointerup', onPointerUp, capture)
document.removeEventListener('pointercancel', onPointerCancel, capture)
}
}, [mobileSecondaryOpen])
@ -2368,7 +2373,12 @@ export function PageManager({ maxStackSize = 5 }: { maxStackSize?: number }) { @@ -2368,7 +2373,12 @@ export function PageManager({ maxStackSize = 5 }: { maxStackSize?: number }) {
) : (
<>
{secondaryStack.length > 0 ? (
<div
ref={setMobileSecondarySwipeRoot}
className="flex min-h-0 min-w-0 flex-1 flex-col touch-pan-y"
>
<TopSecondaryStackPane item={secondaryStack[secondaryStack.length - 1]!} />
</div>
) : null}
{secondaryStack.length === 0 ? (
<div className="block h-full min-h-0 min-w-0">

7
src/components/Note/Superchat.tsx

@ -152,7 +152,12 @@ export default function Superchat({ @@ -152,7 +152,12 @@ export default function Superchat({
<SuperchatCommentMarkdown event={event} comment={comment} className="mt-2" />
) : null}
{!isProfileWall ? (
<TurnIntoSuperchatButton event={event} prominent={isNotification} className="mt-3" />
<TurnIntoSuperchatButton
event={event}
prominent={isNotification}
attestationRecipientPubkey={recipientPubkey}
className="mt-3"
/>
) : null}
</div>
)

9
src/components/Note/Zap.tsx

@ -75,6 +75,8 @@ export default function Zap({ @@ -75,6 +75,8 @@ export default function Zap({
const { senderPubkey, recipientPubkey, amount, comment } = zapInfo
const attestationRecipientPubkey = actualRecipientPubkey ?? recipientPubkey ?? null
const openZapTarget = (e: MouseEvent<HTMLButtonElement>) => {
e.stopPropagation()
if (isEventZap && zapInfo?.eventId) {
@ -182,7 +184,12 @@ export default function Zap({ @@ -182,7 +184,12 @@ export default function Zap({
<SuperchatCommentMarkdown event={event} comment={comment} className="mt-2" />
) : null}
{!isProfileWall ? (
<TurnIntoSuperchatButton event={event} prominent={isNotification} className="mt-3" />
<TurnIntoSuperchatButton
event={event}
prominent={isNotification}
attestationRecipientPubkey={attestationRecipientPubkey}
className="mt-3"
/>
) : null}
</div>
)

8
src/components/Note/index.tsx

@ -572,12 +572,15 @@ export default function Note({ @@ -572,12 +572,15 @@ export default function Note({
)
} else if (event.kind === ExtendedKind.PUBLIC_MESSAGE) {
content = renderEventContent({ hideMetadata: true })
} else if (event.kind === ExtendedKind.ZAP_REQUEST || event.kind === ExtendedKind.ZAP_RECEIPT) {
} else if (
event.kind === ExtendedKind.ZAP_REQUEST ||
event.kind === ExtendedKind.ZAP_RECEIPT ||
event.kind === kinds.Zap
) {
content = (
<Zap
className="mt-2"
event={displayEvent}
showAttestationAction={showPaymentAttestationAction}
variant={showPaymentAttestationAction ? 'notification' : 'thread'}
/>
)
@ -586,7 +589,6 @@ export default function Note({ @@ -586,7 +589,6 @@ export default function Note({
<Superchat
className="mt-2"
event={displayEvent}
showAttestationAction={showPaymentAttestationAction}
variant={showPaymentAttestationAction ? 'notification' : 'thread'}
/>
)

41
src/components/TurnIntoSuperchatButton/index.tsx

@ -3,10 +3,10 @@ import { createPaymentAttestationDraftEvent } from '@/lib/draft-event' @@ -3,10 +3,10 @@ import { createPaymentAttestationDraftEvent } from '@/lib/draft-event'
import { LoginRequiredError } from '@/lib/nostr-errors'
import { showSimplePublishSuccess } from '@/lib/publishing-feedback'
import {
canUserAttestSuperchatPayment,
getSuperchatAttestationTargetKindValue,
getSuperchatPaymentRecipientPubkey,
isAttestableSuperchatPayment,
isIncomingPaymentNotificationOrZapReceipt
isAttestableSuperchatPayment
} from '@/lib/superchat'
import { cn } from '@/lib/utils'
import { requestProfileWallRefresh } from '@/hooks/useProfileWall'
@ -22,48 +22,59 @@ import { toast } from 'sonner' @@ -22,48 +22,59 @@ import { toast } from 'sonner'
export default function TurnIntoSuperchatButton({
event,
className,
prominent = false
prominent = false,
attestationRecipientPubkey: attestationRecipientPubkeyProp
}: {
event: Event
className?: string
/** Full-width call-to-action styling for note cards. */
/** Full-width call-to-action styling for note cards (notifications feed). */
prominent?: boolean
/** Note author for note zaps; defaults to payment `p` / zap metadata. */
attestationRecipientPubkey?: string | null
}) {
const { pubkey } = useNostr()
const attestationRecipientPubkey =
attestationRecipientPubkeyProp ?? getSuperchatPaymentRecipientPubkey(event)
if (
!isAttestableSuperchatPayment(event) ||
!getSuperchatAttestationTargetKindValue(event) ||
!pubkey ||
!isIncomingPaymentNotificationOrZapReceipt(event, pubkey)
!attestationRecipientPubkey ||
!canUserAttestSuperchatPayment(event, pubkey, attestationRecipientPubkey)
) {
return null
}
return (
<TurnIntoSuperchatButtonInner event={event} className={className} prominent={prominent} />
<TurnIntoSuperchatButtonInner
event={event}
attestationRecipientPubkey={attestationRecipientPubkey}
className={className}
prominent={prominent}
/>
)
}
function TurnIntoSuperchatButtonInner({
event,
attestationRecipientPubkey,
className,
prominent = false
}: {
event: Event
attestationRecipientPubkey: string
className?: string
prominent?: boolean
}) {
const { t } = useTranslation()
const { publish, checkLogin } = useNostr()
const recipientPubkey = getSuperchatPaymentRecipientPubkey(event)
const { attested, checking, markAttested } = usePaymentAttestationStatus(event)
const { attested, checking, markAttested } = usePaymentAttestationStatus(
event,
attestationRecipientPubkey
)
const [publishing, setPublishing] = useState(false)
if (!recipientPubkey) {
return null
}
if (attested) {
return (
<p
@ -85,13 +96,13 @@ function TurnIntoSuperchatButtonInner({ @@ -85,13 +96,13 @@ function TurnIntoSuperchatButtonInner({
try {
const draft = await createPaymentAttestationDraftEvent(event, { addClientTag: true })
const published = await publish(draft, { disableFallbacks: true })
markLocalAttestationTarget(recipientPubkey, event.id)
markLocalAttestationTarget(attestationRecipientPubkey, event.id)
if (published) {
markAttested(published)
} else {
markAttested({ ...draft, id: event.id, pubkey: recipientPubkey, sig: '' } as Event)
markAttested({ ...draft, id: event.id, pubkey: attestationRecipientPubkey, sig: '' } as Event)
}
requestProfileWallRefresh(recipientPubkey)
requestProfileWallRefresh(attestationRecipientPubkey)
showSimplePublishSuccess(t('Superchat attested'))
} catch (error) {
if (error instanceof LoginRequiredError) return

34
src/components/ZapDialog/SuperchatRequestForm.tsx

@ -1,11 +1,13 @@ @@ -1,11 +1,13 @@
import { Button } from '@/components/ui/button'
import { DialogFooter } from '@/components/ui/dialog'
import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
import { Slider } from '@/components/ui/slider'
import { Textarea } from '@/components/ui/textarea'
import { ExtendedKind } from '@/constants'
import { createPaymentNotificationDraftEvent } from '@/lib/draft-event'
import { createFakeEvent } from '@/lib/event'
import { clampZapSats, formatSatsGrouped, parseGroupedIntegerInput } from '@/lib/lightning'
import { parsePaytoTagType } from '@/lib/payto'
import { LoginRequiredError } from '@/lib/nostr-errors'
import { paymentNotificationReferenceTags, type PostPaymentContext } from '@/lib/post-payment-context'
@ -32,10 +34,15 @@ export default function SuperchatRequestForm({ @@ -32,10 +34,15 @@ export default function SuperchatRequestForm({
const { t } = useTranslation()
const { publish, checkLogin, pubkey: selfPubkey } = useNostr()
const [message, setMessage] = useState('')
const [amountSats, setAmountSats] = useState(() =>
paymentContext?.amountMsat ? clampZapSats(Math.floor(paymentContext.amountMsat / 1000)) : 0
)
const [minPow, setMinPow] = useState(0)
const [sending, setSending] = useState(false)
const textareaRef = useRef<HTMLTextAreaElement>(null)
const amountMsat = amountSats > 0 ? clampZapSats(amountSats) * 1000 : undefined
useEffect(() => {
const id = requestAnimationFrame(() => textareaRef.current?.focus())
return () => cancelAnimationFrame(id)
@ -43,8 +50,8 @@ export default function SuperchatRequestForm({ @@ -43,8 +50,8 @@ export default function SuperchatRequestForm({
const previewEvent = useMemo(() => {
const tags: string[][] = [['p', recipientPubkey]]
if (paymentContext?.amountMsat) {
tags.push(['amount', String(paymentContext.amountMsat)])
if (amountMsat) {
tags.push(['amount', String(amountMsat)])
}
if (paymentContext?.payto) {
tags.push(['payto', paymentContext.payto])
@ -56,7 +63,7 @@ export default function SuperchatRequestForm({ @@ -56,7 +63,7 @@ export default function SuperchatRequestForm({
content: message,
tags
})
}, [message, paymentContext, recipientPubkey, selfPubkey])
}, [amountMsat, message, paymentContext, recipientPubkey, selfPubkey])
const handleSend = () => {
const trimmed = message.trim()
@ -65,7 +72,7 @@ export default function SuperchatRequestForm({ @@ -65,7 +72,7 @@ export default function SuperchatRequestForm({
setSending(true)
try {
const draft = await createPaymentNotificationDraftEvent(trimmed, recipientPubkey, {
amountMsat: paymentContext?.amountMsat,
amountMsat,
payto: paymentContext?.payto,
referencedEvent: paymentContext?.referencedEvent,
addClientTag: true
@ -96,6 +103,25 @@ export default function SuperchatRequestForm({ @@ -96,6 +103,25 @@ export default function SuperchatRequestForm({
<SuperchatPaymentMethodLabel paytoType={paytoType} />
</div>
) : null}
<div className="mt-3 grid gap-2">
<Label htmlFor="superchat-amount">{t('Superchat estimated amount (sats)')}</Label>
<div className="flex min-w-0 items-center gap-2">
<Input
id="superchat-amount"
inputMode="numeric"
value={amountSats > 0 ? formatSatsGrouped(amountSats) : ''}
onChange={(e) => setAmountSats(parseGroupedIntegerInput(e.target.value))}
placeholder="0"
disabled={sending}
className="min-w-0 flex-1 tabular-nums"
aria-describedby="superchat-amount-hint"
/>
<span className="shrink-0 text-sm text-muted-foreground">{t('sats')}</span>
</div>
<p id="superchat-amount-hint" className="text-xs text-muted-foreground">
{t('Superchat estimated amount hint')}
</p>
</div>
<Textarea
ref={textareaRef}
value={message}

9
src/hooks/usePaymentAttestationStatus.tsx

@ -49,8 +49,13 @@ function readAttestedFromLocalSources( @@ -49,8 +49,13 @@ function readAttestedFromLocalSources(
}
}
export function usePaymentAttestationStatus(targetEvent: NostrEvent | undefined) {
const recipientPubkey = targetEvent ? getSuperchatPaymentRecipientPubkey(targetEvent) : null
export function usePaymentAttestationStatus(
targetEvent: NostrEvent | undefined,
recipientPubkeyOverride?: string | null
) {
const recipientPubkey = targetEvent
? recipientPubkeyOverride ?? getSuperchatPaymentRecipientPubkey(targetEvent)
: null
const targetId = targetEvent?.id?.toLowerCase()
const filter = useMemo(

2
src/i18n/locales/en.ts

@ -210,6 +210,8 @@ export default { @@ -210,6 +210,8 @@ export default {
"Publish a payment notification (kind 9740). The recipient can attest to receiving your payment so this message may appear as a superchat.",
"Superchat message": "Superchat message",
"Superchat message placeholder": "Thank you for this post!",
"Superchat estimated amount (sats)": "Estimated payment amount (sats)",
"Superchat estimated amount hint": "Optional. Stored on the event as millisats (sats × 1000).",
"Send superchat request": "Send superchat request",
"Superchat request sent": "Superchat request sent",
"Failed to send superchat request": "Failed to send superchat request: {{error}}",

1
src/layouts/SecondaryPageLayout/index.tsx

@ -98,6 +98,7 @@ const SecondaryPageLayout = forwardRef( @@ -98,6 +98,7 @@ const SecondaryPageLayout = forwardRef(
<DeepBrowsingProvider active={currentIndex === index}>
<div
ref={setMobileSwipeRoot}
className="flex min-h-0 min-w-0 flex-1 flex-col touch-pan-y"
style={{
paddingBottom: 'calc(env(safe-area-inset-bottom) + 3rem)'
}}

33
src/lib/mobile-swipe-back.test.ts

@ -0,0 +1,33 @@ @@ -0,0 +1,33 @@
import { describe, expect, it, vi } from 'vitest'
import {
MOBILE_SWIPE_BACK_MIN_PX,
tryMobileSwipeBackFromGesture
} from './mobile-swipe-back'
describe('tryMobileSwipeBackFromGesture', () => {
it('invokes onBack for a rightward edge swipe', () => {
const onBack = vi.fn()
const grab = { x: 12, y: 100, pointerId: 1 }
const handled = tryMobileSwipeBackFromGesture(
grab,
grab.x + MOBILE_SWIPE_BACK_MIN_PX + 8,
grab.y + 4,
1,
onBack
)
expect(handled).toBe(true)
expect(onBack).toHaveBeenCalledTimes(1);
})
it('ignores leftward swipes', () => {
const onBack = vi.fn()
tryMobileSwipeBackFromGesture({ x: 12, y: 100, pointerId: 1 }, 4, 100, 1, onBack)
expect(onBack).not.toHaveBeenCalled()
})
it('ignores mostly vertical swipes', () => {
const onBack = vi.fn()
tryMobileSwipeBackFromGesture({ x: 12, y: 100, pointerId: 1 }, 80, 220, 1, onBack)
expect(onBack).not.toHaveBeenCalled()
})
})

94
src/lib/mobile-swipe-back.ts

@ -5,11 +5,41 @@ export const MOBILE_SWIPE_BACK_EDGE_PX = 28 @@ -5,11 +5,41 @@ export const MOBILE_SWIPE_BACK_EDGE_PX = 28
export const MOBILE_SWIPE_BACK_MIN_PX = 56
export const MOBILE_SWIPE_BACK_DOMINANCE = 1.25
const SWIPE_BACK_DEBOUNCE_MS = 400
let lastSwipeBackAt = 0
export type UseMobileSwipeBackOnElementOptions = {
enabled?: boolean
edgePx?: number
}
type Grab = { x: number; y: number; pointerId: number }
function isPrimaryPointerButton(button: number): boolean {
return button === 0 || button === -1
}
export function tryMobileSwipeBackFromGesture(
grab: Grab | null,
clientX: number,
clientY: number,
pointerId: number,
onBack: () => void
): boolean {
if (!grab || grab.pointerId !== pointerId) return false
const dx = clientX - grab.x
const dy = clientY - grab.y
const ax = Math.abs(dx)
const ay = Math.abs(dy)
if (dx < MOBILE_SWIPE_BACK_MIN_PX || ax < ay * MOBILE_SWIPE_BACK_DOMINANCE) return false
const now = Date.now()
if (now - lastSwipeBackAt < SWIPE_BACK_DEBOUNCE_MS) return true
lastSwipeBackAt = now
onBack()
return true
}
/**
* Detect a rightward swipe from the left edge and invoke `onBack` (close secondary / drawer).
* Radix sheets and SPA history often block the native browser back gesture on mobile.
@ -22,9 +52,10 @@ export function useMobileSwipeBackOnElement( @@ -22,9 +52,10 @@ export function useMobileSwipeBackOnElement(
const { enabled = true, edgePx = MOBILE_SWIPE_BACK_EDGE_PX } = options
const onBackRef = useRef(onBack)
onBackRef.current = onBack
const grabRef = useRef<{ x: number; y: number; pointerId: number } | null>(null)
const grabRef = useRef<Grab | null>(null)
const releaseCapture = (el: HTMLElement, pointerId: number) => {
if (pointerId < 0) return
try {
if (el.hasPointerCapture?.(pointerId)) el.releasePointerCapture(pointerId)
} catch {
@ -32,24 +63,27 @@ export function useMobileSwipeBackOnElement( @@ -32,24 +63,27 @@ export function useMobileSwipeBackOnElement(
}
}
const finishSwipe = useCallback((clientX: number, clientY: number, pointerId: number, el: HTMLElement) => {
const grab = grabRef.current
const finishSwipe = useCallback(
(clientX: number, clientY: number, pointerId: number, el: HTMLElement) => {
const handled = tryMobileSwipeBackFromGesture(
grabRef.current,
clientX,
clientY,
pointerId,
() => onBackRef.current()
)
grabRef.current = null
releaseCapture(el, pointerId)
if (!grab || grab.pointerId !== pointerId) return
const dx = clientX - grab.x
const dy = clientY - grab.y
const ax = Math.abs(dx)
const ay = Math.abs(dy)
if (dx < MOBILE_SWIPE_BACK_MIN_PX || ax < ay * MOBILE_SWIPE_BACK_DOMINANCE) return
onBackRef.current()
}, [])
return handled
},
[]
)
useEffect(() => {
if (!element || !enabled) return
const onPointerDown = (e: PointerEvent) => {
if (e.button !== 0 || e.clientX > edgePx) return
if (!isPrimaryPointerButton(e.button) || e.clientX > edgePx) return
grabRef.current = { x: e.clientX, y: e.clientY, pointerId: e.pointerId }
try {
element.setPointerCapture(e.pointerId)
@ -67,13 +101,37 @@ export function useMobileSwipeBackOnElement( @@ -67,13 +101,37 @@ export function useMobileSwipeBackOnElement(
releaseCapture(element, e.pointerId)
}
element.addEventListener('pointerdown', onPointerDown)
element.addEventListener('pointerup', onPointerUp)
element.addEventListener('pointercancel', onPointerCancel)
const onTouchStart = (e: TouchEvent) => {
if (e.touches.length !== 1) return
const touch = e.touches[0]
if (touch.clientX > edgePx) return
grabRef.current = { x: touch.clientX, y: touch.clientY, pointerId: touch.identifier }
}
const onTouchEnd = (e: TouchEvent) => {
const touch = e.changedTouches[0]
if (!touch) return
finishSwipe(touch.clientX, touch.clientY, touch.identifier, element)
}
const onTouchCancel = () => {
grabRef.current = null
}
const capture = { capture: true } as const
element.addEventListener('pointerdown', onPointerDown, capture)
element.addEventListener('pointerup', onPointerUp, capture)
element.addEventListener('pointercancel', onPointerCancel, capture)
element.addEventListener('touchstart', onTouchStart, { ...capture, passive: true })
element.addEventListener('touchend', onTouchEnd, capture)
element.addEventListener('touchcancel', onTouchCancel, capture)
return () => {
element.removeEventListener('pointerdown', onPointerDown)
element.removeEventListener('pointerup', onPointerUp)
element.removeEventListener('pointercancel', onPointerCancel)
element.removeEventListener('pointerdown', onPointerDown, capture)
element.removeEventListener('pointerup', onPointerUp, capture)
element.removeEventListener('pointercancel', onPointerCancel, capture)
element.removeEventListener('touchstart', onTouchStart, capture)
element.removeEventListener('touchend', onTouchEnd, capture)
element.removeEventListener('touchcancel', onTouchCancel, capture)
}
}, [element, enabled, edgePx, finishSwipe])
}

35
src/lib/superchat.test.ts

@ -6,6 +6,7 @@ import { @@ -6,6 +6,7 @@ import {
getPaymentNotificationInfo,
getSuperchatPaytoType,
getSuperchatReferenceFetchId,
canUserAttestSuperchatPayment,
isProfileWallPaymentNotification,
isProfileWallZapReceipt,
partitionAttestedSuperchats
@ -30,6 +31,40 @@ function fakeEvent(partial: Partial<Event> & Pick<Event, 'kind' | 'tags'>): Even @@ -30,6 +31,40 @@ function fakeEvent(partial: Partial<Event> & Pick<Event, 'kind' | 'tags'>): Even
}
}
describe('canUserAttestSuperchatPayment', () => {
it('accepts payment notification recipient via p tag', () => {
const event = fakeEvent({
id: PAYMENT_ID,
kind: ExtendedKind.PAYMENT_NOTIFICATION,
tags: [['p', RECIPIENT], ['amount', '1000']]
})
expect(canUserAttestSuperchatPayment(event, RECIPIENT)).toBe(true)
})
it('accepts zap receipt recipient via p tag when bolt11 metadata is missing', () => {
const event = fakeEvent({
id: ZAP_ID,
kind: ExtendedKind.ZAP_RECEIPT,
tags: [['p', RECIPIENT], ['e', 'f'.repeat(64)]]
})
expect(canUserAttestSuperchatPayment(event, RECIPIENT)).toBe(true)
})
it('accepts note author override for note zaps', () => {
const noteAuthor = RECIPIENT
const payer = SENDER
const event = fakeEvent({
id: ZAP_ID,
kind: ExtendedKind.ZAP_RECEIPT,
pubkey: payer,
tags: [['p', payer], ['e', 'f'.repeat(64)]]
})
expect(canUserAttestSuperchatPayment(event, noteAuthor, noteAuthor)).toBe(true)
expect(canUserAttestSuperchatPayment(event, payer)).toBe(true)
expect(canUserAttestSuperchatPayment(event, 'c'.repeat(64))).toBe(false)
})
})
describe('buildAttestedPaymentIdSet', () => {
it('collects attested zap and payment notification ids from recipient', () => {
const attestations = [

33
src/lib/superchat.ts

@ -138,11 +138,33 @@ export function getSuperchatPaymentRecipientPubkey(event: Event): string | null @@ -138,11 +138,33 @@ export function getSuperchatPaymentRecipientPubkey(event: Event): string | null
return getPaymentNotificationInfo(event)?.recipientPubkey ?? null
}
if (event.kind === kinds.Zap || event.kind === ExtendedKind.ZAP_RECEIPT) {
return getZapInfoFromEvent(event)?.recipientPubkey ?? null
return getZapInfoFromEvent(event)?.recipientPubkey ?? firstTagValue(event.tags, ['p']) ?? null
}
return null
}
/** True when `userPubkey` may publish a kind 9741 attestation for this payment. */
export function canUserAttestSuperchatPayment(
event: Event,
userPubkey: string,
attestationRecipientPubkey?: string | null
): boolean {
if (!isAttestableSuperchatPayment(event)) return false
const resolved = attestationRecipientPubkey ?? getSuperchatPaymentRecipientPubkey(event)
if (resolved && hexPubkeysEqual(resolved, userPubkey)) return true
const pTag = firstTagValue(event.tags, ['p'])
return Boolean(pTag && hexPubkeysEqual(pTag, userPubkey))
}
/** Incoming payment notification or zap receipt addressed to `userPubkey`. */
export function isIncomingPaymentNotificationOrZapReceipt(
event: Event,
userPubkey: string,
attestationRecipientPubkey?: string | null
): boolean {
return canUserAttestSuperchatPayment(event, userPubkey, attestationRecipientPubkey)
}
/** Target `k` tag value for a kind 9741 attestation pointing at this event. */
export function getSuperchatAttestationTargetKindValue(event: Event): string | null {
if (event.kind === ExtendedKind.PAYMENT_NOTIFICATION) {
@ -158,15 +180,6 @@ export function isAttestableSuperchatPayment(event: Event): boolean { @@ -158,15 +180,6 @@ export function isAttestableSuperchatPayment(event: Event): boolean {
return getSuperchatAttestationTargetKindValue(event) != null
}
/** Incoming payment notification or zap receipt addressed to `userPubkey`. */
export function isIncomingPaymentNotificationOrZapReceipt(
event: Event,
userPubkey: string
): boolean {
const recipient = getSuperchatPaymentRecipientPubkey(event)
return recipient != null && hexPubkeysEqual(recipient, userPubkey)
}
export function isAttestedSuperchat(event: Event, attestedIds: ReadonlySet<string>): boolean {
if (!isSuperchatKind(event.kind)) return false
return attestedIds.has(event.id.toLowerCase())

Loading…
Cancel
Save