ekzyis 93713b33df
Optimistic updates via pending sats in item context (#1229)
* Use context for pending sats

* Fix sats going negative on zap undo

We already handle undoing pending sats by wrapping the payment+mutation with try/finally.

* Remove unnecessary ItemContextProvider

* Rename to parentCtx

* Fix hierarchy of ItemContextProvider

If a comment was root and it was zapped, the pending sats contributed to the sats shown in <CommentsHeader>.

This was caused by <CommentsHeader> accessing the root item context for all comments, even for the root comment.

So even if the root comment was zapped, the pending sats contributed to the sats for the comment section.

This wasn't the case for posts since their item context was above the context used by <CommentsHeader>.

This was fixed by moving <ItemProviderContext> down into <Comments> and <Item> instead of declaring it at <ItemFull> which wraps the root item and all comments.

* Optimistic update for poll votes

* prevent twice optimistic zap

* enhance client notifications with skeleton and no redudant queries

* enlarge nwc amount limits

* Disable max amount and daily limit in NWC container

---------

Co-authored-by: Keyan <34140557+huumn@users.noreply.github.com>
Co-authored-by: keyan <keyan.kousha+huumn@gmail.com>
2024-06-12 08:34:24 -05:00

130 lines
4.2 KiB
JavaScript

import { gql, useMutation } from '@apollo/client'
import Button from 'react-bootstrap/Button'
import { fixedDecimal, numWithUnits } from '@/lib/format'
import { timeLeft } from '@/lib/time'
import { useMe } from './me'
import styles from './poll.module.css'
import { signIn } from 'next-auth/react'
import ActionTooltip from './action-tooltip'
import { POLL_COST } from '@/lib/constants'
import { InvoiceCanceledError, usePayment } from './payment'
import { useToast } from './toast'
import { Types as ClientNotification, useClientNotifications } from './client-notifications'
import { useItemContext } from './item'
export default function Poll ({ item }) {
const me = useMe()
const POLL_VOTE_MUTATION = gql`
mutation pollVote($id: ID!, $hash: String, $hmac: String) {
pollVote(id: $id, hash: $hash, hmac: $hmac)
}`
const [pollVote] = useMutation(POLL_VOTE_MUTATION)
const toaster = useToast()
const { notify, unnotify } = useClientNotifications()
const { pendingVote, setPendingVote } = useItemContext()
const update = (cache, { data: { pollVote } }) => {
cache.modify({
id: `Item:${item.id}`,
fields: {
poll (existingPoll) {
const poll = { ...existingPoll }
poll.meVoted = true
poll.count += 1
return poll
}
}
})
cache.modify({
id: `PollOption:${pollVote}`,
fields: {
count (existingCount) {
return existingCount + 1
}
}
})
}
const PollButton = ({ v }) => {
const payment = usePayment()
return (
<ActionTooltip placement='left' notForm overlayText='1 sat'>
<Button
variant='outline-info' className={styles.pollButton}
onClick={me
? async () => {
const variables = { id: v.id }
const notifyProps = { itemId: item.id }
const optimisticResponse = { pollVote: v.id }
let cancel, nid
try {
setPendingVote(v.id)
if (me) {
nid = notify(ClientNotification.PollVote.PENDING, notifyProps)
}
let hash, hmac;
[{ hash, hmac }, cancel] = await payment.request(item.pollCost || POLL_COST)
await pollVote({ variables: { hash, hmac, ...variables }, optimisticResponse, update })
} catch (error) {
if (error instanceof InvoiceCanceledError) {
return
}
const reason = error?.message || error?.toString?.()
if (me) {
notify(ClientNotification.PollVote.ERROR, { ...notifyProps, reason })
} else {
toaster.danger('poll vote failed: ' + reason)
}
cancel?.()
} finally {
setPendingVote(undefined)
if (nid) unnotify(nid)
}
}
: signIn}
>
{v.option}
</Button>
</ActionTooltip>
)
}
const hasExpiration = !!item.pollExpiresAt
const timeRemaining = timeLeft(new Date(item.pollExpiresAt))
const mine = item.user.id === me?.id
const showPollButton = (!hasExpiration || timeRemaining) && !item.poll.meVoted && !mine && !pendingVote
const pollCount = item.poll.count + (pendingVote ? 1 : 0)
return (
<div className={styles.pollBox}>
{item.poll.options.map(v =>
showPollButton
? <PollButton key={v.id} v={v} />
: <PollResult
key={v.id} v={v}
progress={pollCount
? fixedDecimal((v.count + (pendingVote === v.id ? 1 : 0)) * 100 / pollCount, 1)
: 0}
/>)}
<div className='text-muted mt-1'>
{numWithUnits(pollCount, { unitSingular: 'vote', unitPlural: 'votes' })}
{hasExpiration && ` \\ ${timeRemaining ? `${timeRemaining} left` : 'poll ended'}`}
</div>
</div>
)
}
function PollResult ({ v, progress }) {
return (
<div className={styles.pollResult}>
<span className={styles.pollOption}>{v.option}</span>
<span className='ms-auto me-2 align-self-center'>{progress}%</span>
<div className={styles.pollProgress} style={{ width: `${progress}%` }} />
</div>
)
}