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:
parent
569d0448c2
commit
93713b33df
|
@ -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} />
|
||||
: (
|
||||
|
|
|
@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
|
|
|
@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
|
|
|
@ -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.
|
||||
|
|
|
@ -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 {
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -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 })
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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)
|
||||
}
|
||||
|
|
Loading…
Reference in New Issue