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