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 } from '../lib/validate' import { gql, useApolloClient, useMutation } from '@apollo/client' import { payOrLoginError, useInvoiceModal } from './invoice' import { TOAST_DEFAULT_DELAY_MS, useToast, withToastFlow } from './toast' import { useLightning } from './lightning' import { nextTip } from './upvote' const defaultTips = [100, 1000, 10000, 100000] const Tips = ({ setOValue }) => { const tips = [...getCustomTips(), ...defaultTips].sort((a, b) => a - b) return tips.map(num => <Button size='sm' className={`${num > 1 ? 'ms-2' : ''} mb-2`} key={num} onClick={() => { setOValue(num) }} > <UpBolt className='me-1' width={14} height={14} />{num} </Button>) } 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)) } export default function ItemAct ({ onClose, itemId, down, children }) { const inputRef = useRef(null) const me = useMe() const [oValue, setOValue] = useState() const strike = useLightning() const toaster = useToast() const client = useApolloClient() useEffect(() => { inputRef.current?.focus() }, [onClose, itemId]) const [act, actUpdate] = useAct() const onSubmit = useCallback(async ({ amount, hash, hmac }, { update }) => { if (!me) { const storageKey = `TIP-item:${itemId}` const existingAmount = Number(window.localStorage.getItem(storageKey) || '0') window.localStorage.setItem(storageKey, existingAmount + amount) } await act({ variables: { id: itemId, sats: Number(amount), act: down ? 'DONT_LIKE_THIS' : 'TIP', hash, hmac }, update }) // only strike when zap undos not enabled // due to optimistic UX on zap undos if (!me || !me.privates.zapUndos) await strike() addCustomTip(Number(amount)) onClose() }, [me, act, down, itemId, strike]) const onSubmitWithUndos = withToastFlow(toaster)( (values, args) => { const { flowId } = args let canceled const sats = values.amount const insufficientFunds = me?.privates?.sats < sats const invoiceAttached = values.hash && values.hmac if (insufficientFunds && !invoiceAttached) throw new Error('insufficient funds') // payments from external wallets already have their own flow // and we don't want to show undo toasts for them const skipToastFlow = invoiceAttached // update function for optimistic UX const update = () => { const fragment = { id: `Item:${itemId}`, fragment: gql` fragment ItemMeSats on Item { path sats meSats meDontLikeSats } ` } const item = client.cache.readFragment(fragment) const optimisticResponse = { act: { id: itemId, sats, path: item.path, act: down ? 'DONT_LIKE_THIS' : 'TIP' } } actUpdate(client.cache, { data: optimisticResponse }) return () => client.cache.writeFragment({ ...fragment, data: item }) } let undoUpdate return { skipToastFlow, flowId, type: 'zap', pendingMessage: `${down ? 'down' : ''}zapped ${sats} sats`, onPending: async () => { if (skipToastFlow) { return onSubmit(values, { flowId, ...args, update: null }) } await strike() onClose() return new Promise((resolve, reject) => { undoUpdate = update() setTimeout(() => { if (canceled) return resolve() onSubmit(values, { flowId, ...args, update: null }) .then(resolve) .catch((err) => { undoUpdate() reject(err) }) }, TOAST_DEFAULT_DELAY_MS) }) }, onUndo: () => { canceled = true undoUpdate?.() }, hideSuccess: true, hideError: true, timeout: TOAST_DEFAULT_DELAY_MS } } ) return ( <Form initial={{ amount: me?.privates?.tipDefault || defaultTips[0], default: false }} schema={amountSchema} invoiceable onSubmit={me?.privates?.zapUndos ? onSubmitWithUndos : onSubmit} > <Input label='amount' name='amount' type='number' innerRef={inputRef} overrideValue={oValue} required autoFocus append={<InputGroup.Text className='text-monospace'>sats</InputGroup.Text>} /> <div> <Tips setOValue={setOValue} /> </div> {children} <div className='d-flex'> <SubmitButton variant={down ? 'danger' : 'success'} className='ms-auto mt-1 px-4' value='TIP'>{down && 'down'}zap</SubmitButton> </div> </Form> ) } export function useAct ({ onUpdate } = {}) { const me = useMe() const update = useCallback((cache, args) => { const { data: { act: { id, sats, path, act } } } = args cache.modify({ id: `Item:${id}`, fields: { sats (existingSats = 0) { if (act === 'TIP') { return existingSats + sats } return existingSats }, meSats: me ? (existingSats = 0) => { if (act === 'TIP') { return existingSats + sats } return existingSats } : undefined, meDontLikeSats: me ? (existingSats = 0) => { if (act === 'DONT_LIKE_THIS') { return existingSats + sats } return existingSats } : undefined } }) 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 } } }) }) onUpdate && onUpdate(cache, args) } }, [!!me, onUpdate]) const [act] = useMutation( gql` mutation act($id: ID!, $sats: Int!, $act: String, $hash: String, $hmac: String) { act(id: $id, sats: $sats, act: $act, hash: $hash, hmac: $hmac) { id sats path act } }`, { update } ) return [act, update] } export function useZap () { const update = useCallback((cache, args) => { const { data: { act: { id, sats, path } } } = args // determine how much we increased existing sats by by checking the // difference between result sats and meSats // if it's negative, skip the cache as it's an out of order update // if it's positive, add it to sats and commentSats const item = cache.readFragment({ id: `Item:${id}`, fragment: gql` fragment ItemMeSats on Item { meSats } ` }) const satsDelta = sats - item.meSats if (satsDelta > 0) { cache.modify({ id: `Item:${id}`, fields: { sats (existingSats = 0) { return existingSats + satsDelta }, meSats: () => { return sats } } }) // update all ancestors path.split('.').forEach(aId => { if (Number(aId) === Number(id)) return cache.modify({ id: `Item:${aId}`, fields: { commentSats (existingCommentSats = 0) { return existingCommentSats + satsDelta } } }) }) } }, []) const [zap] = useMutation( gql` mutation idempotentAct($id: ID!, $sats: Int!) { act(id: $id, sats: $sats, idempotent: true) { id sats path } }` ) const toaster = useToast() const strike = useLightning() const [act] = useAct() const client = useApolloClient() const invoiceableAct = useInvoiceModal( async ({ hash, hmac }, { variables, ...apolloArgs }) => { await act({ variables: { ...variables, hash, hmac }, ...apolloArgs }) strike() }, [act, strike]) const zapWithUndos = withToastFlow(toaster)( ({ variables, optimisticResponse, update, flowId }) => { const { id: itemId, amount } = variables let canceled // update function for optimistic UX const _update = () => { const fragment = { id: `Item:${itemId}`, fragment: gql` fragment ItemMeSats on Item { sats meSats } ` } const item = client.cache.readFragment(fragment) update(client.cache, { data: optimisticResponse }) // undo function return () => client.cache.writeFragment({ ...fragment, data: item }) } let undoUpdate return { flowId, tag: itemId, type: 'zap', pendingMessage: `zapped ${amount} sats`, onPending: () => new Promise((resolve, reject) => { undoUpdate = _update() setTimeout( () => { if (canceled) return resolve() zap({ variables, optimisticResponse, update: null }).then(resolve).catch((err) => { undoUpdate() reject(err) }) }, TOAST_DEFAULT_DELAY_MS ) }), onUndo: () => { // we can't simply clear the timeout on cancel since // the onPending promise would never settle in that case canceled = true undoUpdate?.() }, hideSuccess: true, hideError: true, timeout: TOAST_DEFAULT_DELAY_MS } } ) return useCallback(async ({ item, me }) => { const meSats = (item?.meSats || 0) // add current sats to next tip since idempotent zaps use desired total zap not difference const sats = meSats + nextTip(meSats, { ...me?.privates }) const variables = { id: item.id, sats, act: 'TIP', amount: sats - meSats } const insufficientFunds = me?.privates.sats < (sats - meSats) const optimisticResponse = { act: { path: item.path, ...variables } } const flowId = (+new Date()).toString(16) const zapArgs = { variables, optimisticResponse: insufficientFunds ? null : optimisticResponse, update, flowId } try { if (insufficientFunds) throw new Error('insufficient funds') strike() if (me?.privates?.zapUndos) { await zapWithUndos(zapArgs) } else { await zap(zapArgs) } } catch (error) { if (payOrLoginError(error)) { // call non-idempotent version const amount = sats - meSats optimisticResponse.act.amount = amount try { await invoiceableAct({ amount }, { variables: { ...variables, sats: amount }, optimisticResponse, update, flowId }) } catch (error) {} return } console.error(error) toaster.danger('zap: ' + error?.message || error?.toString?.()) } }) }