fix rapid voting ui race condition (#213)

This commit is contained in:
keyan 2023-07-09 11:15:46 -05:00
parent 0c67808e44
commit f33534dd36
4 changed files with 58 additions and 40 deletions

View File

@ -97,6 +97,7 @@ export default function Comment ({
const ref = useRef(null) const ref = useRef(null)
const router = useRouter() const router = useRouter()
const root = useRoot() const root = useRoot()
const [pendingSats, setPendingSats] = useState(0)
useEffect(() => { useEffect(() => {
setCollapse(localStorage.getItem(`commentCollapse:${item.id}`) || collapse) setCollapse(localStorage.getItem(`commentCollapse:${item.id}`) || collapse)
@ -124,11 +125,12 @@ export default function Comment ({
<div className={`${itemStyles.item} ${styles.item}`}> <div className={`${itemStyles.item} ${styles.item}`}>
{item.meDontLike {item.meDontLike
? <Flag width={24} height={24} className={`${styles.dontLike}`} /> ? <Flag width={24} height={24} className={`${styles.dontLike}`} />
: <UpVote item={item} className={styles.upvote} />} : <UpVote item={item} className={styles.upvote} pendingSats={pendingSats} setPendingSats={setPendingSats} />}
<div className={`${itemStyles.hunk} ${styles.hunk}`}> <div className={`${itemStyles.hunk} ${styles.hunk}`}>
<div className='d-flex align-items-center'> <div className='d-flex align-items-center'>
<ItemInfo <ItemInfo
item={item} item={item}
pendingSats={pendingSats}
commentsText='replies' commentsText='replies'
className={`${itemStyles.other} ${styles.other}`} className={`${itemStyles.other} ${styles.other}`}
embellishUser={op && <span className='text-boost font-weight-bold ml-1'>OP</span>} embellishUser={op && <span className='text-boost font-weight-bold ml-1'>OP</span>}

View File

@ -16,7 +16,7 @@ import BookmarkDropdownItem from './bookmark'
import SubscribeDropdownItem from './subscribe' import SubscribeDropdownItem from './subscribe'
import { CopyLinkDropdownItem } from './share' import { CopyLinkDropdownItem } from './share'
export default function ItemInfo ({ item, full, commentsText, className, embellishUser, extraInfo, onEdit, editText }) { export default function ItemInfo ({ item, pendingSats, full, commentsText, className, embellishUser, extraInfo, onEdit, editText }) {
const editThreshold = new Date(item.createdAt).getTime() + 10 * 60000 const editThreshold = new Date(item.createdAt).getTime() + 10 * 60000
const me = useMe() const me = useMe()
const router = useRouter() const router = useRouter()
@ -33,7 +33,7 @@ export default function ItemInfo ({ item, full, commentsText, className, embelli
<div className={className || `${styles.other}`}> <div className={className || `${styles.other}`}>
{!item.position && {!item.position &&
<> <>
<span title={`from ${item.upvotes} users ${item.mine ? `\\ ${item.meSats} sats to post` : `(${item.meSats} sats from me)`} `}>{abbrNum(item.sats)} sats</span> <span title={`from ${item.upvotes} users ${item.mine ? `\\ ${item.meSats} sats to post` : `(${item.meSats + pendingSats} sats from me)`} `}>{abbrNum(item.sats + pendingSats)} sats</span>
<span> \ </span> <span> \ </span>
</>} </>}
{item.boost > 0 && {item.boost > 0 &&

View File

@ -1,7 +1,7 @@
import Link from 'next/link' import Link from 'next/link'
import styles from './item.module.css' import styles from './item.module.css'
import UpVote from './upvote' import UpVote from './upvote'
import { useRef } from 'react' import { useRef, useState } from 'react'
import { NOFOLLOW_LIMIT } from '../lib/constants' import { NOFOLLOW_LIMIT } from '../lib/constants'
import Pin from '../svgs/pushpin-fill.svg' import Pin from '../svgs/pushpin-fill.svg'
import reactStringReplace from 'react-string-replace' import reactStringReplace from 'react-string-replace'
@ -20,6 +20,7 @@ export function SearchTitle ({ title }) {
export default function Item ({ item, rank, belowTitle, right, full, children }) { export default function Item ({ item, rank, belowTitle, right, full, children }) {
const titleRef = useRef() const titleRef = useRef()
const [pendingSats, setPendingSats] = useState(0)
return ( return (
<> <>
@ -32,7 +33,7 @@ export default function Item ({ item, rank, belowTitle, right, full, children })
<div className={styles.item}> <div className={styles.item}>
{item.position {item.position
? <Pin width={24} height={24} className={styles.pin} /> ? <Pin width={24} height={24} className={styles.pin} />
: item.meDontLike ? <Flag width={24} height={24} className={`${styles.dontLike}`} /> : <UpVote item={item} className={styles.upvote} />} : item.meDontLike ? <Flag width={24} height={24} className={`${styles.dontLike}`} /> : <UpVote item={item} className={styles.upvote} pendingSats={pendingSats} setPendingSats={setPendingSats} />}
<div className={styles.hunk}> <div className={styles.hunk}>
<div className={`${styles.main} flex-wrap`}> <div className={`${styles.main} flex-wrap`}>
<Link href={`/items/${item.id}`} passHref> <Link href={`/items/${item.id}`} passHref>
@ -58,7 +59,7 @@ export default function Item ({ item, rank, belowTitle, right, full, children })
</a> </a>
</>} </>}
</div> </div>
<ItemInfo full={full} item={item} /> <ItemInfo full={full} item={item} pendingSats={pendingSats} />
{belowTitle} {belowTitle}
</div> </div>
{right} {right}

View File

@ -6,7 +6,7 @@ import ActionTooltip from './action-tooltip'
import ItemAct from './item-act' import ItemAct from './item-act'
import { useMe } from './me' import { useMe } from './me'
import Rainbow from '../lib/rainbow' import Rainbow from '../lib/rainbow'
import { useRef, useState } from 'react' import { useCallback, useEffect, useRef, useState } from 'react'
import LongPressable from 'react-longpressable' import LongPressable from 'react-longpressable'
import { Overlay, Popover } from 'react-bootstrap' import { Overlay, Popover } from 'react-bootstrap'
import { useShowModal } from './modal' import { useShowModal } from './modal'
@ -63,12 +63,13 @@ const TipPopover = ({ target, show, handleClose }) => (
</Overlay> </Overlay>
) )
export default function UpVote ({ item, className }) { export default function UpVote ({ item, className, pendingSats, setPendingSats }) {
const showModal = useShowModal() const showModal = useShowModal()
const router = useRouter() const router = useRouter()
const [voteShow, _setVoteShow] = useState(false) const [voteShow, _setVoteShow] = useState(false)
const [tipShow, _setTipShow] = useState(false) const [tipShow, _setTipShow] = useState(false)
const ref = useRef() const ref = useRef()
const timerRef = useRef(null)
const me = useMe() const me = useMe()
const [setWalkthrough] = useMutation( const [setWalkthrough] = useMutation(
gql` gql`
@ -112,11 +113,12 @@ export default function UpVote ({ item, className }) {
gql` gql`
mutation act($id: ID!, $sats: Int!) { mutation act($id: ID!, $sats: Int!) {
act(id: $id, sats: $sats) { act(id: $id, sats: $sats) {
vote,
sats sats
} }
}`, { }`, {
update (cache, { data: { act: { vote, sats } } }) { update (cache, { data: { act: { sats } } }) {
setPendingSats(0)
cache.modify({ cache.modify({
id: `Item:${item.id}`, id: `Item:${item.id}`,
fields: { fields: {
@ -133,9 +135,6 @@ export default function UpVote ({ item, className }) {
} }
return existingSats + sats return existingSats + sats
},
upvotes (existingUpvotes = 0) {
return existingUpvotes + vote
} }
} }
}) })
@ -156,15 +155,50 @@ export default function UpVote ({ item, className }) {
} }
) )
// prevent updating pendingSats after unmount
useEffect(() => {
return () => { timerRef.current = null }
}, [])
// if we want to use optimistic response, we need to buffer the votes
// because if someone votes in quick succession, responses come back out of order
// so we wait a bit to see if there are more votes coming in
// this effectively performs our own debounced optimistic response
const bufferVotes = useCallback((sats) => {
if (timerRef.current) {
clearTimeout(timerRef.current)
}
timerRef.current = setTimeout(async (pendingSats, sats) => {
try {
await act({
variables: { id: item.id, sats: pendingSats + sats }
})
} catch (error) {
if (error.toString().includes('insufficient funds')) {
showModal(onClose => {
return <FundError onClose={onClose} />
})
return
}
throw new Error({ message: error.toString() })
}
}, 1000, pendingSats, sats)
timerRef.current && setPendingSats(pendingSats + sats)
}, [item, pendingSats, act, setPendingSats, showModal])
const meSats = (item?.meSats || 0) + pendingSats
// what should our next tip be? // what should our next tip be?
let sats = me?.tipDefault || 1 let sats = me?.tipDefault || 1
if (me?.turboTipping && item?.meSats) { if (me?.turboTipping && me) {
let raiseTip = sats let raiseTip = sats
while (item?.meSats >= raiseTip) { while (meSats >= raiseTip) {
raiseTip *= 10 raiseTip *= 10
} }
sats = raiseTip - item.meSats sats = raiseTip - meSats
} }
const overlayText = () => { const overlayText = () => {
@ -173,7 +207,7 @@ export default function UpVote ({ item, className }) {
const disabled = item?.mine || fwd2me || item?.deletedAt const disabled = item?.mine || fwd2me || item?.deletedAt
const color = getColor(item?.meSats) const color = getColor(meSats)
return ( return (
<LightningConsumer> <LightningConsumer>
{({ strike }) => {({ strike }) =>
@ -203,32 +237,13 @@ export default function UpVote ({ item, className }) {
return return
} }
if (item?.meSats) { if (meSats) {
setVoteShow(false) setVoteShow(false)
} }
strike() strike()
try { bufferVotes(sats)
await act({
variables: { id: item.id, sats },
optimisticResponse: {
act: {
id: `Item:${item.id}`,
sats,
vote: 0
}
}
})
} catch (error) {
if (error.toString().includes('insufficient funds')) {
showModal(onClose => {
return <FundError onClose={onClose} />
})
return
}
throw new Error({ message: error.toString() })
}
} }
: async () => await router.push({ : async () => await router.push({
pathname: '/signup', pathname: '/signup',
@ -248,9 +263,9 @@ export default function UpVote ({ item, className }) {
`${styles.upvote} `${styles.upvote}
${className || ''} ${className || ''}
${disabled ? styles.noSelfTips : ''} ${disabled ? styles.noSelfTips : ''}
${item?.meSats ? styles.voted : ''}` ${meSats ? styles.voted : ''}`
} }
style={item?.meSats style={meSats
? { ? {
fill: color, fill: color,
filter: `drop-shadow(0 0 6px ${color}90)` filter: `drop-shadow(0 0 6px ${color}90)`