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 { datePivot, timeSince } from '@/lib/time'
import { USER_ID, JIT_INVOICE_TIMEOUT_MS } from '@/lib/constants' import { USER_ID, JIT_INVOICE_TIMEOUT_MS } from '@/lib/constants'
import { HAS_NOTIFICATIONS } from '@/fragments/notifications' import { HAS_NOTIFICATIONS } from '@/fragments/notifications'
import Item from './item' import Item, { ItemSkeleton } from './item'
import { RootProvider } from './root' import { RootProvider } from './root'
import Comment from './comment' 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 className='text-muted ms-1 fw-normal' suppressHydrationWarning>{timeSince(new Date(n.sortTime))}</small>
</small> </small>
{!n.item {!n.item
? null ? <ItemSkeleton />
: n.item.title : n.item.title
? <Item item={n.item} /> ? <Item item={n.item} />
: ( : (

View File

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

View File

@ -6,10 +6,12 @@ import Navbar from 'react-bootstrap/Navbar'
import { numWithUnits } from '@/lib/format' import { numWithUnits } from '@/lib/format'
import { defaultCommentSort } from '@/lib/item' import { defaultCommentSort } from '@/lib/item'
import { useRouter } from 'next/router' import { useRouter } from 'next/router'
import { ItemContextProvider, useItemContext } from './item'
export function CommentsHeader ({ handleSort, pinned, bio, parentCreatedAt, commentSats }) { export function CommentsHeader ({ handleSort, pinned, bio, parentCreatedAt, commentSats }) {
const router = useRouter() const router = useRouter()
const sort = router.query.sort || defaultCommentSort(pinned, bio, parentCreatedAt) const sort = router.query.sort || defaultCommentSort(pinned, bio, parentCreatedAt)
const { pendingCommentSats } = useItemContext()
const getHandleClick = sort => { const getHandleClick = sort => {
return () => { return () => {
@ -24,7 +26,7 @@ export function CommentsHeader ({ handleSort, pinned, bio, parentCreatedAt, comm
activeKey={sort} activeKey={sort}
> >
<Nav.Item className='text-muted'> <Nav.Item className='text-muted'>
{numWithUnits(commentSats)} {numWithUnits(commentSats + pendingCommentSats)}
</Nav.Item> </Nav.Item>
<div className='ms-auto d-flex'> <div className='ms-auto d-flex'>
<Nav.Item> <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) const pins = comments?.filter(({ position }) => !!position).sort((a, b) => a.position - b.position)
return ( return (
<> <ItemContextProvider>
{comments?.length > 0 {comments?.length > 0
? <CommentsHeader ? <CommentsHeader
commentSats={commentSats} parentCreatedAt={parentCreatedAt} commentSats={commentSats} parentCreatedAt={parentCreatedAt}
@ -91,7 +93,7 @@ export default function Comments ({ parentId, pinned, bio, parentCreatedAt, comm
{comments.filter(({ position }) => !position).map(item => ( {comments.filter(({ position }) => !position).map(item => (
<Comment depth={1} key={item.id} item={item} {...props} /> <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 Info from './info'
import { InvoiceCanceledError, usePayment } from './payment' import { InvoiceCanceledError, usePayment } from './payment'
import { useMe } from './me' import { useMe } from './me'
import { optimisticUpdate } from '@/lib/apollo'
import { useClientNotifications } from './client-notifications' import { useClientNotifications } from './client-notifications'
import { ActCanceledError } from './item-act' import { ActCanceledError } from './item-act'
@ -804,7 +803,7 @@ const StorageKeyPrefixContext = createContext()
export function Form ({ export function Form ({
initial, schema, onSubmit, children, initialError, validateImmediately, initial, schema, onSubmit, children, initialError, validateImmediately,
storageKeyPrefix, validateOnChange = true, prepaid, requireSession, innerRef, storageKeyPrefix, validateOnChange = true, prepaid, requireSession, innerRef,
optimisticUpdate: optimisticUpdateArgs, clientNotification, signal, ...props optimisticUpdate, clientNotification, signal, ...props
}) { }) {
const toaster = useToast() const toaster = useToast()
const initialErrorToasted = useRef(false) const initialErrorToasted = useRef(false)
@ -844,9 +843,7 @@ export function Form ({
throw new SessionRequiredError() throw new SessionRequiredError()
} }
if (optimisticUpdateArgs) { revert = optimisticUpdate?.(variables)
revert = optimisticUpdate(optimisticUpdateArgs(variables))
}
await signal?.pause({ me, amount }) await signal?.pause({ me, amount })
@ -865,8 +862,6 @@ export function Form ({
clearLocalStorage(values) clearLocalStorage(values)
} }
} catch (err) { } catch (err) {
revert?.()
if (err instanceof InvoiceCanceledError || err instanceof ActCanceledError) { if (err instanceof InvoiceCanceledError || err instanceof ActCanceledError) {
return return
} }
@ -880,6 +875,7 @@ export function Form ({
cancel?.() cancel?.()
} finally { } finally {
revert?.()
// if we reach this line, the submit either failed or was successful so we can remove the pending notification. // 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 // 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. // stored in localStorage to handle this case.

View File

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

View File

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

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 { createContext, useCallback, useContext, useMemo, useRef, useState } from 'react'
import { USER_ID, UNKNOWN_LINK_REL } from '@/lib/constants' import { USER_ID, UNKNOWN_LINK_REL } 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'
@ -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 }) { export default function Item ({ item, rank, belowTitle, right, full, children, siblingComments, onQuoteReply, pinnable }) {
const titleRef = useRef() const titleRef = useRef()
const router = useRouter() 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) const image = item.url && item.url.startsWith(process.env.NEXT_PUBLIC_IMGPROXY_URL)
return ( return (
<> <ItemContextProvider>
{rank {rank
? ( ? (
<div className={styles.rank}> <div className={styles.rank}>
@ -110,7 +151,7 @@ export default function Item ({ item, rank, belowTitle, right, full, children, s
{children} {children}
</div> </div>
)} )}
</> </ItemContextProvider>
) )
} }

View File

@ -36,13 +36,8 @@ import { ITEM_FULL } from '@/fragments/items'
function Notification ({ n, fresh }) { function Notification ({ n, fresh }) {
const type = n.__typename 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 ( return (
<NotificationLayout nid={nid(n)} {...defaultOnClick(itemN)} fresh={fresh}> <NotificationLayout nid={nid(n)} {...defaultOnClick(n)} fresh={fresh}>
{ {
(type === 'Earn' && <EarnNotification n={n} />) || (type === 'Earn' && <EarnNotification n={n} />) ||
(type === 'Revenue' && <RevenueNotification n={n} />) || (type === 'Revenue' && <RevenueNotification n={n} />) ||
@ -62,15 +57,26 @@ function Notification ({ n, fresh }) {
(type === 'TerritoryPost' && <TerritoryPost n={n} />) || (type === 'TerritoryPost' && <TerritoryPost n={n} />) ||
(type === 'TerritoryTransfer' && <TerritoryTransfer n={n} />) || (type === 'TerritoryTransfer' && <TerritoryTransfer n={n} />) ||
(type === 'Reminder' && <Reminder n={n} />) || (type === 'Reminder' && <Reminder n={n} />) ||
([ClientTypes.Zap.ERROR, ClientTypes.Zap.PENDING].includes(type) && <ClientZap n={itemN} />) || <ClientNotification n={n} />
([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} />)
} }
</NotificationLayout> </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 }) { function NotificationLayout ({ children, nid, href, as, fresh }) {
const router = useRouter() const router = useRouter()
if (!href) return <div className={fresh ? styles.fresh : ''}>{children}</div> 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 { InvoiceCanceledError, usePayment } from './payment'
import { useToast } from './toast' import { useToast } from './toast'
import { Types as ClientNotification, useClientNotifications } from './client-notifications' import { Types as ClientNotification, useClientNotifications } from './client-notifications'
import { useItemContext } from './item'
export default function Poll ({ item }) { export default function Poll ({ item }) {
const me = useMe() const me = useMe()
@ -20,6 +21,7 @@ export default function Poll ({ item }) {
const [pollVote] = useMutation(POLL_VOTE_MUTATION) const [pollVote] = useMutation(POLL_VOTE_MUTATION)
const toaster = useToast() const toaster = useToast()
const { notify, unnotify } = useClientNotifications() const { notify, unnotify } = useClientNotifications()
const { pendingVote, setPendingVote } = useItemContext()
const update = (cache, { data: { pollVote } }) => { const update = (cache, { data: { pollVote } }) => {
cache.modify({ cache.modify({
@ -56,6 +58,8 @@ export default function Poll ({ item }) {
const optimisticResponse = { pollVote: v.id } const optimisticResponse = { pollVote: v.id }
let cancel, nid let cancel, nid
try { try {
setPendingVote(v.id)
if (me) { if (me) {
nid = notify(ClientNotification.PollVote.PENDING, notifyProps) nid = notify(ClientNotification.PollVote.PENDING, notifyProps)
} }
@ -78,6 +82,7 @@ export default function Poll ({ item }) {
cancel?.() cancel?.()
} finally { } finally {
setPendingVote(undefined)
if (nid) unnotify(nid) if (nid) unnotify(nid)
} }
} }
@ -92,7 +97,8 @@ export default function Poll ({ item }) {
const hasExpiration = !!item.pollExpiresAt const hasExpiration = !!item.pollExpiresAt
const timeRemaining = timeLeft(new Date(item.pollExpiresAt)) const timeRemaining = timeLeft(new Date(item.pollExpiresAt))
const mine = item.user.id === me?.id 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 ( return (
<div className={styles.pollBox}> <div className={styles.pollBox}>
{item.poll.options.map(v => {item.poll.options.map(v =>
@ -100,10 +106,12 @@ export default function Poll ({ item }) {
? <PollButton key={v.id} v={v} /> ? <PollButton key={v.id} v={v} />
: <PollResult : <PollResult
key={v.id} v={v} 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'> <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'}`} {hasExpiration && ` \\ ${timeRemaining ? `${timeRemaining} left` : 'poll ended'}`}
</div> </div>
</div> </div>

View File

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