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'
import { cn } from '@/lib/utils' import { cn } from '@/lib/utils'
import logger from '@/lib/logger' import logger from '@/lib/logger'
import { import {
MOBILE_SWIPE_BACK_DOMINANCE,
MOBILE_SWIPE_BACK_EDGE_PX, MOBILE_SWIPE_BACK_EDGE_PX,
MOBILE_SWIPE_BACK_MIN_PX, tryMobileSwipeBackFromGesture,
useMobileSwipeBackOnElement useMobileSwipeBackOnElement
} from '@/lib/mobile-swipe-back' } from '@/lib/mobile-swipe-back'
import { preventRadixSheetCloseForPortaledOverlay } from '@/lib/sheet-dismiss-guard' import { preventRadixSheetCloseForPortaledOverlay } from '@/lib/sheet-dismiss-guard'
@ -1132,8 +1131,10 @@ export function PageManager({ maxStackSize = 5 }: { maxStackSize?: number }) {
}, [currentPrimaryPage]) }, [currentPrimaryPage])
const navigationCounterRef = useRef(0) const navigationCounterRef = useRef(0)
const goBackRef = useRef<() => void>(() => {}) const goBackRef = useRef<() => void>(() => {})
const popSecondaryPageRef = useRef<() => void>(() => {})
const drawerOpenRef = useRef(drawerOpen) const drawerOpenRef = useRef(drawerOpen)
const [mobilePrimarySwipeRoot, setMobilePrimarySwipeRoot] = useState<HTMLElement | null>(null) const [mobilePrimarySwipeRoot, setMobilePrimarySwipeRoot] = useState<HTMLElement | null>(null)
const [mobileSecondarySwipeRoot, setMobileSecondarySwipeRoot] = useState<HTMLElement | null>(null)
useLayoutEffect(() => { useLayoutEffect(() => {
drawerOpenRef.current = drawerOpen drawerOpenRef.current = drawerOpen
}, [drawerOpen]) }, [drawerOpen])
@ -2219,6 +2220,16 @@ export function PageManager({ maxStackSize = 5 }: { maxStackSize?: number }) {
hardCloseSecondaryPanel() 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) const mobileSecondaryOpen = isSmallScreen && (drawerOpen || secondaryStack.length > 0)
useEffect(() => { useEffect(() => {
if (!mobileSecondaryOpen) return if (!mobileSecondaryOpen) return
@ -2226,36 +2237,30 @@ export function PageManager({ maxStackSize = 5 }: { maxStackSize?: number }) {
let grab: { x: number; y: number; pointerId: number } | null = null let grab: { x: number; y: number; pointerId: number } | null = null
const onPointerDown = (e: PointerEvent) => { 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 } grab = { x: e.clientX, y: e.clientY, pointerId: e.pointerId }
} }
const onPointerUp = (e: PointerEvent) => { const onPointerUp = (e: PointerEvent) => {
if (!grab || grab.pointerId !== e.pointerId) return if (!grab || grab.pointerId !== e.pointerId) return
const dx = e.clientX - grab.x tryMobileSwipeBackFromGesture(grab, e.clientX, e.clientY, e.pointerId, () =>
const dy = e.clientY - grab.y popSecondaryPageRef.current()
)
grab = null 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 = () => { const onPointerCancel = () => {
grab = null grab = null
} }
document.addEventListener('pointerdown', onPointerDown, { capture: true }) const capture = { capture: true } as const
document.addEventListener('pointerup', onPointerUp, { capture: true }) document.addEventListener('pointerdown', onPointerDown, capture)
document.addEventListener('pointercancel', onPointerCancel, { capture: true }) document.addEventListener('pointerup', onPointerUp, capture)
document.addEventListener('pointercancel', onPointerCancel, capture)
return () => { return () => {
document.removeEventListener('pointerdown', onPointerDown, { capture: true }) document.removeEventListener('pointerdown', onPointerDown, capture)
document.removeEventListener('pointerup', onPointerUp, { capture: true }) document.removeEventListener('pointerup', onPointerUp, capture)
document.removeEventListener('pointercancel', onPointerCancel, { capture: true }) document.removeEventListener('pointercancel', onPointerCancel, capture)
} }
}, [mobileSecondaryOpen]) }, [mobileSecondaryOpen])
@ -2368,7 +2373,12 @@ export function PageManager({ maxStackSize = 5 }: { maxStackSize?: number }) {
) : ( ) : (
<> <>
{secondaryStack.length > 0 ? ( {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]!} /> <TopSecondaryStackPane item={secondaryStack[secondaryStack.length - 1]!} />
</div>
) : null} ) : null}
{secondaryStack.length === 0 ? ( {secondaryStack.length === 0 ? (
<div className="block h-full min-h-0 min-w-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({
<SuperchatCommentMarkdown event={event} comment={comment} className="mt-2" /> <SuperchatCommentMarkdown event={event} comment={comment} className="mt-2" />
) : null} ) : null}
{!isProfileWall ? ( {!isProfileWall ? (
<TurnIntoSuperchatButton event={event} prominent={isNotification} className="mt-3" /> <TurnIntoSuperchatButton
event={event}
prominent={isNotification}
attestationRecipientPubkey={recipientPubkey}
className="mt-3"
/>
) : null} ) : null}
</div> </div>
) )

9
src/components/Note/Zap.tsx

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

8
src/components/Note/index.tsx

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

41
src/components/TurnIntoSuperchatButton/index.tsx

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

34
src/components/ZapDialog/SuperchatRequestForm.tsx

@ -1,11 +1,13 @@
import { Button } from '@/components/ui/button' import { Button } from '@/components/ui/button'
import { DialogFooter } from '@/components/ui/dialog' import { DialogFooter } from '@/components/ui/dialog'
import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label' import { Label } from '@/components/ui/label'
import { Slider } from '@/components/ui/slider' import { Slider } from '@/components/ui/slider'
import { Textarea } from '@/components/ui/textarea' import { Textarea } from '@/components/ui/textarea'
import { ExtendedKind } from '@/constants' import { ExtendedKind } from '@/constants'
import { createPaymentNotificationDraftEvent } from '@/lib/draft-event' import { createPaymentNotificationDraftEvent } from '@/lib/draft-event'
import { createFakeEvent } from '@/lib/event' import { createFakeEvent } from '@/lib/event'
import { clampZapSats, formatSatsGrouped, parseGroupedIntegerInput } from '@/lib/lightning'
import { parsePaytoTagType } from '@/lib/payto' import { parsePaytoTagType } from '@/lib/payto'
import { LoginRequiredError } from '@/lib/nostr-errors' import { LoginRequiredError } from '@/lib/nostr-errors'
import { paymentNotificationReferenceTags, type PostPaymentContext } from '@/lib/post-payment-context' import { paymentNotificationReferenceTags, type PostPaymentContext } from '@/lib/post-payment-context'
@ -32,10 +34,15 @@ export default function SuperchatRequestForm({
const { t } = useTranslation() const { t } = useTranslation()
const { publish, checkLogin, pubkey: selfPubkey } = useNostr() const { publish, checkLogin, pubkey: selfPubkey } = useNostr()
const [message, setMessage] = useState('') const [message, setMessage] = useState('')
const [amountSats, setAmountSats] = useState(() =>
paymentContext?.amountMsat ? clampZapSats(Math.floor(paymentContext.amountMsat / 1000)) : 0
)
const [minPow, setMinPow] = useState(0) const [minPow, setMinPow] = useState(0)
const [sending, setSending] = useState(false) const [sending, setSending] = useState(false)
const textareaRef = useRef<HTMLTextAreaElement>(null) const textareaRef = useRef<HTMLTextAreaElement>(null)
const amountMsat = amountSats > 0 ? clampZapSats(amountSats) * 1000 : undefined
useEffect(() => { useEffect(() => {
const id = requestAnimationFrame(() => textareaRef.current?.focus()) const id = requestAnimationFrame(() => textareaRef.current?.focus())
return () => cancelAnimationFrame(id) return () => cancelAnimationFrame(id)
@ -43,8 +50,8 @@ export default function SuperchatRequestForm({
const previewEvent = useMemo(() => { const previewEvent = useMemo(() => {
const tags: string[][] = [['p', recipientPubkey]] const tags: string[][] = [['p', recipientPubkey]]
if (paymentContext?.amountMsat) { if (amountMsat) {
tags.push(['amount', String(paymentContext.amountMsat)]) tags.push(['amount', String(amountMsat)])
} }
if (paymentContext?.payto) { if (paymentContext?.payto) {
tags.push(['payto', paymentContext.payto]) tags.push(['payto', paymentContext.payto])
@ -56,7 +63,7 @@ export default function SuperchatRequestForm({
content: message, content: message,
tags tags
}) })
}, [message, paymentContext, recipientPubkey, selfPubkey]) }, [amountMsat, message, paymentContext, recipientPubkey, selfPubkey])
const handleSend = () => { const handleSend = () => {
const trimmed = message.trim() const trimmed = message.trim()
@ -65,7 +72,7 @@ export default function SuperchatRequestForm({
setSending(true) setSending(true)
try { try {
const draft = await createPaymentNotificationDraftEvent(trimmed, recipientPubkey, { const draft = await createPaymentNotificationDraftEvent(trimmed, recipientPubkey, {
amountMsat: paymentContext?.amountMsat, amountMsat,
payto: paymentContext?.payto, payto: paymentContext?.payto,
referencedEvent: paymentContext?.referencedEvent, referencedEvent: paymentContext?.referencedEvent,
addClientTag: true addClientTag: true
@ -96,6 +103,25 @@ export default function SuperchatRequestForm({
<SuperchatPaymentMethodLabel paytoType={paytoType} /> <SuperchatPaymentMethodLabel paytoType={paytoType} />
</div> </div>
) : null} ) : 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 <Textarea
ref={textareaRef} ref={textareaRef}
value={message} value={message}

9
src/hooks/usePaymentAttestationStatus.tsx

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

2
src/i18n/locales/en.ts

@ -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.", "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": "Superchat message",
"Superchat message placeholder": "Thank you for this post!", "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", "Send superchat request": "Send superchat request",
"Superchat request sent": "Superchat request sent", "Superchat request sent": "Superchat request sent",
"Failed to send superchat request": "Failed to send superchat request: {{error}}", "Failed to send superchat request": "Failed to send superchat request: {{error}}",

1
src/layouts/SecondaryPageLayout/index.tsx

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

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

@ -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
export const MOBILE_SWIPE_BACK_MIN_PX = 56 export const MOBILE_SWIPE_BACK_MIN_PX = 56
export const MOBILE_SWIPE_BACK_DOMINANCE = 1.25 export const MOBILE_SWIPE_BACK_DOMINANCE = 1.25
const SWIPE_BACK_DEBOUNCE_MS = 400
let lastSwipeBackAt = 0
export type UseMobileSwipeBackOnElementOptions = { export type UseMobileSwipeBackOnElementOptions = {
enabled?: boolean enabled?: boolean
edgePx?: number 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). * 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. * Radix sheets and SPA history often block the native browser back gesture on mobile.
@ -22,9 +52,10 @@ export function useMobileSwipeBackOnElement(
const { enabled = true, edgePx = MOBILE_SWIPE_BACK_EDGE_PX } = options const { enabled = true, edgePx = MOBILE_SWIPE_BACK_EDGE_PX } = options
const onBackRef = useRef(onBack) const onBackRef = useRef(onBack)
onBackRef.current = 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) => { const releaseCapture = (el: HTMLElement, pointerId: number) => {
if (pointerId < 0) return
try { try {
if (el.hasPointerCapture?.(pointerId)) el.releasePointerCapture(pointerId) if (el.hasPointerCapture?.(pointerId)) el.releasePointerCapture(pointerId)
} catch { } catch {
@ -32,24 +63,27 @@ export function useMobileSwipeBackOnElement(
} }
} }
const finishSwipe = useCallback((clientX: number, clientY: number, pointerId: number, el: HTMLElement) => { const finishSwipe = useCallback(
const grab = grabRef.current (clientX: number, clientY: number, pointerId: number, el: HTMLElement) => {
const handled = tryMobileSwipeBackFromGesture(
grabRef.current,
clientX,
clientY,
pointerId,
() => onBackRef.current()
)
grabRef.current = null grabRef.current = null
releaseCapture(el, pointerId) releaseCapture(el, pointerId)
if (!grab || grab.pointerId !== pointerId) return return handled
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()
}, [])
useEffect(() => { useEffect(() => {
if (!element || !enabled) return if (!element || !enabled) return
const onPointerDown = (e: PointerEvent) => { 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 } grabRef.current = { x: e.clientX, y: e.clientY, pointerId: e.pointerId }
try { try {
element.setPointerCapture(e.pointerId) element.setPointerCapture(e.pointerId)
@ -67,13 +101,37 @@ export function useMobileSwipeBackOnElement(
releaseCapture(element, e.pointerId) releaseCapture(element, e.pointerId)
} }
element.addEventListener('pointerdown', onPointerDown) const onTouchStart = (e: TouchEvent) => {
element.addEventListener('pointerup', onPointerUp) if (e.touches.length !== 1) return
element.addEventListener('pointercancel', onPointerCancel) 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 () => { return () => {
element.removeEventListener('pointerdown', onPointerDown) element.removeEventListener('pointerdown', onPointerDown, capture)
element.removeEventListener('pointerup', onPointerUp) element.removeEventListener('pointerup', onPointerUp, capture)
element.removeEventListener('pointercancel', onPointerCancel) element.removeEventListener('pointercancel', onPointerCancel, capture)
element.removeEventListener('touchstart', onTouchStart, capture)
element.removeEventListener('touchend', onTouchEnd, capture)
element.removeEventListener('touchcancel', onTouchCancel, capture)
} }
}, [element, enabled, edgePx, finishSwipe]) }, [element, enabled, edgePx, finishSwipe])
} }

35
src/lib/superchat.test.ts

@ -6,6 +6,7 @@ import {
getPaymentNotificationInfo, getPaymentNotificationInfo,
getSuperchatPaytoType, getSuperchatPaytoType,
getSuperchatReferenceFetchId, getSuperchatReferenceFetchId,
canUserAttestSuperchatPayment,
isProfileWallPaymentNotification, isProfileWallPaymentNotification,
isProfileWallZapReceipt, isProfileWallZapReceipt,
partitionAttestedSuperchats partitionAttestedSuperchats
@ -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', () => { describe('buildAttestedPaymentIdSet', () => {
it('collects attested zap and payment notification ids from recipient', () => { it('collects attested zap and payment notification ids from recipient', () => {
const attestations = [ const attestations = [

33
src/lib/superchat.ts

@ -138,11 +138,33 @@ export function getSuperchatPaymentRecipientPubkey(event: Event): string | null
return getPaymentNotificationInfo(event)?.recipientPubkey ?? null return getPaymentNotificationInfo(event)?.recipientPubkey ?? null
} }
if (event.kind === kinds.Zap || event.kind === ExtendedKind.ZAP_RECEIPT) { 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 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. */ /** Target `k` tag value for a kind 9741 attestation pointing at this event. */
export function getSuperchatAttestationTargetKindValue(event: Event): string | null { export function getSuperchatAttestationTargetKindValue(event: Event): string | null {
if (event.kind === ExtendedKind.PAYMENT_NOTIFICATION) { if (event.kind === ExtendedKind.PAYMENT_NOTIFICATION) {
@ -158,15 +180,6 @@ export function isAttestableSuperchatPayment(event: Event): boolean {
return getSuperchatAttestationTargetKindValue(event) != null 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 { export function isAttestedSuperchat(event: Event, attestedIds: ReadonlySet<string>): boolean {
if (!isSuperchatKind(event.kind)) return false if (!isSuperchatKind(event.kind)) return false
return attestedIds.has(event.id.toLowerCase()) return attestedIds.has(event.id.toLowerCase())

Loading…
Cancel
Save