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>
This commit is contained in:
ekzyis 2024-06-12 15:34:24 +02:00 committed by GitHub
parent 569d0448c2
commit 93713b33df
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
12 changed files with 233 additions and 191 deletions

View File

@ -4,7 +4,7 @@ import { createContext, useCallback, useContext, useEffect, useMemo, useState }
import { datePivot, timeSince } from '@/lib/time'
import { USER_ID, JIT_INVOICE_TIMEOUT_MS } from '@/lib/constants'
import { HAS_NOTIFICATIONS } from '@/fragments/notifications'
import Item from './item'
import Item, { ItemSkeleton } from './item'
import { RootProvider } from './root'
import Comment from './comment'
@ -103,7 +103,7 @@ function ClientNotification ({ n, message }) {
<small className='text-muted ms-1 fw-normal' suppressHydrationWarning>{timeSince(new Date(n.sortTime))}</small>
</small>
{!n.item
? null
? <ItemSkeleton />
: n.item.title
? <Item item={n.item} />
: (

View File

@ -25,6 +25,7 @@ import Skull from '@/svgs/death-skull.svg'
import { commentSubTreeRootId } from '@/lib/item'
import Pin from '@/svgs/pushpin-fill.svg'
import LinkToContext from './link-to-context'
import { ItemContextProvider } from './item'
function Parent ({ item, rootText }) {
const root = useRoot()
@ -136,114 +137,116 @@ export default function Comment ({
const bountyPaid = root.bountyPaidTo?.includes(Number(item.id))
return (
<div
ref={ref} className={includeParent ? '' : `${styles.comment} ${collapse === 'yep' ? styles.collapsed : ''}`}
onMouseEnter={() => ref.current.classList.add('outline-new-comment-unset')}
onTouchStart={() => ref.current.classList.add('outline-new-comment-unset')}
>
<div className={`${itemStyles.item} ${styles.item}`}>
{item.outlawed && !me?.privates?.wildWestMode
? <Skull className={styles.dontLike} width={24} height={24} />
: item.meDontLikeSats > item.meSats
? <DownZap width={24} height={24} className={styles.dontLike} item={item} />
: pin ? <Pin width={22} height={22} className={styles.pin} /> : <UpVote item={item} className={styles.upvote} />}
<div className={`${itemStyles.hunk} ${styles.hunk}`}>
<div className='d-flex align-items-center'>
{item.user?.meMute && !includeParent && collapse === 'yep'
? (
<span
className={`${itemStyles.other} ${styles.other} pointer`} onClick={() => {
setCollapse('nope')
window.localStorage.setItem(`commentCollapse:${item.id}`, 'nope')
}}
>reply from someone you muted
</span>)
: <ItemInfo
item={item}
commentsText='replies'
commentTextSingular='reply'
className={`${itemStyles.other} ${styles.other}`}
embellishUser={op && <><span> </span><Badge bg={op === 'fwd' ? 'secondary' : 'boost'} className={`${styles.op} bg-opacity-75`}>{op}</Badge></>}
onQuoteReply={quoteReply}
nested={!includeParent}
extraInfo={
<>
{includeParent && <Parent item={item} rootText={rootText} />}
{bountyPaid &&
<ActionTooltip notForm overlayText={`${numWithUnits(root.bounty)} paid`}>
<BountyIcon className={`${styles.bountyIcon} ${'fill-success vertical-align-middle'}`} height={16} width={16} />
</ActionTooltip>}
</>
<ItemContextProvider>
<div
ref={ref} className={includeParent ? '' : `${styles.comment} ${collapse === 'yep' ? styles.collapsed : ''}`}
onMouseEnter={() => ref.current.classList.add('outline-new-comment-unset')}
onTouchStart={() => ref.current.classList.add('outline-new-comment-unset')}
>
<div className={`${itemStyles.item} ${styles.item}`}>
{item.outlawed && !me?.privates?.wildWestMode
? <Skull className={styles.dontLike} width={24} height={24} />
: item.meDontLikeSats > item.meSats
? <DownZap width={24} height={24} className={styles.dontLike} item={item} />
: pin ? <Pin width={22} height={22} className={styles.pin} /> : <UpVote item={item} className={styles.upvote} />}
<div className={`${itemStyles.hunk} ${styles.hunk}`}>
<div className='d-flex align-items-center'>
{item.user?.meMute && !includeParent && collapse === 'yep'
? (
<span
className={`${itemStyles.other} ${styles.other} pointer`} onClick={() => {
setCollapse('nope')
window.localStorage.setItem(`commentCollapse:${item.id}`, 'nope')
}}
>reply from someone you muted
</span>)
: <ItemInfo
item={item}
commentsText='replies'
commentTextSingular='reply'
className={`${itemStyles.other} ${styles.other}`}
embellishUser={op && <><span> </span><Badge bg={op === 'fwd' ? 'secondary' : 'boost'} className={`${styles.op} bg-opacity-75`}>{op}</Badge></>}
onQuoteReply={quoteReply}
nested={!includeParent}
extraInfo={
<>
{includeParent && <Parent item={item} rootText={rootText} />}
{bountyPaid &&
<ActionTooltip notForm overlayText={`${numWithUnits(root.bounty)} paid`}>
<BountyIcon className={`${styles.bountyIcon} ${'fill-success vertical-align-middle'}`} height={16} width={16} />
</ActionTooltip>}
</>
}
onEdit={e => { setEdit(!edit) }}
editText={edit ? 'cancel' : 'edit'}
/>}
onEdit={e => { setEdit(!edit) }}
editText={edit ? 'cancel' : 'edit'}
/>}
{!includeParent && (collapse === 'yep'
? <Eye
className={styles.collapser} height={10} width={10} onClick={() => {
setCollapse('nope')
window.localStorage.setItem(`commentCollapse:${item.id}`, 'nope')
{!includeParent && (collapse === 'yep'
? <Eye
className={styles.collapser} height={10} width={10} onClick={() => {
setCollapse('nope')
window.localStorage.setItem(`commentCollapse:${item.id}`, 'nope')
}}
/>
: <EyeClose
className={styles.collapser} height={10} width={10} onClick={() => {
setCollapse('yep')
window.localStorage.setItem(`commentCollapse:${item.id}`, 'yep')
}}
/>)}
{topLevel && (
<span className='d-flex ms-auto align-items-center'>
<Share title={item?.title} path={`/items/${item?.id}`} />
</span>
)}
</div>
{edit
? (
<CommentEdit
comment={item}
onSuccess={() => {
setEdit(!edit)
}}
/>
: <EyeClose
className={styles.collapser} height={10} width={10} onClick={() => {
setCollapse('yep')
window.localStorage.setItem(`commentCollapse:${item.id}`, 'yep')
}}
/>)}
{topLevel && (
<span className='d-flex ms-auto align-items-center'>
<Share title={item?.title} path={`/items/${item?.id}`} />
</span>
)}
)
: (
<div className={styles.text} ref={textRef}>
{item.searchText
? <SearchText text={item.searchText} />
: (
<Text itemId={item.id} topLevel={topLevel} rel={item.rel ?? UNKNOWN_LINK_REL} outlawed={item.outlawed} imgproxyUrls={item.imgproxyUrls}>
{item.outlawed && !me?.privates?.wildWestMode
? '*stackers have outlawed this. turn on wild west mode in your [settings](/settings) to see outlawed content.*'
: truncate ? truncateString(item.text) : item.text}
</Text>)}
</div>
)}
</div>
{edit
? (
<CommentEdit
comment={item}
onSuccess={() => {
setEdit(!edit)
}}
/>
)
: (
<div className={styles.text} ref={textRef}>
{item.searchText
? <SearchText text={item.searchText} />
: (
<Text itemId={item.id} topLevel={topLevel} rel={item.rel ?? UNKNOWN_LINK_REL} outlawed={item.outlawed} imgproxyUrls={item.imgproxyUrls}>
{item.outlawed && !me?.privates?.wildWestMode
? '*stackers have outlawed this. turn on wild west mode in your [settings](/settings) to see outlawed content.*'
: truncate ? truncateString(item.text) : item.text}
</Text>)}
</div>
)}
</div>
</div>
{collapse !== 'yep' && (
bottomedOut
? <div className={styles.children}><ReplyOnAnotherPage item={item} /></div>
: (
<div className={styles.children}>
{item.outlawed && !me?.privates?.wildWestMode
? <div className='py-2' />
: !noReply &&
<Reply depth={depth + 1} item={item} replyOpen={replyOpen} onCancelQuote={cancelQuote} onQuoteReply={quoteReply} quote={quote}>
{root.bounty && !bountyPaid && <PayBounty item={item} />}
</Reply>}
{children}
<div className={styles.comments}>
{item.comments && !noComments
? item.comments.map((item) => (
<Comment depth={depth + 1} key={item.id} item={item} />
))
: null}
{collapse !== 'yep' && (
bottomedOut
? <div className={styles.children}><ReplyOnAnotherPage item={item} /></div>
: (
<div className={styles.children}>
{item.outlawed && !me?.privates?.wildWestMode
? <div className='py-2' />
: !noReply &&
<Reply depth={depth + 1} item={item} replyOpen={replyOpen} onCancelQuote={cancelQuote} onQuoteReply={quoteReply} quote={quote}>
{root.bounty && !bountyPaid && <PayBounty item={item} />}
</Reply>}
{children}
<div className={styles.comments}>
{item.comments && !noComments
? item.comments.map((item) => (
<Comment depth={depth + 1} key={item.id} item={item} />
))
: null}
</div>
</div>
</div>
)
)}
</div>
)
)}
</div>
</ItemContextProvider>
)
}

View File

@ -6,10 +6,12 @@ import Navbar from 'react-bootstrap/Navbar'
import { numWithUnits } from '@/lib/format'
import { defaultCommentSort } from '@/lib/item'
import { useRouter } from 'next/router'
import { ItemContextProvider, useItemContext } from './item'
export function CommentsHeader ({ handleSort, pinned, bio, parentCreatedAt, commentSats }) {
const router = useRouter()
const sort = router.query.sort || defaultCommentSort(pinned, bio, parentCreatedAt)
const { pendingCommentSats } = useItemContext()
const getHandleClick = sort => {
return () => {
@ -24,7 +26,7 @@ export function CommentsHeader ({ handleSort, pinned, bio, parentCreatedAt, comm
activeKey={sort}
>
<Nav.Item className='text-muted'>
{numWithUnits(commentSats)}
{numWithUnits(commentSats + pendingCommentSats)}
</Nav.Item>
<div className='ms-auto d-flex'>
<Nav.Item>
@ -66,7 +68,7 @@ export default function Comments ({ parentId, pinned, bio, parentCreatedAt, comm
const pins = comments?.filter(({ position }) => !!position).sort((a, b) => a.position - b.position)
return (
<>
<ItemContextProvider>
{comments?.length > 0
? <CommentsHeader
commentSats={commentSats} parentCreatedAt={parentCreatedAt}
@ -91,7 +93,7 @@ export default function Comments ({ parentId, pinned, bio, parentCreatedAt, comm
{comments.filter(({ position }) => !position).map(item => (
<Comment depth={1} key={item.id} item={item} {...props} />
))}
</>
</ItemContextProvider>
)
}

View File

@ -33,7 +33,6 @@ import EyeClose from '@/svgs/eye-close-line.svg'
import Info from './info'
import { InvoiceCanceledError, usePayment } from './payment'
import { useMe } from './me'
import { optimisticUpdate } from '@/lib/apollo'
import { useClientNotifications } from './client-notifications'
import { ActCanceledError } from './item-act'
@ -804,7 +803,7 @@ const StorageKeyPrefixContext = createContext()
export function Form ({
initial, schema, onSubmit, children, initialError, validateImmediately,
storageKeyPrefix, validateOnChange = true, prepaid, requireSession, innerRef,
optimisticUpdate: optimisticUpdateArgs, clientNotification, signal, ...props
optimisticUpdate, clientNotification, signal, ...props
}) {
const toaster = useToast()
const initialErrorToasted = useRef(false)
@ -844,9 +843,7 @@ export function Form ({
throw new SessionRequiredError()
}
if (optimisticUpdateArgs) {
revert = optimisticUpdate(optimisticUpdateArgs(variables))
}
revert = optimisticUpdate?.(variables)
await signal?.pause({ me, amount })
@ -865,8 +862,6 @@ export function Form ({
clearLocalStorage(values)
}
} catch (err) {
revert?.()
if (err instanceof InvoiceCanceledError || err instanceof ActCanceledError) {
return
}
@ -880,6 +875,7 @@ export function Form ({
cancel?.()
} finally {
revert?.()
// if we reach this line, the submit either failed or was successful so we can remove the pending notification.
// if we don't reach this line, the page was probably reloaded and we can use the pending notification
// stored in localStorage to handle this case.

View File

@ -10,9 +10,9 @@ import { useToast } from './toast'
import { useLightning } from './lightning'
import { nextTip } from './upvote'
import { InvoiceCanceledError, usePayment } from './payment'
// import { optimisticUpdate } from '@/lib/apollo'
import { Types as ClientNotification, ClientNotifyProvider, useClientNotifications } from './client-notifications'
import { ZAP_UNDO_DELAY_MS } from '@/lib/constants'
import { useItemContext } from './item'
const defaultTips = [100, 1000, 10_000, 100_000]
@ -100,11 +100,10 @@ export const actUpdate = ({ me, onUpdate }) => (cache, args) => {
onUpdate?.(cache, args)
}
export default function ItemAct ({ onClose, item, down, children, abortSignal }) {
export default function ItemAct ({ onClose, item, down, children, abortSignal, optimisticUpdate }) {
const inputRef = useRef(null)
const me = useMe()
const [oValue, setOValue] = useState()
const strike = useLightning()
useEffect(() => {
inputRef.current?.focus()
@ -113,8 +112,6 @@ export default function ItemAct ({ onClose, item, down, children, abortSignal })
const act = useAct()
const onSubmit = useCallback(async ({ amount, hash, hmac }) => {
strike()
onClose()
await act({
variables: {
id: item.id,
@ -123,28 +120,11 @@ export default function ItemAct ({ onClose, item, down, children, abortSignal })
hash,
hmac
},
optimisticResponse: {
act: { id: item.id, sats: Number(amount), act: down ? 'DONT_LIKE_THIS' : 'TIP', path: item.path }
},
update: actUpdate({ me })
})
if (!me) setItemMeAnonSats({ id: item.id, amount })
addCustomTip(Number(amount))
}, [me, act, down, item.id, strike])
// XXX avoid manual optimistic updates until
// https://github.com/stackernews/stacker.news/issues/1218 is fixed
// const optimisticUpdate = useCallback(({ amount }) => {
// const variables = {
// id: item.id,
// sats: Number(amount),
// act: down ? 'DONT_LIKE_THIS' : 'TIP'
// }
// const optimisticResponse = { act: { ...variables, path: item.path } }
// strike()
// onClose()
// return { mutation: ACT_MUTATION, variables, optimisticResponse, update: actUpdate({ me }) }
// }, [item.id, down, !!me, strike])
}, [me, act, down, item.id])
return (
<ClientNotifyProvider additionalProps={{ itemId: item.id }}>
@ -155,7 +135,7 @@ export default function ItemAct ({ onClose, item, down, children, abortSignal })
}}
schema={amountSchema}
prepaid
// optimisticUpdate={optimisticUpdate}
optimisticUpdate={({ amount }) => optimisticUpdate(amount, { onClose })}
onSubmit={onSubmit}
clientNotification={ClientNotification.Zap}
signal={abortSignal}
@ -260,9 +240,10 @@ export function useZap () {
const toaster = useToast()
const strike = useLightning()
const payment = usePayment()
const { pendingSats } = useItemContext()
return useCallback(async ({ item, mem, abortSignal }) => {
const meSats = (item?.meSats || 0)
return useCallback(async ({ item, abortSignal, optimisticUpdate }) => {
const meSats = (item?.meSats || 0) + pendingSats
// add current sats to next tip since idempotent zaps use desired total zap not difference
const sats = meSats + nextTip(meSats, { ...me?.privates })
@ -270,14 +251,11 @@ export function useZap () {
const variables = { id: item.id, sats, act: 'TIP' }
const notifyProps = { itemId: item.id, sats: satsDelta }
const optimisticResponse = { act: { path: item.path, ...variables } }
// const optimisticResponse = { act: { path: item.path, ...variables } }
let revert, cancel, nid
try {
// XXX avoid manual optimistic updates until
// https://github.com/stackernews/stacker.news/issues/1218 is fixed
// revert = optimisticUpdate({ mutation: ZAP_MUTATION, variables, optimisticResponse, update })
// strike()
revert = optimisticUpdate?.(satsDelta)
await abortSignal.pause({ me, amount: satsDelta })
@ -288,13 +266,15 @@ export function useZap () {
let hash, hmac;
[{ hash, hmac }, cancel] = await payment.request(satsDelta)
// XXX related to comment above
// await zap({ variables: { ...variables, hash, hmac } })
strike()
await zap({ variables: { ...variables, hash, hmac }, optimisticResponse, update })
await zap({
variables: { ...variables, hash, hmac },
update: (...args) => {
revert?.()
update(...args)
}
})
} catch (error) {
revert?.()
if (error instanceof InvoiceCanceledError || error instanceof ActCanceledError) {
return
}
@ -310,7 +290,7 @@ export function useZap () {
} finally {
if (nid) unnotify(nid)
}
}, [me?.id, strike, payment, notify, unnotify])
}, [me?.id, strike, payment, notify, unnotify, pendingSats])
}
export class ActCanceledError extends Error {

View File

@ -22,6 +22,7 @@ import { DropdownItemUpVote } from './upvote'
import { useRoot } from './root'
import { MuteSubDropdownItem, PinSubDropdownItem } from './territory-header'
import UserPopover from './user-popover'
import { useItemContext } from './item'
export default function ItemInfo ({
item, full, commentsText = 'comments',
@ -36,6 +37,7 @@ export default function ItemInfo ({
const [hasNewComments, setHasNewComments] = useState(false)
const [meTotalSats, setMeTotalSats] = useState(0)
const root = useRoot()
const { pendingSats, pendingCommentSats } = useItemContext()
const sub = item?.sub || root?.sub
useEffect(() => {
@ -45,8 +47,8 @@ export default function ItemInfo ({
}, [item])
useEffect(() => {
if (item) setMeTotalSats((item.meSats || 0) + (item.meAnonSats || 0))
}, [item?.meSats, item?.meAnonSats])
if (item) setMeTotalSats((item.meSats || 0) + (item.meAnonSats || 0) + (pendingSats))
}, [item?.meSats, item?.meAnonSats, pendingSats])
// territory founders can pin any post in their territory
// and OPs can pin any root reply in their post
@ -70,7 +72,7 @@ export default function ItemInfo ({
? ` & ${numWithUnits(item.meDontLikeSats, { abbreviate: false, unitSingular: 'downsat', unitPlural: 'downsats' })}`
: ''} from me)`} `}
>
{numWithUnits(item.sats)}
{numWithUnits(item.sats + pendingSats)}
</span>
<span> \ </span>
</>}
@ -88,7 +90,7 @@ export default function ItemInfo ({
`/items/${item.id}?commentsViewedAt=${viewedAt}`,
`/items/${item.id}`)
}
}} title={numWithUnits(item.commentSats)} className='text-reset position-relative'
}} title={numWithUnits(item.commentSats + pendingCommentSats)} className='text-reset position-relative'
>
{numWithUnits(item.ncomments, {
abbreviate: false,

View File

@ -1,7 +1,7 @@
import Link from 'next/link'
import styles from './item.module.css'
import UpVote from './upvote'
import { useRef } from 'react'
import { createContext, useCallback, useContext, useMemo, useRef, useState } from 'react'
import { USER_ID, UNKNOWN_LINK_REL } from '@/lib/constants'
import Pin from '@/svgs/pushpin-fill.svg'
import reactStringReplace from 'react-string-replace'
@ -45,6 +45,47 @@ export function SearchTitle ({ title }) {
})
}
const ItemContext = createContext({
pendingSats: 0,
setPendingSats: undefined,
pendingVote: undefined,
setPendingVote: undefined
})
export const ItemContextProvider = ({ children }) => {
const parentCtx = useItemContext()
const [pendingSats, innerSetPendingSats] = useState(0)
const [pendingCommentSats, innerSetPendingCommentSats] = useState(0)
const [pendingVote, setPendingVote] = useState()
// cascade comment sats up to root context
const setPendingSats = useCallback((sats) => {
innerSetPendingSats(sats)
parentCtx?.setPendingCommentSats?.(sats)
}, [parentCtx?.setPendingCommentSats])
const setPendingCommentSats = useCallback((sats) => {
innerSetPendingCommentSats(sats)
parentCtx?.setPendingCommentSats?.(sats)
}, [parentCtx?.setPendingCommentSats])
const value = useMemo(() =>
({
pendingSats,
setPendingSats,
pendingCommentSats,
setPendingCommentSats,
pendingVote,
setPendingVote
}),
[pendingSats, setPendingSats, pendingCommentSats, setPendingCommentSats, pendingVote, setPendingVote])
return <ItemContext.Provider value={value}>{children}</ItemContext.Provider>
}
export const useItemContext = () => {
return useContext(ItemContext)
}
export default function Item ({ item, rank, belowTitle, right, full, children, siblingComments, onQuoteReply, pinnable }) {
const titleRef = useRef()
const router = useRouter()
@ -52,7 +93,7 @@ export default function Item ({ item, rank, belowTitle, right, full, children, s
const image = item.url && item.url.startsWith(process.env.NEXT_PUBLIC_IMGPROXY_URL)
return (
<>
<ItemContextProvider>
{rank
? (
<div className={styles.rank}>
@ -110,7 +151,7 @@ export default function Item ({ item, rank, belowTitle, right, full, children, s
{children}
</div>
)}
</>
</ItemContextProvider>
)
}

View File

@ -36,13 +36,8 @@ import { ITEM_FULL } from '@/fragments/items'
function Notification ({ n, fresh }) {
const type = n.__typename
// we need to resolve item id to item to show item for client notifications
const { data } = useQuery(ITEM_FULL, { variables: { id: n.itemId }, skip: !n.itemId })
const item = data?.item
const itemN = { item, ...n }
return (
<NotificationLayout nid={nid(n)} {...defaultOnClick(itemN)} fresh={fresh}>
<NotificationLayout nid={nid(n)} {...defaultOnClick(n)} fresh={fresh}>
{
(type === 'Earn' && <EarnNotification n={n} />) ||
(type === 'Revenue' && <RevenueNotification n={n} />) ||
@ -62,15 +57,26 @@ function Notification ({ n, fresh }) {
(type === 'TerritoryPost' && <TerritoryPost n={n} />) ||
(type === 'TerritoryTransfer' && <TerritoryTransfer n={n} />) ||
(type === 'Reminder' && <Reminder n={n} />) ||
([ClientTypes.Zap.ERROR, ClientTypes.Zap.PENDING].includes(type) && <ClientZap n={itemN} />) ||
([ClientTypes.Reply.ERROR, ClientTypes.Reply.PENDING].includes(type) && <ClientReply n={itemN} />) ||
([ClientTypes.Bounty.ERROR, ClientTypes.Bounty.PENDING].includes(type) && <ClientBounty n={itemN} />) ||
([ClientTypes.PollVote.ERROR, ClientTypes.PollVote.PENDING].includes(type) && <ClientPollVote n={itemN} />)
<ClientNotification n={n} />
}
</NotificationLayout>
)
}
function ClientNotification ({ n }) {
// we need to resolve item id to item to show item for client notifications
const { data } = useQuery(ITEM_FULL, { variables: { id: n.itemId }, skip: !n.itemId })
const item = data?.item
const itemN = { item, ...n }
return (
([ClientTypes.Zap.ERROR, ClientTypes.Zap.PENDING].includes(n.__typename) && <ClientZap n={itemN} />) ||
([ClientTypes.Reply.ERROR, ClientTypes.Reply.PENDING].includes(n.__typename) && <ClientReply n={itemN} />) ||
([ClientTypes.Bounty.ERROR, ClientTypes.Bounty.PENDING].includes(n.__typename) && <ClientBounty n={itemN} />) ||
([ClientTypes.PollVote.ERROR, ClientTypes.PollVote.PENDING].includes(n.__typename) && <ClientPollVote n={itemN} />)
)
}
function NotificationLayout ({ children, nid, href, as, fresh }) {
const router = useRouter()
if (!href) return <div className={fresh ? styles.fresh : ''}>{children}</div>

View File

@ -10,6 +10,7 @@ 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()
@ -20,6 +21,7 @@ export default function Poll ({ item }) {
const [pollVote] = useMutation(POLL_VOTE_MUTATION)
const toaster = useToast()
const { notify, unnotify } = useClientNotifications()
const { pendingVote, setPendingVote } = useItemContext()
const update = (cache, { data: { pollVote } }) => {
cache.modify({
@ -56,6 +58,8 @@ export default function Poll ({ item }) {
const optimisticResponse = { pollVote: v.id }
let cancel, nid
try {
setPendingVote(v.id)
if (me) {
nid = notify(ClientNotification.PollVote.PENDING, notifyProps)
}
@ -78,6 +82,7 @@ export default function Poll ({ item }) {
cancel?.()
} finally {
setPendingVote(undefined)
if (nid) unnotify(nid)
}
}
@ -92,7 +97,8 @@ export default function Poll ({ item }) {
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
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 =>
@ -100,10 +106,12 @@ export default function Poll ({ item }) {
? <PollButton key={v.id} v={v} />
: <PollResult
key={v.id} v={v}
progress={item.poll.count ? fixedDecimal(v.count * 100 / item.poll.count, 1) : 0}
progress={pollCount
? fixedDecimal((v.count + (pendingVote === v.id ? 1 : 0)) * 100 / pollCount, 1)
: 0}
/>)}
<div className='text-muted mt-1'>
{numWithUnits(item.poll.count, { unitSingular: 'vote', unitPlural: 'votes' })}
{numWithUnits(pollCount, { unitSingular: 'vote', unitPlural: 'votes' })}
{hasExpiration && ` \\ ${timeRemaining ? `${timeRemaining} left` : 'poll ended'}`}
</div>
</div>

View File

@ -12,6 +12,8 @@ import Popover from 'react-bootstrap/Popover'
import { useShowModal } from './modal'
import { numWithUnits } from '@/lib/format'
import { Dropdown } from 'react-bootstrap'
import { useLightning } from './lightning'
import { useItemContext } from './item'
const UpvotePopover = ({ target, show, handleClose }) => {
const me = useMe()
@ -96,8 +98,9 @@ export default function UpVote ({ item, className }) {
setWalkthrough(upvotePopover: $upvotePopover, tipPopover: $tipPopover)
}`
)
const [controller, setController] = useState(null)
const strike = useLightning()
const [controller, setController] = useState()
const { pendingSats, setPendingSats } = useItemContext()
const pending = controller?.started && !controller.done
const setVoteShow = useCallback((yes) => {
@ -134,7 +137,7 @@ export default function UpVote ({ item, className }) {
[item?.mine, item?.meForward, item?.deletedAt])
const [meSats, overlayText, color, nextColor] = useMemo(() => {
const meSats = (item?.meSats || item?.meAnonSats || 0)
const meSats = (item?.meSats || item?.meAnonSats || 0) + pendingSats
// what should our next tip be?
const sats = nextTip(meSats, { ...me?.privates })
@ -142,7 +145,16 @@ export default function UpVote ({ item, className }) {
return [
meSats, me ? numWithUnits(sats, { abbreviate: false }) : 'zap it',
getColor(meSats), getColor(meSats + sats)]
}, [item?.meSats, item?.meAnonSats, me?.privates?.tipDefault, me?.privates?.turboDefault])
}, [item?.meSats, item?.meAnonSats, pendingSats, me?.privates?.tipDefault, me?.privates?.turboDefault])
const optimisticUpdate = useCallback((sats, { onClose } = {}) => {
setPendingSats(pendingSats => pendingSats + sats)
strike()
onClose?.()
return () => {
setPendingSats(pendingSats => pendingSats - sats)
}
}, [])
const handleModalClosed = () => {
setHover(false)
@ -167,7 +179,9 @@ export default function UpVote ({ item, className }) {
setController(c)
showModal(onClose =>
<ItemAct onClose={onClose} item={item} abortSignal={c.signal} />, { onClose: handleModalClosed })
<ItemAct
onClose={onClose} item={item} abortSignal={c.signal} optimisticUpdate={optimisticUpdate}
/>, { onClose: handleModalClosed })
}
const handleShortPress = async () => {
@ -193,9 +207,9 @@ export default function UpVote ({ item, className }) {
const c = new ZapUndoController()
setController(c)
await zap({ item, me, abortSignal: c.signal })
await zap({ item, me, abortSignal: c.signal, optimisticUpdate })
} else {
showModal(onClose => <ItemAct onClose={onClose} item={item} />, { onClose: handleModalClosed })
showModal(onClose => <ItemAct onClose={onClose} item={item} optimisticUpdate={optimisticUpdate} />, { onClose: handleModalClosed })
}
}

View File

@ -498,6 +498,10 @@ services:
- 'stacker_lnd'
- '--lnd-port'
- '10009'
- '--max-amount'
- '0'
- '--daily-limit'
- '0'
lnbits:
image: lnbits/lnbits:0.12.5
container_name: lnbits

View File

@ -255,17 +255,3 @@ function getClient (uri) {
}
})
}
export function optimisticUpdate ({ mutation, variables, optimisticResponse, update }) {
const { cache, queryManager } = getApolloClient()
const mutationId = String(queryManager.mutationIdCounter++)
queryManager.markMutationOptimistic(optimisticResponse, {
mutationId,
document: mutation,
variables,
update
})
return () => cache.removeOptimistic(mutationId)
}