fix rapid voting ui race condition (#213)
This commit is contained in:
parent
0c67808e44
commit
f33534dd36
@ -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>}
|
||||||
|
@ -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 &&
|
||||||
|
@ -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}
|
||||||
|
@ -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)`
|
||||||
|
Loading…
x
Reference in New Issue
Block a user