import Button from 'react-bootstrap/Button' import InputGroup from 'react-bootstrap/InputGroup' import React, { useState, useRef, useEffect, useCallback } from 'react' import { Form, Input, SubmitButton } from './form' import { useMe } from './me' import UpBolt from '@/svgs/bolt.svg' import { amountSchema, boostSchema } from '@/lib/validate' import { useToast } from './toast' import { useLightning } from './lightning' import { nextTip, defaultTipIncludingRandom } from './upvote' import { ZAP_UNDO_DELAY_MS } from '@/lib/constants' import { usePaidMutation } from './use-paid-mutation' import { ACT_MUTATION } from '@/fragments/paidAction' import { meAnonSats } from '@/lib/apollo' import { BoostItemInput } from './adv-post-form' import { useWallet } from '../wallets' const defaultTips = [100, 1000, 10_000, 100_000] const Tips = ({ setOValue }) => { const tips = [...getCustomTips(), ...defaultTips].sort((a, b) => a - b) return tips.map((num, i) => ) } const getCustomTips = () => JSON.parse(window.localStorage.getItem('custom-tips')) || [] const addCustomTip = (amount) => { if (defaultTips.includes(amount)) return let customTips = Array.from(new Set([amount, ...getCustomTips()])) if (customTips.length > 3) { customTips = customTips.slice(0, 3) } window.localStorage.setItem('custom-tips', JSON.stringify(customTips)) } const setItemMeAnonSats = ({ id, amount }) => { const reactiveVar = meAnonSats[id] const existingAmount = reactiveVar() reactiveVar(existingAmount + amount) // save for next page load const storageKey = `TIP-item:${id}` window.localStorage.setItem(storageKey, existingAmount + amount) } function BoostForm ({ step, onSubmit, children, item, oValue, inputRef, act = 'BOOST' }) { return (
boost
{children} ) } export default function ItemAct ({ onClose, item, act = 'TIP', step, children, abortSignal }) { const inputRef = useRef(null) const { me } = useMe() const wallet = useWallet() const [oValue, setOValue] = useState() useEffect(() => { inputRef.current?.focus() }, [onClose, item.id]) const actor = useAct() const strike = useLightning() const onSubmit = useCallback(async ({ amount }) => { if (abortSignal && zapUndoTrigger({ me, amount })) { onClose?.() try { await abortSignal.pause({ me, amount }) } catch (error) { if (error instanceof ActCanceledError) { return } } } const onPaid = () => { strike() onClose?.() if (!me) setItemMeAnonSats({ id: item.id, amount }) } const closeImmediately = !!wallet || me?.privates?.sats > Number(amount) if (closeImmediately) { onPaid() } const { error } = await actor({ variables: { id: item.id, sats: Number(amount), act }, optimisticResponse: me ? { act: { __typename: 'ItemActPaidAction', result: { id: item.id, sats: Number(amount), act, path: item.path } } } : undefined, // don't close modal immediately because we want the QR modal to stack onPaid: closeImmediately ? undefined : onPaid }) if (error) throw error addCustomTip(Number(amount)) }, [me, actor, !!wallet, act, item.id, onClose, abortSignal, strike]) return act === 'BOOST' ? {children} : (
sats} />
{act === 'DONT_LIKE_THIS' ? 'downzap' : 'zap'}
{children}
) } function modifyActCache (cache, { result, invoice }) { if (!result) return const { id, sats, path, act } = result cache.modify({ id: `Item:${id}`, fields: { sats (existingSats = 0) { if (act === 'TIP') { return existingSats + sats } return existingSats }, meSats: (existingSats = 0) => { if (act === 'TIP') { return existingSats + sats } return existingSats }, meDontLikeSats: (existingSats = 0) => { if (act === 'DONT_LIKE_THIS') { return existingSats + sats } return existingSats }, boost: (existingBoost = 0) => { if (act === 'BOOST') { return existingBoost + sats } return existingBoost } } }) if (act === 'TIP') { // update all ancestors path.split('.').forEach(aId => { if (Number(aId) === Number(id)) return cache.modify({ id: `Item:${aId}`, fields: { commentSats (existingCommentSats = 0) { return existingCommentSats + sats } } }) }) } } export function useAct ({ query = ACT_MUTATION, ...options } = {}) { // because the mutation name we use varies, // we need to extract the result/invoice from the response const getPaidActionResult = data => Object.values(data)[0] const [act] = usePaidMutation(query, { waitFor: inv => inv?.satsReceived > 0, ...options, update: (cache, { data }) => { const response = getPaidActionResult(data) if (!response) return modifyActCache(cache, response) options?.update?.(cache, { data }) }, onPayError: (e, cache, { data }) => { const response = getPaidActionResult(data) if (!response || !response.result) return const { result: { sats } } = response const negate = { ...response, result: { ...response.result, sats: -1 * sats } } modifyActCache(cache, negate) options?.onPayError?.(e, cache, { data }) }, onPaid: (cache, { data }) => { const response = getPaidActionResult(data) if (!response) return options?.onPaid?.(cache, { data }) } }) return act } export function useZap () { const wallet = useWallet() const act = useAct() const strike = useLightning() const toaster = useToast() return useCallback(async ({ item, me, abortSignal }) => { const meSats = (item?.meSats || 0) // add current sats to next tip since idempotent zaps use desired total zap not difference const sats = nextTip(meSats, { ...me?.privates }) const variables = { id: item.id, sats, act: 'TIP' } const optimisticResponse = { act: { __typename: 'ItemActPaidAction', result: { path: item.path, ...variables } } } try { await abortSignal.pause({ me, amount: sats }) strike() // batch zaps if wallet is enabled or using fee credits so they can be executed serially in a single request const { error } = await act({ variables, optimisticResponse, context: { batch: !!wallet || me?.privates?.sats > sats } }) if (error) throw error } catch (error) { if (error instanceof ActCanceledError) { return } const reason = error?.message || error?.toString?.() toaster.danger(reason) } }, [act, toaster, strike, !!wallet]) } export class ActCanceledError extends Error { constructor () { super('act canceled') this.name = 'ActCanceledError' } } export class ZapUndoController extends AbortController { constructor ({ onStart = () => {}, onDone = () => {} }) { super() this.signal.start = onStart this.signal.done = onDone this.signal.pause = async ({ me, amount }) => { if (zapUndoTrigger({ me, amount })) { await zapUndo(this.signal, amount) } } } } const zapUndoTrigger = ({ me, amount }) => { if (!me) return false const enabled = me.privates.zapUndos !== null return enabled ? amount >= me.privates.zapUndos : false } const zapUndo = async (signal, amount) => { return await new Promise((resolve, reject) => { signal.start(amount) const abortHandler = () => { reject(new ActCanceledError()) signal.done() signal.removeEventListener('abort', abortHandler) } signal.addEventListener('abort', abortHandler) setTimeout(() => { resolve() signal.done() signal.removeEventListener('abort', abortHandler) }, ZAP_UNDO_DELAY_MS) }) }