Compare commits

..

No commits in common. "93713b33df9bc3701dc5a692b86a04ff64e8cfb1" and "35be035850d5a014df9375a51f3d209f8f5368c6" have entirely different histories.

12 changed files with 194 additions and 235 deletions

View File

@ -4,7 +4,7 @@ import { createContext, useCallback, useContext, useEffect, useMemo, useState }
import { datePivot, timeSince } from '@/lib/time'
import { USER_ID, JIT_INVOICE_TIMEOUT_MS } from '@/lib/constants'
import { HAS_NOTIFICATIONS } from '@/fragments/notifications'
import Item, { ItemSkeleton } from './item'
import Item 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
? <ItemSkeleton />
? null
: n.item.title
? <Item item={n.item} />
: (

View File

@ -25,7 +25,6 @@ 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()
@ -137,7 +136,6 @@ export default function Comment ({
const bountyPaid = root.bountyPaidTo?.includes(Number(item.id))
return (
<ItemContextProvider>
<div
ref={ref} className={includeParent ? '' : `${styles.comment} ${collapse === 'yep' ? styles.collapsed : ''}`}
onMouseEnter={() => ref.current.classList.add('outline-new-comment-unset')}
@ -246,7 +244,6 @@ export default function Comment ({
)
)}
</div>
</ItemContextProvider>
)
}

View File

@ -6,12 +6,10 @@ 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 () => {
@ -26,7 +24,7 @@ export function CommentsHeader ({ handleSort, pinned, bio, parentCreatedAt, comm
activeKey={sort}
>
<Nav.Item className='text-muted'>
{numWithUnits(commentSats + pendingCommentSats)}
{numWithUnits(commentSats)}
</Nav.Item>
<div className='ms-auto d-flex'>
<Nav.Item>
@ -68,7 +66,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}
@ -93,7 +91,7 @@ export default function Comments ({ parentId, pinned, bio, parentCreatedAt, comm
{comments.filter(({ position }) => !position).map(item => (
<Comment depth={1} key={item.id} item={item} {...props} />
))}
</ItemContextProvider>
</>
)
}

View File

@ -33,6 +33,7 @@ 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'
@ -120,7 +121,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) {
@ -134,6 +135,7 @@ 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({
@ -803,7 +805,7 @@ const StorageKeyPrefixContext = createContext()
export function Form ({
initial, schema, onSubmit, children, initialError, validateImmediately,
storageKeyPrefix, validateOnChange = true, prepaid, requireSession, innerRef,
optimisticUpdate, clientNotification, signal, ...props
optimisticUpdate: optimisticUpdateArgs, clientNotification, signal, ...props
}) {
const toaster = useToast()
const initialErrorToasted = useRef(false)
@ -843,7 +845,9 @@ export function Form ({
throw new SessionRequiredError()
}
revert = optimisticUpdate?.(variables)
if (optimisticUpdateArgs) {
revert = optimisticUpdate(optimisticUpdateArgs(variables))
}
await signal?.pause({ me, amount })
@ -862,6 +866,8 @@ export function Form ({
clearLocalStorage(values)
}
} catch (err) {
revert?.()
if (err instanceof InvoiceCanceledError || err instanceof ActCanceledError) {
return
}
@ -875,7 +881,6 @@ export function Form ({
cancel?.()
} finally {
revert?.()
// if we reach this line, the submit either failed or was successful so we can remove the pending notification.
// if we don't reach this line, the page was probably reloaded and we can use the pending notification
// stored in localStorage to handle this case.

View File

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

View File

@ -22,7 +22,6 @@ 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',
@ -37,7 +36,6 @@ 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(() => {
@ -47,8 +45,8 @@ export default function ItemInfo ({
}, [item])
useEffect(() => {
if (item) setMeTotalSats((item.meSats || 0) + (item.meAnonSats || 0) + (pendingSats))
}, [item?.meSats, item?.meAnonSats, pendingSats])
if (item) setMeTotalSats((item.meSats || 0) + (item.meAnonSats || 0))
}, [item?.meSats, item?.meAnonSats])
// territory founders can pin any post in their territory
// and OPs can pin any root reply in their post
@ -72,7 +70,7 @@ export default function ItemInfo ({
? ` & ${numWithUnits(item.meDontLikeSats, { abbreviate: false, unitSingular: 'downsat', unitPlural: 'downsats' })}`
: ''} from me)`} `}
>
{numWithUnits(item.sats + pendingSats)}
{numWithUnits(item.sats)}
</span>
<span> \ </span>
</>}
@ -90,7 +88,7 @@ export default function ItemInfo ({
`/items/${item.id}?commentsViewedAt=${viewedAt}`,
`/items/${item.id}`)
}
}} title={numWithUnits(item.commentSats + pendingCommentSats)} className='text-reset position-relative'
}} title={numWithUnits(item.commentSats)} className='text-reset position-relative'
>
{numWithUnits(item.ncomments, {
abbreviate: false,

View File

@ -1,7 +1,7 @@
import Link from 'next/link'
import styles from './item.module.css'
import UpVote from './upvote'
import { createContext, useCallback, useContext, useMemo, useRef, useState } from 'react'
import { useRef } 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,47 +45,6 @@ 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()
@ -93,7 +52,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}>
@ -151,7 +110,7 @@ export default function Item ({ item, rank, belowTitle, right, full, children, s
{children}
</div>
)}
</ItemContextProvider>
</>
)
}

View File

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

View File

@ -10,7 +10,6 @@ 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()
@ -21,7 +20,6 @@ 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({
@ -58,8 +56,6 @@ 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)
}
@ -82,7 +78,6 @@ export default function Poll ({ item }) {
cancel?.()
} finally {
setPendingVote(undefined)
if (nid) unnotify(nid)
}
}
@ -97,8 +92,7 @@ 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 && !pendingVote
const pollCount = item.poll.count + (pendingVote ? 1 : 0)
const showPollButton = (!hasExpiration || timeRemaining) && !item.poll.meVoted && !mine
return (
<div className={styles.pollBox}>
{item.poll.options.map(v =>
@ -106,12 +100,10 @@ export default function Poll ({ item }) {
? <PollButton key={v.id} v={v} />
: <PollResult
key={v.id} v={v}
progress={pollCount
? fixedDecimal((v.count + (pendingVote === v.id ? 1 : 0)) * 100 / pollCount, 1)
: 0}
progress={item.poll.count ? fixedDecimal(v.count * 100 / item.poll.count, 1) : 0}
/>)}
<div className='text-muted mt-1'>
{numWithUnits(pollCount, { unitSingular: 'vote', unitPlural: 'votes' })}
{numWithUnits(item.poll.count, { unitSingular: 'vote', unitPlural: 'votes' })}
{hasExpiration && ` \\ ${timeRemaining ? `${timeRemaining} left` : 'poll ended'}`}
</div>
</div>

View File

@ -12,8 +12,6 @@ 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()
@ -98,9 +96,8 @@ export default function UpVote ({ item, className }) {
setWalkthrough(upvotePopover: $upvotePopover, tipPopover: $tipPopover)
}`
)
const strike = useLightning()
const [controller, setController] = useState()
const { pendingSats, setPendingSats } = useItemContext()
const [controller, setController] = useState(null)
const pending = controller?.started && !controller.done
const setVoteShow = useCallback((yes) => {
@ -137,7 +134,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) + pendingSats
const meSats = (item?.meSats || item?.meAnonSats || 0)
// what should our next tip be?
const sats = nextTip(meSats, { ...me?.privates })
@ -145,16 +142,7 @@ export default function UpVote ({ item, className }) {
return [
meSats, me ? numWithUnits(sats, { abbreviate: false }) : 'zap it',
getColor(meSats), getColor(meSats + sats)]
}, [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)
}
}, [])
}, [item?.meSats, item?.meAnonSats, me?.privates?.tipDefault, me?.privates?.turboDefault])
const handleModalClosed = () => {
setHover(false)
@ -179,9 +167,7 @@ export default function UpVote ({ item, className }) {
setController(c)
showModal(onClose =>
<ItemAct
onClose={onClose} item={item} abortSignal={c.signal} optimisticUpdate={optimisticUpdate}
/>, { onClose: handleModalClosed })
<ItemAct onClose={onClose} item={item} abortSignal={c.signal} />, { onClose: handleModalClosed })
}
const handleShortPress = async () => {
@ -207,9 +193,9 @@ export default function UpVote ({ item, className }) {
const c = new ZapUndoController()
setController(c)
await zap({ item, me, abortSignal: c.signal, optimisticUpdate })
await zap({ item, me, abortSignal: c.signal })
} else {
showModal(onClose => <ItemAct onClose={onClose} item={item} optimisticUpdate={optimisticUpdate} />, { onClose: handleModalClosed })
showModal(onClose => <ItemAct onClose={onClose} item={item} />, { onClose: handleModalClosed })
}
}

View File

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

View File

@ -255,3 +255,17 @@ 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)
}