Compare commits
2 Commits
35be035850
...
93713b33df
Author | SHA1 | Date | |
---|---|---|---|
|
93713b33df | ||
|
569d0448c2 |
@ -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'
|
||||
|
||||
@ -121,7 +120,7 @@ export function MarkdownInput ({ label, topLevel, groupClassName, onChange, onKe
|
||||
const imageUploadRef = useRef(null)
|
||||
const previousTab = useRef(tab)
|
||||
const { merge, setDisabled: setSubmitDisabled } = useFeeButton()
|
||||
const toaster = useToast()
|
||||
|
||||
const [updateImageFeesInfo] = useLazyQuery(gql`
|
||||
query imageFeesInfo($s3Keys: [Int]!) {
|
||||
imageFeesInfo(s3Keys: $s3Keys) {
|
||||
@ -135,7 +134,6 @@ export function MarkdownInput ({ label, topLevel, groupClassName, onChange, onKe
|
||||
nextFetchPolicy: 'no-cache',
|
||||
onError: (err) => {
|
||||
console.error(err)
|
||||
toaster.danger(`unabled to get image fees: ${err.message || err.toString?.()}`)
|
||||
},
|
||||
onCompleted: ({ imageFeesInfo }) => {
|
||||
merge({
|
||||
@ -805,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)
|
||||
@ -845,9 +843,7 @@ export function Form ({
|
||||
throw new SessionRequiredError()
|
||||
}
|
||||
|
||||
if (optimisticUpdateArgs) {
|
||||
revert = optimisticUpdate(optimisticUpdateArgs(variables))
|
||||
}
|
||||
revert = optimisticUpdate?.(variables)
|
||||
|
||||
await signal?.pause({ me, amount })
|
||||
|
||||
@ -866,8 +862,6 @@ export function Form ({
|
||||
clearLocalStorage(values)
|
||||
}
|
||||
} catch (err) {
|
||||
revert?.()
|
||||
|
||||
if (err instanceof InvoiceCanceledError || err instanceof ActCanceledError) {
|
||||
return
|
||||
}
|
||||
@ -881,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…
x
Reference in New Issue
Block a user