Compare commits

..

50 Commits

Author SHA1 Message Date
k00b 909853521d item referral threshold 2024-12-02 14:45:15 -06:00
ekzyis 01d5177006
Fix edit timer stuck at 00:00 (#1673)
* Fix edit timer stuck at 00:00

* refactor with useCanEdit hook
2024-12-02 08:18:35 -06:00
k00b 8595a2b8b0 stop probable source of 504 toasts 2024-12-01 17:01:13 -06:00
Riccardo Balbo 7f11792111
Custom invite code and note (#1649)
* Custom invite code and note

* disable autocomplete and hide invite code under advanced

* show invite description only to the owner

* note->description and move unser advanced

* Update lib/validate.js

Co-authored-by: ekzyis <ek@stacker.news>

* Update lib/webPush.js

Co-authored-by: ekzyis <ek@stacker.news>

* Update api/typeDefs/invite.js

Co-authored-by: ekzyis <ek@stacker.news>

* Update pages/invites/index.js

Co-authored-by: ekzyis <ek@stacker.news>

* Update pages/invites/index.js

Co-authored-by: ekzyis <ek@stacker.news>

* fix

* apply review suggestions

* change limits

* Update lib/validate.js

Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>

* don't show invite id in push notification

* remove invoice metadata from push notifications

* fix form reset, jsx/dom attrs, accidental uncontrolled prop warnings

* support underscores as we claim

* increase default gift to fit inflation

---------

Co-authored-by: ekzyis <ek@stacker.news>
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
Co-authored-by: Keyan <34140557+huumn@users.noreply.github.com>
Co-authored-by: k00b <k00b@stacker.news>
2024-12-01 16:31:47 -06:00
soxa 76e384b188
fix can't upload from iOS camera/mov files (#1667)
* fix can't upload from iOS camera/mov files

* pivot: iOS automatically transcodes HEVC mov files to H264, custom error if codec not supported
2024-11-30 19:12:13 -06:00
soxa 0b97d2ae94
fix: top boosts shows others' unpaid boosts (#1647) 2024-11-30 19:06:10 -06:00
Lorenzo d88971e8e5
Fix: progress bar shown on back navigation through pathname check (#1633)
* fix: progress bar shown on back navigation through pathname check

* fix progress done race

* use router.pathname instead cause it's already there

---------

Co-authored-by: Keyan <34140557+huumn@users.noreply.github.com>
Co-authored-by: k00b <k00b@stacker.news>
2024-11-30 19:05:26 -06:00
ekzyis 0837460c53
Fix missing authentication check for invite revocation (#1666)
* Fix missing authentication check for invite revocation

* Toast invite revocation error
2024-11-30 12:08:30 -06:00
Felipe Bueno 55d1f2c952
Introduce SubPopover (#1620)
* Introduce SubPopover

* add truncation to sub description popover

---------

Co-authored-by: Keyan <34140557+huumn@users.noreply.github.com>
Co-authored-by: k00b <k00b@stacker.news>
2024-11-29 19:58:18 -06:00
Lorenzo bd5db1b62e
Fix Territories selector updates without hard-reload (#1619)
* fix: territories select fetches new data on reload

* chore: removed unnecessary extra function

* chore: territories refetched on nsfwMode change

* chore: check for undefined me object on refetch hook

---------

Co-authored-by: Keyan <34140557+huumn@users.noreply.github.com>
2024-11-29 18:59:39 -06:00
Lorenzo 7cb2aed9db
feat: recent unpaid bounties selection (#1589)
* feat: recent unpaid bounties selection

* chore: added checkbox on recent header

* chore: active bounties selection made through a checkbox

* chore: renamed function for better clarity

* chore: fixed active bounties only checkbox alignment

* chore: active-only option passed as query param

* chores: variablesFunc refactoring

* chore: removed type mapping function from recent header

* chore: router replace instead of push

* chore: router retrieved by hook instead of argument

* chore: checkbox starts checked based on url's query param

* more idiomatic react + push instead of replace

---------

Co-authored-by: ekzyis <ek@stacker.news>
Co-authored-by: Keyan <34140557+huumn@users.noreply.github.com>
Co-authored-by: k00b <k00b@stacker.news>
2024-11-28 18:47:01 -06:00
ekzyis 8ce2e46519
Merge pull request #1642 from stackernews/sender-fallbacks
Sender fallbacks
2024-11-28 23:00:03 +01:00
k00b 9caeca00df fix cache update option name on qr 2024-11-28 14:23:41 -06:00
ekzyis 799a39b75f
Fix deposit push notifications (#1662) 2024-11-28 11:48:08 -06:00
k00b 404cf188b3 function for merging data after retry 2024-11-28 11:43:40 -06:00
k00b 105f7b07e5 make fallback retry cache updates a special case 2024-11-28 11:22:46 -06:00
k00b cb028d217c fix zap fallback retries in notifications 2024-11-28 10:48:22 -06:00
k00b 6b59e1fa75 usesendwallets 2024-11-27 19:39:20 -06:00
k00b f89286ffdf make logger use full wallet 2024-11-27 19:10:00 -06:00
ekzyis 6c3301a9c4 Fix missing item invoice update on failure 2024-11-28 01:53:45 +01:00
k00b 67f6c170aa readability improvements 2024-11-27 18:39:23 -06:00
ekzyis f9169c645a Fix [undefined] in logs 2024-11-28 01:36:29 +01:00
ekzyis a0d33a23f3 Fix wallet save 2024-11-28 01:16:30 +01:00
k00b b608fb6848 refactor out array of hooks 2024-11-27 17:31:08 -06:00
Keyan 61395a3525
Merge branch 'master' into sender-fallbacks 2024-11-27 17:17:11 -06:00
ekzyis 5e76ee1844
Remove unused useWallet from QR code component (#1660) 2024-11-27 17:16:44 -06:00
ekzyis 7a8db53ecf Only retry same receiver if forward did not fail 2024-11-27 23:00:27 +01:00
ekzyis b301b31a46 Create wrapped invoices on p2p zap retries 2024-11-27 23:00:27 +01:00
ekzyis 9cfc18d655 Return latest state of paid or failed invoice 2024-11-27 23:00:27 +01:00
ekzyis 68513559e4 Remove unnecessary error handling in LNC
We already wrap sendPayment with our own error handling so these errors will become instances of WalletPaymentErrors anyway
2024-11-27 23:00:27 +01:00
ekzyis a4144d4fcc Fix missing item invoice update for optimistic actions 2024-11-27 23:00:27 +01:00
ekzyis 0051c82415 Fix invoice retry even if no payment was attempted 2024-11-27 23:00:27 +01:00
ekzyis 14a92ee5ce Fix filter for wallets that can send
Testing for canSend is not enough since it can also return true if the wallet is not enabled.

This is the case because we want to allow disabling wallets but canSend must still return true in this case if send is configured.

This should probably be changed such that canSend only returns true when the wallet is enabled without preventing disabling of wallets.
2024-11-27 23:00:27 +01:00
ekzyis b1cdd953a0 Return last attempted invoice in canceled state 2024-11-27 23:00:27 +01:00
ekzyis 7e25e29507 Show aggregated wallet errors in QR code 2024-11-27 23:00:27 +01:00
ekzyis 7f5bb33073 Fix payment method returned by retries 2024-11-27 23:00:27 +01:00
ekzyis be4ce5daf9 Allow retries of pessimistic actions 2024-11-27 23:00:27 +01:00
ekzyis 1f2b717da9 Fix last wallet not returning new invoice 2024-11-27 23:00:27 +01:00
ekzyis 00f9e05dd7 Ignore wallet configuration errors in QR code 2024-11-27 23:00:27 +01:00
ekzyis 974e897753 Add comment when err.newInvoice is not set 2024-11-27 23:00:27 +01:00
ekzyis 517d9a9bb9 Abort payment on unexpected errors 2024-11-27 23:00:27 +01:00
ekzyis 413f76c33a Refactor wallet error handling with inheritance 2024-11-27 23:00:27 +01:00
ekzyis ed82d9cfc0 Fix payments if recv-only wallet enabled 2024-11-27 23:00:27 +01:00
ekzyis d99caa43fc Remove unnecessary sort 2024-11-27 23:00:27 +01:00
ekzyis 7036804c67 Fix old invoice passed to QR code 2024-11-27 23:00:27 +01:00
ekzyis 7742257470 Fix TypeError 2024-11-27 23:00:27 +01:00
ekzyis bc0c6d1038 Fix SenderError name 2024-11-27 23:00:27 +01:00
ekzyis 5218a03b3a sender fallbacks 2024-11-27 23:00:27 +01:00
ekzyis 2b47bf527b Fix comment 2024-11-27 23:00:27 +01:00
ekzyis 35159bf7f3 Remove unused useWallet from QR code component 2024-11-27 22:54:00 +01:00
44 changed files with 816 additions and 373 deletions

View File

@ -304,28 +304,43 @@ export async function retryPaidAction (actionType, args, incomingContext) {
throw new Error(`retryPaidAction - must be logged in ${actionType}`)
}
if (!action.paymentMethods.includes(PAID_ACTION_PAYMENT_METHODS.OPTIMISTIC)) {
throw new Error(`retryPaidAction - action does not support optimism ${actionType}`)
}
if (!action.retry) {
throw new Error(`retryPaidAction - action does not support retrying ${actionType}`)
}
if (!failedInvoice) {
throw new Error(`retryPaidAction - missing invoice ${actionType}`)
}
const { msatsRequested, actionId, actionArgs } = failedInvoice
const { msatsRequested, actionId, actionArgs, actionOptimistic } = failedInvoice
const retryContext = {
...incomingContext,
optimistic: true,
optimistic: actionOptimistic,
me: await models.user.findUnique({ where: { id: me.id } }),
cost: BigInt(msatsRequested),
actionId
}
const invoiceArgs = await createSNInvoice(actionType, actionArgs, retryContext)
let invoiceArgs
const invoiceForward = await models.invoiceForward.findUnique({
where: { invoiceId: failedInvoice.id },
include: {
wallet: true,
invoice: true,
withdrawl: true
}
})
// TODO: receiver fallbacks
// use next receiver wallet if forward failed (we currently immediately fallback to SN)
const failedForward = invoiceForward?.withdrawl && invoiceForward.withdrawl.actionState !== 'CONFIRMED'
if (invoiceForward && !failedForward) {
const { userId } = invoiceForward.wallet
const { invoice: bolt11, wrappedInvoice: wrappedBolt11, wallet, maxFee } = await createWrappedInvoice(userId, {
msats: failedInvoice.msatsRequested,
feePercent: await action.getSybilFeePercent?.(actionArgs, retryContext),
description: await action.describe?.(actionArgs, retryContext),
expiry: INVOICE_EXPIRE_SECS
}, retryContext)
invoiceArgs = { bolt11, wrappedBolt11, wallet, maxFee }
} else {
invoiceArgs = await createSNInvoice(actionType, actionArgs, retryContext)
}
return await models.$transaction(async tx => {
const context = { ...retryContext, tx, invoiceArgs }
@ -345,9 +360,9 @@ export async function retryPaidAction (actionType, args, incomingContext) {
const invoice = await createDbInvoice(actionType, actionArgs, context)
return {
result: await action.retry({ invoiceId: failedInvoice.id, newInvoiceId: invoice.id }, context),
result: await action.retry?.({ invoiceId: failedInvoice.id, newInvoiceId: invoice.id }, context),
invoice,
paymentMethod: 'OPTIMISTIC'
paymentMethod: actionOptimistic ? 'OPTIMISTIC' : 'PESSIMISTIC'
}
}, { isolationLevel: Prisma.TransactionIsolationLevel.ReadCommitted })
}

View File

@ -1,7 +1,8 @@
import { inviteSchema, validateSchema } from '@/lib/validate'
import { msatsToSats } from '@/lib/format'
import assertApiKeyNotPermitted from './apiKey'
import { GqlAuthenticationError } from '@/lib/error'
import { GqlAuthenticationError, GqlInputError } from '@/lib/error'
import { Prisma } from '@prisma/client'
export default {
Query: {
@ -9,7 +10,6 @@ export default {
if (!me) {
throw new GqlAuthenticationError()
}
return await models.invite.findMany({
where: {
userId: me.id
@ -29,27 +29,48 @@ export default {
},
Mutation: {
createInvite: async (parent, { gift, limit }, { me, models }) => {
createInvite: async (parent, { id, gift, limit, description }, { me, models }) => {
if (!me) {
throw new GqlAuthenticationError()
}
assertApiKeyNotPermitted({ me })
await validateSchema(inviteSchema, { gift, limit })
return await models.invite.create({
data: { gift, limit, userId: me.id }
})
await validateSchema(inviteSchema, { id, gift, limit, description })
try {
return await models.invite.create({
data: {
id,
gift,
limit,
userId: me.id,
description
}
})
} catch (error) {
if (error instanceof Prisma.PrismaClientKnownRequestError) {
if (error.code === 'P2002' && error.meta.target.includes('id')) {
throw new GqlInputError('an invite with this code already exists')
}
}
throw error
}
},
revokeInvite: async (parent, { id }, { me, models }) => {
if (!me) {
throw new GqlAuthenticationError()
}
return await models.invite.update({
where: { id },
data: { revoked: true }
})
try {
return await models.invite.update({
where: { id, userId: me.id },
data: { revoked: true }
})
} catch (err) {
if (err.code === 'P2025') {
throw new GqlInputError('invite not found')
}
throw err
}
}
},
@ -63,6 +84,9 @@ export default {
poor: async (invite, args, { me, models }) => {
const user = await models.user.findUnique({ where: { id: invite.userId } })
return msatsToSats(user.msats) < invite.gift
},
description: (invite, args, { me }) => {
return invite.userId === me?.id ? invite.description : undefined
}
}
}

View File

@ -300,6 +300,8 @@ function typeClause (type) {
return ['"Item".bio = true', '"Item"."parentId" IS NULL']
case 'bounties':
return ['"Item".bounty IS NOT NULL', '"Item"."parentId" IS NULL']
case 'bounties_active':
return ['"Item".bounty IS NOT NULL', '"Item"."parentId" IS NULL', '"Item"."bountyPaidTo" IS NULL']
case 'comments':
return '"Item"."parentId" IS NOT NULL'
case 'freebies':
@ -423,6 +425,7 @@ export default {
subClause(sub, 5, subClauseTable(type), me, showNsfw),
typeClause(type),
whenClause(when, 'Item'),
activeOrMine(me),
await filterClause(me, models, type),
by === 'boost' && '"Item".boost > 0',
muteClause(me))}

View File

@ -13,6 +13,8 @@ import { BLOCK_HEIGHT } from '@/fragments/blockHeight'
import { CHAIN_FEE } from '@/fragments/chainFee'
import { getServerSession } from 'next-auth/next'
import { getAuthOptions } from '@/pages/api/auth/[...nextauth]'
import { NOFOLLOW_LIMIT } from '@/lib/constants'
import { satsToMsats } from '@/lib/format'
export default async function getSSRApolloClient ({ req, res, me = null }) {
const session = req && await getServerSession(req, res, getAuthOptions(req))
@ -64,7 +66,17 @@ function oneDayReferral (request, { me }) {
let prismaPromise, getData
if (referrer.startsWith('item-')) {
prismaPromise = models.item.findUnique({ where: { id: parseInt(referrer.slice(5)) } })
prismaPromise = models.item.findUnique({
where: {
id: parseInt(referrer.slice(5)),
msats: {
gt: satsToMsats(NOFOLLOW_LIMIT)
},
weightedVotes: {
gt: 0
}
}
})
getData = item => ({
referrerId: item.userId,
refereeId: parseInt(me.id),

View File

@ -7,7 +7,7 @@ export default gql`
}
extend type Mutation {
createInvite(gift: Int!, limit: Int): Invite
createInvite(id: String, gift: Int!, limit: Int, description: String): Invite
revokeInvite(id: ID!): Invite
}
@ -20,5 +20,6 @@ export default gql`
user: User!
revoked: Boolean!
poor: Boolean!
description: String
}
`

View File

@ -106,7 +106,11 @@ export const FileUpload = forwardRef(({ children, className, onSelect, onUpload,
if (onSelect) await onSelect?.(file, s3Upload)
else await s3Upload(file)
} catch (e) {
toaster.danger(`upload of '${file.name}' failed: ` + e.message || e.toString?.())
if (file.type === 'video/quicktime') {
toaster.danger(`upload of '${file.name}' failed: codec might not be supported, check video settings`)
} else {
toaster.danger(`upload of '${file.name}' failed: ` + e.message || e.toString?.())
}
continue
}
}

View File

@ -2,6 +2,7 @@ import { CopyInput } from './form'
import { gql, useMutation } from '@apollo/client'
import { INVITE_FIELDS } from '@/fragments/invites'
import styles from '@/styles/invites.module.css'
import { useToast } from '@/components/toast'
export default function Invite ({ invite, active }) {
const [revokeInvite] = useMutation(
@ -13,11 +14,13 @@ export default function Invite ({ invite, active }) {
}
}`
)
const toaster = useToast()
return (
<div
className={styles.invite}
>
{invite.description && <small className='text-muted'>{invite.description}</small>}
<CopyInput
groupClassName='mb-1'
size='sm' type='text'
@ -33,7 +36,13 @@ export default function Invite ({ invite, active }) {
<span> \ </span>
<span
className={styles.revoke}
onClick={() => revokeInvite({ variables: { id: invite.id } })}
onClick={async () => {
try {
await revokeInvite({ variables: { id: invite.id } })
} catch (err) {
toaster.danger(err.message)
}
}}
>revoke
</span>
</>)

View File

@ -8,7 +8,7 @@ import Bolt11Info from './bolt11-info'
import { useQuery } from '@apollo/client'
import { INVOICE } from '@/fragments/wallet'
import { FAST_POLL_INTERVAL, SSR } from '@/lib/constants'
import { NoAttachedWalletError } from '@/wallets/errors'
import { WalletConfigurationError, WalletPaymentAggregateError } from '@/wallets/errors'
import ItemJob from './item-job'
import Item from './item'
import { CommentFlat } from './comment'
@ -19,7 +19,7 @@ import styles from './invoice.module.css'
export default function Invoice ({
id, query = INVOICE, modal, onPayment, onExpired, onCanceled, info, successVerb = 'deposited',
heldVerb = 'settling', useWallet = true, walletError, poll, waitFor, ...props
heldVerb = 'settling', walletError, poll, waitFor, ...props
}) {
const { data, error } = useQuery(query, SSR
? {}
@ -79,15 +79,12 @@ export default function Invoice ({
{invoice.forwardedSats && <Badge className={styles.badge} bg={null}>p2p</Badge>}
</>
)
useWallet = false
} else if (expired) {
variant = 'failed'
status = 'expired'
useWallet = false
} else if (invoice.cancelled) {
variant = 'failed'
status = 'cancelled'
useWallet = false
} else if (invoice.isHeld) {
variant = 'pending'
status = (
@ -95,7 +92,6 @@ export default function Invoice ({
<Moon className='spin fill-grey me-2' /> {heldVerb}
</div>
)
useWallet = false
} else {
variant = 'pending'
status = (
@ -107,13 +103,9 @@ export default function Invoice ({
return (
<>
{walletError && !(walletError instanceof NoAttachedWalletError) &&
<div className='text-center fw-bold text-info mb-3' style={{ lineHeight: 1.25 }}>
Paying from attached wallet failed:
<code> {walletError.message}</code>
</div>}
<WalletError error={walletError} />
<Qr
useWallet={useWallet} value={invoice.bolt11}
value={invoice.bolt11}
description={numWithUnits(invoice.satsRequested, { abbreviate: false })}
statusVariant={variant} status={status}
/>
@ -209,3 +201,23 @@ function ActionInfo ({ invoice }) {
</div>
)
}
function WalletError ({ error }) {
if (!error || error instanceof WalletConfigurationError) return null
if (!(error instanceof WalletPaymentAggregateError)) {
console.error('unexpected wallet error:', error)
return null
}
return (
<div className='text-center fw-bold text-info mb-3' style={{ lineHeight: 1.25 }}>
<div className='text-info mb-2'>Paying from attached wallets failed:</div>
{error.errors.map((e, i) => (
<div key={i}>
<code>{e.wallet}: {e.reason || e.message}</code>
</div>
))}
</div>
)
}

View File

@ -13,7 +13,7 @@ import { usePaidMutation } from './use-paid-mutation'
import { ACT_MUTATION } from '@/fragments/paidAction'
import { meAnonSats } from '@/lib/apollo'
import { BoostItemInput } from './adv-post-form'
import { useWallet } from '@/wallets/index'
import { useSendWallets } from '@/wallets/index'
const defaultTips = [100, 1000, 10_000, 100_000]
@ -89,7 +89,7 @@ function BoostForm ({ step, onSubmit, children, item, oValue, inputRef, act = 'B
export default function ItemAct ({ onClose, item, act = 'TIP', step, children, abortSignal }) {
const inputRef = useRef(null)
const { me } = useMe()
const wallet = useWallet()
const wallets = useSendWallets()
const [oValue, setOValue] = useState()
useEffect(() => {
@ -117,7 +117,7 @@ export default function ItemAct ({ onClose, item, act = 'TIP', step, children, a
if (!me) setItemMeAnonSats({ id: item.id, amount })
}
const closeImmediately = !!wallet || me?.privates?.sats > Number(amount)
const closeImmediately = wallets.length > 0 || me?.privates?.sats > Number(amount)
if (closeImmediately) {
onPaid()
}
@ -143,7 +143,7 @@ export default function ItemAct ({ onClose, item, act = 'TIP', step, children, a
})
if (error) throw error
addCustomTip(Number(amount))
}, [me, actor, !!wallet, act, item.id, onClose, abortSignal, strike])
}, [me, actor, wallets.length, act, item.id, onClose, abortSignal, strike])
return act === 'BOOST'
? <BoostForm step={step} onSubmit={onSubmit} item={item} inputRef={inputRef} act={act}>{children}</BoostForm>
@ -260,7 +260,7 @@ export function useAct ({ query = ACT_MUTATION, ...options } = {}) {
}
export function useZap () {
const wallet = useWallet()
const wallets = useSendWallets()
const act = useAct()
const strike = useLightning()
const toaster = useToast()
@ -278,17 +278,18 @@ export function useZap () {
await abortSignal.pause({ me, amount: sats })
strike()
// batch zaps if wallet is enabled or using fee credits so they can be executed serially in a single request
const { error } = await act({ variables, optimisticResponse, context: { batch: !!wallet || me?.privates?.sats > sats } })
const { error } = await act({ variables, optimisticResponse, context: { batch: wallets.length > 0 || me?.privates?.sats > sats } })
if (error) throw error
} catch (error) {
if (error instanceof ActCanceledError) {
return
}
const reason = error?.message || error?.toString?.()
toaster.danger(reason)
// TODO: we should selectively toast based on error type
// but right now this toast is noisy for optimistic zaps
console.error(error)
}
}, [act, toaster, strike, !!wallet])
}, [act, toaster, strike, wallets.length])
}
export class ActCanceledError extends Error {

View File

@ -6,7 +6,7 @@ import Dropdown from 'react-bootstrap/Dropdown'
import Countdown from './countdown'
import { abbrNum, numWithUnits } from '@/lib/format'
import { newComments, commentsViewedAt } from '@/lib/new-comments'
import { datePivot, timeSince } from '@/lib/time'
import { timeSince } from '@/lib/time'
import { DeleteDropdownItem } from './delete'
import styles from './item.module.css'
import { useMe } from './me'
@ -27,6 +27,8 @@ import { useRetryCreateItem } from './use-item-submit'
import { useToast } from './toast'
import { useShowModal } from './modal'
import classNames from 'classnames'
import SubPopover from './sub-popover'
import useCanEdit from './use-can-edit'
export default function ItemInfo ({
item, full, commentsText = 'comments',
@ -34,12 +36,12 @@ export default function ItemInfo ({
onQuoteReply, extraBadges, nested, pinnable, showActionDropdown = true, showUser = true,
setDisableRetry, disableRetry
}) {
const editThreshold = datePivot(new Date(item.invoice?.confirmedAt ?? item.createdAt), { minutes: 10 })
const { me } = useMe()
const router = useRouter()
const [hasNewComments, setHasNewComments] = useState(false)
const root = useRoot()
const sub = item?.sub || root?.sub
const [canEdit, setCanEdit, editThreshold] = useCanEdit(item)
useEffect(() => {
if (!full) {
@ -47,19 +49,6 @@ export default function ItemInfo ({
}
}, [item])
// allow anon edits if they have the correct hmac for the item invoice
// (the server will verify the hmac)
const [anonEdit, setAnonEdit] = useState(false)
useEffect(() => {
const invParams = window.localStorage.getItem(`item:${item.id}:hash:hmac`)
setAnonEdit(!!invParams && !me && Number(item.user.id) === USER_ID.anon)
}, [])
// deleted items can never be edited and every item has a 10 minute edit window
// except bios, they can always be edited but they should never show the countdown
const noEdit = !!item.deletedAt || (Date.now() >= editThreshold) || item.bio
const canEdit = !noEdit && ((me && item.mine) || anonEdit)
// territory founders can pin any post in their territory
// and OPs can pin any root reply in their post
const isPost = !item.parentId
@ -134,9 +123,11 @@ export default function ItemInfo ({
</>}
</span>
{item.subName &&
<Link href={`/~${item.subName}`}>
{' '}<Badge className={styles.newComment} bg={null}>{item.subName}</Badge>
</Link>}
<SubPopover sub={item.subName}>
<Link href={`/~${item.subName}`}>
{' '}<Badge className={styles.newComment} bg={null}>{item.subName}</Badge>
</Link>
</SubPopover>}
{sub?.nsfw &&
<Badge className={styles.newComment} bg={null}>nsfw</Badge>}
{(item.outlawed && !item.mine &&
@ -157,7 +148,7 @@ export default function ItemInfo ({
<>
<EditInfo
item={item} edit={edit} canEdit={canEdit}
setCanEdit={setAnonEdit} toggleEdit={toggleEdit} editText={editText} editThreshold={editThreshold}
setCanEdit={setCanEdit} toggleEdit={toggleEdit} editText={editText} editThreshold={editThreshold}
/>
<PaymentInfo item={item} disableRetry={disableRetry} setDisableRetry={setDisableRetry} />
<ActionDropdown>

View File

@ -12,6 +12,7 @@ import Badges from './badge'
import { MEDIA_URL } from '@/lib/constants'
import { abbrNum } from '@/lib/format'
import { Badge } from 'react-bootstrap'
import SubPopover from './sub-popover'
export default function ItemJob ({ item, toc, rank, children }) {
const isEmail = string().email().isValidSync(item.url)
@ -62,9 +63,11 @@ export default function ItemJob ({ item, toc, rank, children }) {
</Link>
</span>
{item.subName &&
<Link href={`/~${item.subName}`}>
{' '}<Badge className={styles.newComment} bg={null}>{item.subName}</Badge>
</Link>}
<SubPopover sub={item.subName}>
<Link href={`/~${item.subName}`}>
{' '}<Badge className={styles.newComment} bg={null}>{item.subName}</Badge>
</Link>
</SubPopover>}
{item.status === 'STOPPED' &&
<>{' '}<Badge bg='info' className={styles.badge}>stopped</Badge></>}
{item.mine && !item.deletedAt &&

View File

@ -283,7 +283,7 @@ function Invitification ({ n }) {
<>
<NoteHeader color='secondary'>
your invite has been redeemed by
{numWithUnits(n.invite.invitees.length, {
{' ' + numWithUnits(n.invite.invitees.length, {
abbreviate: false,
unitSingular: 'stacker',
unitPlural: 'stackers'
@ -370,6 +370,29 @@ function useActRetry ({ invoice }) {
invoice.item.root?.bounty === invoice.satsRequested && invoice.item.root?.mine
? payBountyCacheMods
: {}
const update = (cache, { data }) => {
const response = Object.values(data)[0]
if (!response?.invoice) return
cache.modify({
id: `ItemAct:${invoice.itemAct?.id}`,
fields: {
// this is a bit of a hack just to update the reference to the new invoice
invoice: () => cache.writeFragment({
id: `Invoice:${response.invoice.id}`,
fragment: gql`
fragment _ on Invoice {
bolt11
}
`,
data: { bolt11: response.invoice.bolt11 }
})
}
})
paidActionCacheMods?.update?.(cache, { data })
bountyCacheMods?.update?.(cache, { data })
}
return useAct({
query: RETRY_PAID_ACTION,
onPayError: (e, cache, { data }) => {
@ -380,27 +403,8 @@ function useActRetry ({ invoice }) {
paidActionCacheMods?.onPaid?.(cache, { data })
bountyCacheMods?.onPaid?.(cache, { data })
},
update: (cache, { data }) => {
const response = Object.values(data)[0]
if (!response?.invoice) return
cache.modify({
id: `ItemAct:${invoice.itemAct?.id}`,
fields: {
// this is a bit of a hack just to update the reference to the new invoice
invoice: () => cache.writeFragment({
id: `Invoice:${response.invoice.id}`,
fragment: gql`
fragment _ on Invoice {
bolt11
}
`,
data: { bolt11: response.invoice.bolt11 }
})
}
})
paidActionCacheMods?.update?.(cache, { data })
bountyCacheMods?.update?.(cache, { data })
}
update,
updateOnFallback: update
})
}

View File

@ -1,22 +1,16 @@
import { useCallback } from 'react'
import { gql, useApolloClient, useMutation } from '@apollo/client'
import { useWallet } from '@/wallets/index'
import { FAST_POLL_INTERVAL } from '@/lib/constants'
import { INVOICE } from '@/fragments/wallet'
import { useApolloClient, useMutation } from '@apollo/client'
import { CANCEL_INVOICE, INVOICE } from '@/fragments/wallet'
import Invoice from '@/components/invoice'
import { useShowModal } from './modal'
import { InvoiceCanceledError, NoAttachedWalletError, InvoiceExpiredError } from '@/wallets/errors'
import { InvoiceCanceledError, InvoiceExpiredError } from '@/wallets/errors'
import { RETRY_PAID_ACTION } from '@/fragments/paidAction'
export const useInvoice = () => {
const client = useApolloClient()
const [retryPaidAction] = useMutation(RETRY_PAID_ACTION)
const [cancelInvoice] = useMutation(gql`
mutation cancelInvoice($hash: String!, $hmac: String!) {
cancelInvoice(hash: $hash, hmac: $hmac) {
id
}
}
`)
const [cancelInvoice] = useMutation(CANCEL_INVOICE)
const isInvoice = useCallback(async ({ id }, that) => {
const { data, error } = await client.query({ query: INVOICE, fetchPolicy: 'network-only', variables: { id } })
@ -24,15 +18,15 @@ export const useInvoice = () => {
throw error
}
const { hash, cancelled, cancelledAt, actionError, actionState, expiresAt } = data.invoice
const { cancelled, cancelledAt, actionError, actionState, expiresAt } = data.invoice
const expired = cancelledAt && new Date(expiresAt) < new Date(cancelledAt)
if (expired) {
throw new InvoiceExpiredError(hash)
throw new InvoiceExpiredError(data.invoice)
}
if (cancelled || actionError) {
throw new InvoiceCanceledError(hash, actionError)
throw new InvoiceCanceledError(data.invoice, actionError)
}
// write to cache if paid
@ -40,7 +34,7 @@ export const useInvoice = () => {
client.writeQuery({ query: INVOICE, variables: { id }, data: { invoice: data.invoice } })
}
return that(data.invoice)
return { invoice: data.invoice, check: that(data.invoice) }
}, [client])
const cancel = useCallback(async ({ hash, hmac }) => {
@ -49,77 +43,22 @@ export const useInvoice = () => {
}
console.log('canceling invoice:', hash)
const inv = await cancelInvoice({ variables: { hash, hmac } })
return inv
const { data } = await cancelInvoice({ variables: { hash, hmac } })
return data.cancelInvoice
}, [cancelInvoice])
return { cancel, isInvoice }
}
const retry = useCallback(async ({ id, hash, hmac }, { update }) => {
console.log('retrying invoice:', hash)
const { data, error } = await retryPaidAction({ variables: { invoiceId: Number(id) }, update })
if (error) throw error
const invoiceController = (id, isInvoice) => {
const controller = new AbortController()
const signal = controller.signal
controller.wait = async (waitFor = inv => inv?.actionState === 'PAID') => {
return await new Promise((resolve, reject) => {
const interval = setInterval(async () => {
try {
const paid = await isInvoice({ id }, waitFor)
if (paid) {
resolve()
clearInterval(interval)
signal.removeEventListener('abort', abort)
} else {
console.info(`invoice #${id}: waiting for payment ...`)
}
} catch (err) {
reject(err)
clearInterval(interval)
signal.removeEventListener('abort', abort)
}
}, FAST_POLL_INTERVAL)
const newInvoice = data.retryPaidAction.invoice
console.log('new invoice:', newInvoice?.hash)
const abort = () => {
console.info(`invoice #${id}: stopped waiting`)
resolve()
clearInterval(interval)
signal.removeEventListener('abort', abort)
}
signal.addEventListener('abort', abort)
})
}
return newInvoice
}, [retryPaidAction])
controller.stop = () => controller.abort()
return controller
}
export const useWalletPayment = () => {
const invoice = useInvoice()
const wallet = useWallet()
const waitForWalletPayment = useCallback(async ({ id, bolt11 }, waitFor) => {
if (!wallet) {
throw new NoAttachedWalletError()
}
const controller = invoiceController(id, invoice.isInvoice)
try {
return await new Promise((resolve, reject) => {
// can't use await here since we might pay JIT invoices and sendPaymentAsync is not supported yet.
// see https://www.webln.guide/building-lightning-apps/webln-reference/webln.sendpaymentasync
wallet.sendPayment(bolt11).catch(reject)
controller.wait(waitFor)
.then(resolve)
.catch(reject)
})
} catch (err) {
console.error('payment failed:', err)
throw err
} finally {
controller.stop()
}
}, [wallet, invoice])
return waitForWalletPayment
return { cancel, retry, isInvoice }
}
export const useQrPayment = () => {
@ -138,10 +77,10 @@ export const useQrPayment = () => {
let paid
const cancelAndReject = async (onClose) => {
if (!paid && cancelOnClose) {
await invoice.cancel(inv).catch(console.error)
reject(new InvoiceCanceledError(inv?.hash))
const updatedInv = await invoice.cancel(inv).catch(console.error)
reject(new InvoiceCanceledError(updatedInv))
}
resolve()
resolve(inv)
}
showModal(onClose =>
<Invoice
@ -150,12 +89,11 @@ export const useQrPayment = () => {
description
status='loading'
successVerb='received'
useWallet={false}
walletError={walletError}
waitFor={waitFor}
onExpired={inv => reject(new InvoiceExpiredError(inv?.hash))}
onCanceled={inv => { onClose(); reject(new InvoiceCanceledError(inv?.hash, inv?.actionError)) }}
onPayment={() => { paid = true; onClose(); resolve() }}
onExpired={inv => reject(new InvoiceExpiredError(inv))}
onCanceled={inv => { onClose(); reject(new InvoiceCanceledError(inv, inv?.actionError)) }}
onPayment={(inv) => { paid = true; onClose(); resolve(inv) }}
poll
/>,
{ keepOpen, persistOnNavigate, onClose: cancelAndReject })

View File

@ -1,8 +1,6 @@
import { QRCodeSVG } from 'qrcode.react'
import { CopyInput, InputSkeleton } from './form'
import InvoiceStatus from './invoice-status'
import { useEffect } from 'react'
import { useWallet } from '@/wallets/index'
import Bolt11Info from './bolt11-info'
export const qrImageSettings = {
@ -14,22 +12,8 @@ export const qrImageSettings = {
excavate: true
}
export default function Qr ({ asIs, value, useWallet: automated, statusVariant, description, status }) {
export default function Qr ({ asIs, value, statusVariant, description, status }) {
const qrValue = asIs ? value : 'lightning:' + value.toUpperCase()
const wallet = useWallet()
useEffect(() => {
async function effect () {
if (automated && wallet) {
try {
await wallet.sendPayment(value)
} catch (e) {
console.log(e?.message)
}
}
}
effect()
}, [wallet])
return (
<>

View File

@ -1,10 +1,33 @@
import { ITEM_TYPES, ITEM_TYPES_UNIVERSAL } from '@/lib/constants'
import BootstrapForm from 'react-bootstrap/Form'
import { Select } from './form'
import { useRouter } from 'next/router'
export default function RecentHeader ({ type, sub }) {
function ActiveBountiesCheckbox ({ prefix }) {
const router = useRouter()
const onChange = (e) => {
if (e.target.checked) {
router.push(prefix + '/recent/bounties?' + new URLSearchParams({ active: true }).toString())
} else {
router.push(prefix + '/recent/bounties')
}
}
return (
<div className='mx-2 mb-2'>
<BootstrapForm.Check
inline
checked={router.query.active === 'true'}
label='active only'
onChange={onChange}
/>
</div>
)
}
export default function RecentHeader ({ type, sub }) {
const router = useRouter()
const prefix = sub ? `/~${sub.name}` : ''
const items = sub
@ -14,18 +37,22 @@ export default function RecentHeader ({ type, sub }) {
: ITEM_TYPES
type ||= router.query.type || type || 'posts'
return (
<div className='text-muted fw-bold my-1 d-flex justify-content-start align-items-center'>
<Select
groupClassName='mb-2'
className='w-auto'
name='type'
size='sm'
value={type}
items={items}
noForm
onChange={(_, e) => router.push(prefix + (e.target.value === 'posts' ? '/recent' : `/recent/${e.target.value}`))}
/>
<div className='flex-wrap'>
<div className='text-muted fw-bold my-1 d-flex justify-content-start align-items-center'>
<Select
groupClassName='mb-2'
className='w-auto'
name='type'
size='sm'
value={type}
items={items}
noForm
onChange={(_, e) => router.push(prefix + (e.target.value === 'posts' ? '/recent' : `/recent/${e.target.value}`))}
/>
{type === 'bounties' && <ActiveBountiesCheckbox prefix={prefix} />}
</div>
</div>
)
}

View File

@ -113,7 +113,7 @@ export default forwardRef(function Reply ({
}
},
onSuccessfulSubmit: (data, { resetForm }) => {
resetForm({ text: '' })
resetForm({ values: { text: '' } })
setReply(replyOpen || false)
},
navigateOnSubmit: false

29
components/sub-popover.js Normal file
View File

@ -0,0 +1,29 @@
import { SUB_FULL } from '@/fragments/subs'
import errorStyles from '@/styles/error.module.css'
import { useLazyQuery } from '@apollo/client'
import classNames from 'classnames'
import HoverablePopover from './hoverable-popover'
import { TerritoryInfo, TerritoryInfoSkeleton } from './territory-header'
import { truncateString } from '@/lib/format'
export default function SubPopover ({ sub, children }) {
const [getSub, { loading, data }] = useLazyQuery(
SUB_FULL,
{
variables: { sub },
fetchPolicy: 'cache-first'
}
)
return (
<HoverablePopover
onShow={getSub}
trigger={children}
body={!data || loading
? <TerritoryInfoSkeleton />
: !data.sub
? <h1 className={classNames(errorStyles.status, errorStyles.describe)}>SUB NOT FOUND</h1>
: <TerritoryInfo sub={{ ...data.sub, desc: truncateString(data.sub.desc, 280) }} />}
/>
)
}

View File

@ -1,10 +1,11 @@
import { useEffect, useState } from 'react'
import { useRouter } from 'next/router'
import { Select } from './form'
import { EXTRA_LONG_POLL_INTERVAL, SSR } from '@/lib/constants'
import { SUBS } from '@/fragments/subs'
import { useQuery } from '@apollo/client'
import { useEffect, useState } from 'react'
import styles from './sub-select.module.css'
import { useMe } from './me'
export function SubSelectInitial ({ sub }) {
const router = useRouter()
@ -20,19 +21,27 @@ const DEFAULT_APPEND_SUBS = []
const DEFAULT_FILTER_SUBS = () => true
export function useSubs ({ prependSubs = DEFAULT_PREPEND_SUBS, sub, filterSubs = DEFAULT_FILTER_SUBS, appendSubs = DEFAULT_APPEND_SUBS }) {
const { data } = useQuery(SUBS, SSR
const { data, refetch } = useQuery(SUBS, SSR
? {}
: {
pollInterval: EXTRA_LONG_POLL_INTERVAL,
nextFetchPolicy: 'cache-and-network'
})
const { me } = useMe()
useEffect(() => {
refetch()
}, [me?.privates?.nsfwMode])
const [subs, setSubs] = useState([
...prependSubs.filter(s => s !== sub),
...(sub ? [sub] : []),
...appendSubs.filter(s => s !== sub)])
useEffect(() => {
if (!data) return
const joined = data.subs.filter(filterSubs).filter(s => !s.meMuteSub).map(s => s.name)
const muted = data.subs.filter(filterSubs).filter(s => s.meMuteSub).map(s => s.name)
const mutedSection = muted.length ? [{ label: 'muted', items: muted }] : []

View File

@ -31,6 +31,17 @@ export function TerritoryDetails ({ sub, children }) {
)
}
export function TerritoryInfoSkeleton ({ children, className }) {
return (
<div className={`${styles.item} ${styles.skeleton} ${className}`}>
<div className={styles.hunk}>
<div className={`${styles.name} clouds text-reset`} />
{children}
</div>
</div>
)
}
export function TerritoryInfo ({ sub }) {
return (
<>

View File

@ -12,6 +12,7 @@ import { useRouter } from 'next/router'
import Link from 'next/link'
import { UNKNOWN_LINK_REL } from '@/lib/constants'
import isEqual from 'lodash/isEqual'
import SubPopover from './sub-popover'
import UserPopover from './user-popover'
import ItemPopover from './item-popover'
import classNames from 'classnames'
@ -183,8 +184,12 @@ function Mention ({ children, node, href, name, id }) {
)
}
function Sub ({ children, node, href, ...props }) {
return <Link href={href}>{children}</Link>
function Sub ({ children, node, href, name, ...props }) {
return (
<SubPopover sub={name}>
<Link href={href}>{children}</Link>
</SubPopover>
)
}
function Item ({ children, node, href, id }) {

View File

@ -0,0 +1,26 @@
import { useEffect, useState } from 'react'
import { datePivot } from '@/lib/time'
import { useMe } from '@/components/me'
import { USER_ID } from '@/lib/constants'
export default function useCanEdit (item) {
const editThreshold = datePivot(new Date(item.invoice?.confirmedAt ?? item.createdAt), { minutes: 10 })
const { me } = useMe()
// deleted items can never be edited and every item has a 10 minute edit window
// except bios, they can always be edited but they should never show the countdown
const noEdit = !!item.deletedAt || (Date.now() >= editThreshold) || item.bio
const authorEdit = me && item.mine
const [canEdit, setCanEdit] = useState(!noEdit && authorEdit)
useEffect(() => {
// allow anon edits if they have the correct hmac for the item invoice
// (the server will verify the hmac)
const invParams = window.localStorage.getItem(`item:${item.id}:hash:hmac`)
const anonEdit = !!invParams && !me && Number(item.user.id) === USER_ID.anon
// anonEdit should not override canEdit, but only allow edits if they aren't already allowed
setCanEdit(canEdit => canEdit || anonEdit)
}, [])
return [canEdit, setCanEdit, editThreshold]
}

View File

@ -1,8 +1,9 @@
import { useApolloClient, useLazyQuery, useMutation } from '@apollo/client'
import { useCallback, useState } from 'react'
import { useInvoice, useQrPayment, useWalletPayment } from './payment'
import { InvoiceCanceledError, InvoiceExpiredError } from '@/wallets/errors'
import { useInvoice, useQrPayment } from './payment'
import { InvoiceCanceledError, InvoiceExpiredError, WalletError, WalletPaymentError } from '@/wallets/errors'
import { GET_PAID_ACTION } from '@/fragments/paidAction'
import { useWalletPayment } from '@/wallets/payment'
/*
this is just like useMutation with a few changes:
@ -30,25 +31,41 @@ export function usePaidMutation (mutation,
// innerResult is used to store/control the result of the mutation when innerMutate runs
const [innerResult, setInnerResult] = useState(result)
const waitForPayment = useCallback(async (invoice, { alwaysShowQROnFailure = false, persistOnNavigate = false, waitFor }) => {
const waitForPayment = useCallback(async (invoice, { alwaysShowQROnFailure = false, persistOnNavigate = false, waitFor, updateOnFallback }) => {
let walletError
let walletInvoice = invoice
const start = Date.now()
try {
return await waitForWalletPayment(invoice, waitFor)
return await waitForWalletPayment(walletInvoice, { waitFor, updateOnFallback })
} catch (err) {
if (
(!alwaysShowQROnFailure && Date.now() - start > 1000) ||
err instanceof InvoiceCanceledError ||
err instanceof InvoiceExpiredError) {
// bail since qr code payment will also fail
// also bail if the payment took more than 1 second
// and cancel the invoice if it's not already canceled so it can be retried
invoiceHelper.cancel(invoice).catch(console.error)
walletError = null
if (err instanceof WalletError) {
walletError = err
// get the last invoice that was attempted but failed and was canceled
if (err.invoice) walletInvoice = err.invoice
}
const invoiceError = err instanceof InvoiceCanceledError || err instanceof InvoiceExpiredError
if (!invoiceError && !walletError) {
// unexpected error, rethrow
throw err
}
// bail if the payment took too long to prevent showing a QR code on an unrelated page
// (if alwaysShowQROnFailure is not set) or user canceled the invoice or it expired
const tooSlow = Date.now() - start > 1000
const skipQr = (tooSlow && !alwaysShowQROnFailure) || invoiceError
if (skipQr) {
throw err
}
walletError = err
}
return await waitForQrPayment(invoice, walletError, { persistOnNavigate, waitFor })
const paymentAttempted = walletError instanceof WalletPaymentError
if (paymentAttempted) {
walletInvoice = await invoiceHelper.retry(walletInvoice, { update: updateOnFallback })
}
return await waitForQrPayment(walletInvoice, walletError, { persistOnNavigate, waitFor })
}, [waitForWalletPayment, waitForQrPayment, invoiceHelper])
const innerMutate = useCallback(async ({
@ -60,7 +77,7 @@ export function usePaidMutation (mutation,
// use the most inner callbacks/options if they exist
const {
onPaid, onPayError, forceWaitForPayment, persistOnNavigate,
update, waitFor = inv => inv?.actionState === 'PAID'
update, waitFor = inv => inv?.actionState === 'PAID', updateOnFallback
} = { ...options, ...innerOptions }
const ourOnCompleted = innerOnCompleted || onCompleted
@ -69,7 +86,7 @@ export function usePaidMutation (mutation,
throw new Error('usePaidMutation: exactly one mutation at a time is supported')
}
const response = Object.values(data)[0]
const invoice = response?.invoice
let invoice = response?.invoice
// if the mutation returns an invoice, pay it
if (invoice) {
@ -81,15 +98,28 @@ export function usePaidMutation (mutation,
error: e instanceof InvoiceCanceledError && e.actionError ? e : undefined
})
const mergeData = obj => ({
[Object.keys(data)[0]]: {
...data?.[Object.keys(data)[0]],
...obj
}
})
// should we wait for the invoice to be paid?
if (response?.paymentMethod === 'OPTIMISTIC' && !forceWaitForPayment) {
// onCompleted is called before the invoice is paid for optimistic updates
ourOnCompleted?.(data)
// don't wait to pay the invoice
waitForPayment(invoice, { persistOnNavigate, waitFor }).then(() => {
waitForPayment(invoice, { persistOnNavigate, waitFor, updateOnFallback }).then((invoice) => {
// invoice might have been retried during payment
data = mergeData({ invoice })
onPaid?.(client.cache, { data })
}).catch(e => {
console.error('usePaidMutation: failed to pay invoice', e)
if (e.invoice) {
// update the failed invoice for the Apollo cache update
data = mergeData({ invoice: e.invoice })
}
// onPayError is called after the invoice fails to pay
// useful for updating invoiceActionState to FAILED
onPayError?.(e, client.cache, { data })
@ -99,18 +129,14 @@ export function usePaidMutation (mutation,
// the action is pessimistic
try {
// wait for the invoice to be paid
await waitForPayment(invoice, { alwaysShowQROnFailure: true, persistOnNavigate, waitFor })
// returns the invoice that was paid since it might have been updated via retries
invoice = await waitForPayment(invoice, { alwaysShowQROnFailure: true, persistOnNavigate, waitFor, updateOnFallback })
if (!response.result) {
// if the mutation didn't return any data, ie pessimistic, we need to fetch it
const { data: { paidAction } } = await getPaidAction({ variables: { invoiceId: parseInt(invoice.id) } })
// create new data object
// ( hmac is only returned on invoice creation so we need to add it back to the data )
data = {
[Object.keys(data)[0]]: {
...paidAction,
invoice: { ...paidAction.invoice, hmac: invoice.hmac }
}
}
data = mergeData({ ...paidAction, invoice: { ...paidAction.invoice, hmac: invoice.hmac } })
// we need to run update functions on mutations now that we have the data
update?.(client.cache, { data })
}

View File

@ -5,13 +5,11 @@ import { Button } from 'react-bootstrap'
import { useToast } from './toast'
import { useShowModal } from './modal'
import { WALLET_LOGS } from '@/fragments/wallet'
import { getWalletByType } from '@/wallets/common'
import { getWalletByType, walletTag } from '@/wallets/common'
import { gql, useLazyQuery, useMutation } from '@apollo/client'
import { useMe } from './me'
import useIndexedDB, { getDbName } from './use-indexeddb'
import { SSR } from '@/lib/constants'
import { decode as bolt11Decode } from 'bolt11'
import { formatMsats } from '@/lib/format'
import { useRouter } from 'next/router'
export function WalletLogs ({ wallet, embedded }) {
@ -61,7 +59,7 @@ export function WalletLogs ({ wallet, embedded }) {
}
function DeleteWalletLogsObstacle ({ wallet, setLogs, onClose }) {
const { deleteLogs } = useWalletLogger(wallet, setLogs)
const { deleteLogs } = useWalletLogManager(setLogs)
const toaster = useToast()
const prompt = `Do you really want to delete all ${wallet ? '' : 'wallet'} logs ${wallet ? 'of this wallet' : ''}?`
@ -110,11 +108,11 @@ function useWalletLogDB () {
return { add, getPage, clear, error, notSupported }
}
export function useWalletLogger (wallet, setLogs) {
export function useWalletLogManager (setLogs) {
const { add, clear, notSupported } = useWalletLogDB()
const appendLog = useCallback(async (wallet, level, message, context) => {
const log = { wallet: tag(wallet), level, message, ts: +new Date(), context }
const log = { wallet: walletTag(wallet.def), level, message, ts: +new Date(), context }
try {
if (notSupported) {
console.log('cannot persist wallet log: indexeddb not supported')
@ -146,56 +144,20 @@ export function useWalletLogger (wallet, setLogs) {
}
if (!wallet || wallet.sendPayment) {
try {
const walletTag = wallet ? tag(wallet) : null
const tag = wallet ? walletTag(wallet.def) : null
if (notSupported) {
console.log('cannot clear wallet logs: indexeddb not supported')
} else {
await clear('wallet_ts', walletTag ? window.IDBKeyRange.bound([walletTag, 0], [walletTag, Infinity]) : null)
await clear('wallet_ts', tag ? window.IDBKeyRange.bound([tag, 0], [tag, Infinity]) : null)
}
setLogs?.(logs => logs.filter(l => wallet ? l.wallet !== tag(wallet) : false))
setLogs?.(logs => logs.filter(l => wallet ? l.wallet !== tag : false))
} catch (e) {
console.error('failed to delete logs', e)
}
}
}, [clear, deleteServerWalletLogs, setLogs, notSupported])
const log = useCallback(level => (message, context = {}) => {
if (!wallet) {
// console.error('cannot log: no wallet set')
return
}
if (context?.bolt11) {
// automatically populate context from bolt11 to avoid duplicating this code
const decoded = bolt11Decode(context.bolt11)
context = {
...context,
amount: formatMsats(decoded.millisatoshis),
payment_hash: decoded.tagsObject.payment_hash,
description: decoded.tagsObject.description,
created_at: new Date(decoded.timestamp * 1000).toISOString(),
expires_at: new Date(decoded.timeExpireDate * 1000).toISOString(),
// payments should affect wallet status
status: true
}
}
context.send = true
appendLog(wallet, level, message, context)
console[level !== 'error' ? 'info' : 'error'](`[${tag(wallet)}]`, message)
}, [appendLog, wallet])
const logger = useMemo(() => ({
ok: (message, context) => log('ok')(message, context),
info: (message, context) => log('info')(message, context),
error: (message, context) => log('error')(message, context)
}), [log])
return { logger, deleteLogs }
}
function tag (walletDef) {
return walletDef.shortName || walletDef.name
return { appendLog, deleteLogs }
}
export function useWalletLogs (wallet, initialPage = 1, logsPerPage = 10) {
@ -227,7 +189,7 @@ export function useWalletLogs (wallet, initialPage = 1, logsPerPage = 10) {
console.log('cannot get client wallet logs: indexeddb not supported')
} else {
const indexName = walletDef ? 'wallet_ts' : 'ts'
const query = walletDef ? window.IDBKeyRange.bound([tag(walletDef), -Infinity], [tag(walletDef), Infinity]) : null
const query = walletDef ? window.IDBKeyRange.bound([walletTag(walletDef), -Infinity], [walletTag(walletDef), Infinity]) : null
result = await getPage(page, pageSize, indexName, query, 'prev')
// if given wallet has no walletType it means logs are only stored in local IDB
@ -272,7 +234,7 @@ export function useWalletLogs (wallet, initialPage = 1, logsPerPage = 10) {
const newLogs = data.walletLogs.entries.map(({ createdAt, wallet: walletType, ...log }) => ({
ts: +new Date(createdAt),
wallet: tag(getWalletByType(walletType)),
wallet: walletTag(getWalletByType(walletType)),
...log
}))
const combinedLogs = uniqueSort([...result.data, ...newLogs])

View File

@ -19,5 +19,6 @@ export const INVITE_FIELDS = gql`
...StreakFields
}
poor
description
}
`

View File

@ -37,6 +37,7 @@ ${STREAK_FIELDS}
imgproxyOnly
showImagesAndVideos
nostrCrossposting
nsfwMode
sats
tipDefault
tipRandom

View File

@ -221,3 +221,12 @@ export const SET_WALLET_PRIORITY = gql`
setWalletPriority(id: $id, priority: $priority)
}
`
export const CANCEL_INVOICE = gql`
${INVOICE_FIELDS}
mutation cancelInvoice($hash: String!, $hmac: String!) {
cancelInvoice(hash: $hash, hmac: $hmac) {
...InvoiceFields
}
}
`

View File

@ -27,6 +27,7 @@ export const UPLOAD_TYPES_ALLOW = [
'image/png',
'image/jpeg',
'image/webp',
'video/quicktime',
'video/mp4',
'video/mpeg',
'video/webm'

View File

@ -204,3 +204,49 @@ export const toPositive = (x) => {
if (typeof x === 'bigint') return toPositiveBigInt(x)
return toPositiveNumber(x)
}
/**
* Truncates a string intelligently, trying to keep natural breaks
* @param {string} str - The string to truncate
* @param {number} maxLength - Maximum length of the result
* @param {string} [suffix='...'] - String to append when truncated
* @returns {string} Truncated string
*/
export const truncateString = (str, maxLength, suffix = ' ...') => {
if (!str || str.length <= maxLength) return str
const effectiveLength = maxLength - suffix.length
// Split into paragraphs and accumulate until we exceed the limit
const paragraphs = str.split(/\n\n+/)
let result = ''
for (const paragraph of paragraphs) {
if ((result + paragraph).length > effectiveLength) {
// If this is the first paragraph and it's too long,
// fall back to sentence/word breaking
if (!result) {
// Try to break at sentence
const sentenceBreak = paragraph.slice(0, effectiveLength).match(/[.!?]\s+[A-Z]/g)
if (sentenceBreak) {
const lastBreak = paragraph.lastIndexOf(sentenceBreak[sentenceBreak.length - 1], effectiveLength)
if (lastBreak > effectiveLength / 2) {
return paragraph.slice(0, lastBreak + 1) + suffix
}
}
// Try to break at word
const wordBreak = paragraph.lastIndexOf(' ', effectiveLength)
if (wordBreak > 0) {
return paragraph.slice(0, wordBreak) + suffix
}
// Fall back to character break
return paragraph.slice(0, effectiveLength) + suffix
}
return result.trim() + suffix
}
result += (result ? '\n\n' : '') + paragraph
}
return result
}

View File

@ -478,7 +478,9 @@ export const bioSchema = object({
export const inviteSchema = object({
gift: intValidator.positive('must be greater than 0').required('required'),
limit: intValidator.positive('must be positive')
limit: intValidator.positive('must be positive'),
description: string().trim().max(40, 'must be at most 40 characters'),
id: string().matches(/^[\w-_]+$/, 'only letters, numbers, underscores, and hyphens').min(4, 'must be at least 4 characters').max(32, 'must be at most 32 characters')
})
export const pushSubscriptionSchema = object({

View File

@ -340,10 +340,10 @@ export async function notifyEarner (userId, earnings) {
export async function notifyDeposit (userId, invoice) {
try {
await sendUserNotification(userId, {
title: `${numWithUnits(msatsToSats(invoice.received_mtokens), { abbreviate: false, unitSingular: 'sat was', unitPlural: 'sats were' })} deposited in your account`,
title: `${numWithUnits(msatsToSats(invoice.msatsReceived), { abbreviate: false, unitSingular: 'sat was', unitPlural: 'sats were' })} deposited in your account`,
body: invoice.comment || undefined,
tag: 'DEPOSIT',
data: { sats: msatsToSats(invoice.received_mtokens) }
data: { sats: msatsToSats(invoice.msatsReceived) }
})
} catch (err) {
console.error(err)

View File

@ -7,7 +7,7 @@ import { PriceProvider } from '@/components/price'
import { BlockHeightProvider } from '@/components/block-height'
import Head from 'next/head'
import { useRouter } from 'next/dist/client/router'
import { useEffect } from 'react'
import { useCallback, useEffect } from 'react'
import { ShowModalProvider } from '@/components/modal'
import ErrorBoundary from '@/components/error-boundary'
import { LightningProvider } from '@/components/lightning'
@ -46,9 +46,17 @@ export default function MyApp ({ Component, pageProps: { ...props } }) {
const client = getApolloClient()
const router = useRouter()
const shouldShowProgressBar = useCallback((newPathname, shallow) => {
return !shallow || newPathname !== router.pathname
}, [router.pathname])
useEffect(() => {
const nprogressStart = (_, { shallow }) => !shallow && NProgress.start()
const nprogressDone = (_, { shallow }) => !shallow && NProgress.done()
const nprogressStart = (newPathname, { shallow }) => {
shouldShowProgressBar(newPathname, shallow) && NProgress.start()
}
const nprogressDone = (newPathname, { shallow }) => {
NProgress.done()
}
router.events.on('routeChangeStart', nprogressStart)
router.events.on('routeChangeComplete', nprogressDone)
@ -77,7 +85,7 @@ export default function MyApp ({ Component, pageProps: { ...props } }) {
router.events.off('routeChangeComplete', nprogressDone)
router.events.off('routeChangeError', nprogressDone)
}
}, [router.asPath, props?.apollo])
}, [router.asPath, props?.apollo, shouldShowProgressBar])
useEffect(() => {
// hack to disable ios pwa prompt for https://github.com/stackernews/stacker.news/issues/953

View File

@ -9,6 +9,8 @@ import Invite from '@/components/invite'
import { inviteSchema } from '@/lib/validate'
import { SSR } from '@/lib/constants'
import { getGetServerSideProps } from '@/api/ssrApollo'
import Info from '@/components/info'
import Text from '@/components/text'
// force SSR to include CSP nonces
export const getServerSideProps = getGetServerSideProps({ query: null })
@ -17,8 +19,8 @@ function InviteForm () {
const [createInvite] = useMutation(
gql`
${INVITE_FIELDS}
mutation createInvite($gift: Int!, $limit: Int) {
createInvite(gift: $gift, limit: $limit) {
mutation createInvite($id: String, $gift: Int!, $limit: Int, $description: String) {
createInvite(id: $id, gift: $gift, limit: $limit, description: $description) {
...InviteFields
}
}`, {
@ -39,20 +41,28 @@ function InviteForm () {
}
)
const initialValues = {
id: '',
gift: 1000,
limit: 1,
description: ''
}
return (
<Form
initial={{
gift: 100,
limit: 1
}}
initial={initialValues}
schema={inviteSchema}
onSubmit={async ({ limit, gift }) => {
onSubmit={async ({ id, gift, limit, description }, { resetForm }) => {
const { error } = await createInvite({
variables: {
gift: Number(gift), limit: limit ? Number(limit) : limit
id: id || undefined,
gift: Number(gift),
limit: limit ? Number(limit) : limit,
description: description || undefined
}
})
if (error) throw error
resetForm({ values: initialValues })
}}
>
<Input
@ -65,8 +75,40 @@ function InviteForm () {
label={<>invitee limit <small className='text-muted ms-2'>optional</small></>}
name='limit'
/>
<SubmitButton variant='secondary'>create</SubmitButton>
<AccordianItem
headerColor='#6c757d' header='advanced' body={
<>
<Input
prepend={<InputGroup.Text className='text-muted'>{`${process.env.NEXT_PUBLIC_URL}/invites/`}</InputGroup.Text>}
label={<>invite code <small className='text-muted ms-2'>optional</small></>}
name='id'
autoComplete='off'
/>
<Input
label={
<>
<div className='d-flex align-items-center'>
description <small className='text-muted ms-2'>optional</small>
<Info>
<Text>
A brief description to keep track of the invite purpose, such as "Shared in group chat".
This description is private and visible only to you.
</Text>
</Info>
</div>
</>
}
name='description'
autoComplete='off'
/>
</>
}
/>
<SubmitButton
className='mt-4'
variant='secondary'
>create
</SubmitButton>
</Form>
)
}

View File

@ -12,7 +12,7 @@ export default function FullInvoice () {
return (
<CenterLayout>
<Invoice id={router.query.id} query={INVOICE_FULL} poll description status='loading' bolt11Info useWallet={false} />
<Invoice id={router.query.id} query={INVOICE_FULL} poll description status='loading' bolt11Info />
</CenterLayout>
)
}

View File

@ -12,6 +12,7 @@ import { useRouter } from 'next/router'
import PageLoading from '@/components/page-loading'
import { FeeButtonProvider } from '@/components/fee-button'
import SubSelect from '@/components/sub-select'
import useCanEdit from '@/components/use-can-edit'
export const getServerSideProps = getGetServerSideProps({
query: ITEM,
@ -26,7 +27,7 @@ export default function PostEdit ({ ssrData }) {
const { item } = data || ssrData
const [sub, setSub] = useState(item.subName)
const editThreshold = new Date(item?.invoice?.confirmedAt ?? item.createdAt).getTime() + 10 * 60000
const [,, editThreshold] = useCanEdit(item)
let FormType = DiscussionForm
let itemType = 'DISCUSSION'

View File

@ -9,8 +9,21 @@ import { useQuery } from '@apollo/client'
import PageLoading from '@/components/page-loading'
const staticVariables = { sort: 'recent' }
const variablesFunc = vars =>
({ includeComments: COMMENT_TYPE_QUERY.includes(vars.type), ...staticVariables, ...vars })
function variablesFunc (vars) {
let type = vars?.type || ''
if (type === 'bounties' && vars?.active) {
type = 'bounties_active'
}
return ({
includeComments: COMMENT_TYPE_QUERY.includes(vars.type),
...staticVariables,
...vars,
type
})
}
export const getServerSideProps = getGetServerSideProps({
query: SUB_ITEMS,

View File

@ -0,0 +1,2 @@
-- AlterTable
ALTER TABLE "Invite" ADD COLUMN "description" TEXT;

View File

@ -477,6 +477,8 @@ model Invite {
user User @relation("Invites", fields: [userId], references: [id], onDelete: Cascade)
invitees User[]
description String?
@@index([createdAt], map: "Invite.created_at_index")
@@index([userId], map: "Invite.userId_index")
}

View File

@ -27,6 +27,10 @@ export function getStorageKey (name, userId) {
return storageKey
}
export function walletTag (walletDef) {
return walletDef.shortName || walletDef.name
}
export function walletPrioritySort (w1, w2) {
// enabled/configured wallets always come before disabled/unconfigured wallets
if ((w1.config?.enabled && !w2.config?.enabled) || (isConfigured(w1) && !isConfigured(w2))) {

View File

@ -5,7 +5,7 @@ import { canReceive, canSend, getStorageKey, saveWalletLocally, siftConfig, upse
import { useMutation } from '@apollo/client'
import { generateMutation } from './graphql'
import { REMOVE_WALLET } from '@/fragments/wallet'
import { useWalletLogger } from '@/components/wallet-logger'
import { useWalletLogger } from '@/wallets/logger'
import { useWallets } from '.'
import validateWallet from './validate'
@ -13,7 +13,7 @@ export function useWalletConfigurator (wallet) {
const { me } = useMe()
const { reloadLocalWallets } = useWallets()
const { encrypt, isActive } = useVault()
const { logger } = useWalletLogger(wallet?.def)
const logger = useWalletLogger(wallet)
const [upsertWallet] = useMutation(generateMutation(wallet?.def))
const [removeWallet] = useMutation(REMOVE_WALLET)
@ -59,7 +59,7 @@ export function useWalletConfigurator (wallet) {
}
return { clientConfig, serverConfig }
}, [wallet])
}, [wallet, logger])
const _detachFromServer = useCallback(async () => {
await removeWallet({ variables: { id: wallet.config.id } })

View File

@ -1,22 +1,87 @@
export class InvoiceCanceledError extends Error {
constructor (hash, actionError) {
super(actionError ?? `invoice canceled: ${hash}`)
constructor (invoice, actionError) {
super(actionError ?? `invoice canceled: ${invoice.hash}`)
this.name = 'InvoiceCanceledError'
this.hash = hash
this.invoice = invoice
this.actionError = actionError
}
}
export class NoAttachedWalletError extends Error {
constructor () {
super('no attached wallet found')
this.name = 'NoAttachedWalletError'
export class InvoiceExpiredError extends Error {
constructor (invoice) {
super(`invoice expired: ${invoice.hash}`)
this.name = 'InvoiceExpiredError'
this.invoice = invoice
}
}
export class InvoiceExpiredError extends Error {
constructor (hash) {
super(`invoice expired: ${hash}`)
this.name = 'InvoiceExpiredError'
export class WalletError extends Error {}
export class WalletPaymentError extends WalletError {}
export class WalletConfigurationError extends WalletError {}
export class WalletNotEnabledError extends WalletConfigurationError {
constructor (name) {
super(`wallet is not enabled: ${name}`)
this.name = 'WalletNotEnabledError'
this.wallet = name
this.reason = 'wallet is not enabled'
}
}
export class WalletSendNotConfiguredError extends WalletConfigurationError {
constructor (name) {
super(`wallet send is not configured: ${name}`)
this.name = 'WalletSendNotConfiguredError'
this.wallet = name
this.reason = 'wallet send is not configured'
}
}
export class WalletSenderError extends WalletPaymentError {
constructor (name, invoice, message) {
super(`${name} failed to pay invoice ${invoice.hash}: ${message}`)
this.name = 'WalletSenderError'
this.wallet = name
this.invoice = invoice
this.reason = message
}
}
export class WalletsNotAvailableError extends WalletConfigurationError {
constructor () {
super('no wallet available')
this.name = 'WalletsNotAvailableError'
}
}
export class WalletAggregateError extends WalletError {
constructor (errors, invoice) {
super('WalletAggregateError')
this.name = 'WalletAggregateError'
this.errors = errors.reduce((acc, e) => {
if (Array.isArray(e?.errors)) {
acc.push(...e.errors)
} else {
acc.push(e)
}
return acc
}, [])
this.invoice = invoice
}
}
export class WalletPaymentAggregateError extends WalletPaymentError {
constructor (errors, invoice) {
super('WalletPaymentAggregateError')
this.name = 'WalletPaymentAggregateError'
this.errors = errors.reduce((acc, e) => {
if (Array.isArray(e?.errors)) {
acc.push(...e.errors)
} else {
acc.push(e)
}
return acc
}, []).filter(e => e instanceof WalletPaymentError)
this.invoice = invoice
}
}

View File

@ -5,11 +5,8 @@ import { useApolloClient, useMutation, useQuery } from '@apollo/client'
import { createContext, useCallback, useContext, useEffect, useMemo, useState } from 'react'
import { getStorageKey, getWalletByType, walletPrioritySort, canSend, isConfigured, upsertWalletVariables, siftConfig, saveWalletLocally } from './common'
import useVault from '@/components/vault/use-vault'
import { useWalletLogger } from '@/components/wallet-logger'
import { decode as bolt11Decode } from 'bolt11'
import walletDefs from '@/wallets/client'
import { generateMutation } from './graphql'
import { formatSats } from '@/lib/format'
const WalletsContext = createContext({
wallets: []
@ -128,7 +125,7 @@ export function WalletsProvider ({ children }) {
}
}
// sort by priority, then add status field
// sort by priority
return Object.values(merged).sort(walletPrioritySort)
}, [serverWallets, localWallets])
@ -218,34 +215,13 @@ export function useWallets () {
export function useWallet (name) {
const { wallets } = useWallets()
const wallet = useMemo(() => {
if (name) {
return wallets.find(w => w.def.name === name)
}
// return the first enabled wallet that is available and can send
return wallets
.filter(w => !w.def.isAvailable || w.def.isAvailable())
.filter(w => w.config?.enabled && canSend(w))[0]
}, [wallets, name])
const { logger } = useWalletLogger(wallet?.def)
const sendPayment = useCallback(async (bolt11) => {
const decoded = bolt11Decode(bolt11)
logger.info(`↗ sending payment: ${formatSats(decoded.satoshis)}`, { bolt11 })
try {
const preimage = await wallet.def.sendPayment(bolt11, wallet.config, { logger })
logger.ok(`↗ payment sent: ${formatSats(decoded.satoshis)}`, { bolt11, preimage })
} catch (err) {
const message = err.message || err.toString?.()
logger.error(`payment failed: ${message}`, { bolt11 })
throw err
}
}, [wallet, logger])
if (!wallet) return null
return { ...wallet, sendPayment }
return wallets.find(w => w.def.name === name)
}
export function useSendWallets () {
const { wallets } = useWallets()
// return the first enabled wallet that is available and can send
return wallets
.filter(w => !w.def.isAvailable || w.def.isAvailable())
.filter(w => w.config?.enabled && canSend(w))
}

View File

@ -1,5 +1,3 @@
import { InvoiceCanceledError, InvoiceExpiredError } from '@/wallets/errors'
import { bolt11Tags } from '@/lib/bolt11'
import { Mutex } from 'async-mutex'
export * from '@/wallets/lnc'
@ -15,20 +13,12 @@ export async function testSendPayment (credentials, { logger }) {
}
export async function sendPayment (bolt11, credentials, { logger }) {
const hash = bolt11Tags(bolt11).payment_hash
return await mutex.runExclusive(async () => {
try {
const lnc = await getLNC(credentials, { logger })
const { paymentError, paymentPreimage: preimage } = await lnc.lnd.lightning.sendPaymentSync({ payment_request: bolt11 })
if (paymentError) throw new Error(paymentError)
if (!preimage) throw new Error('No preimage in response')
return preimage
} catch (err) {
const msg = err.message || err.toString?.()
if (msg.includes('invoice expired')) throw new InvoiceExpiredError(hash)
if (msg.includes('canceled')) throw new InvoiceCanceledError(hash)
throw err
}
const lnc = await getLNC(credentials, { logger })
const { paymentError, paymentPreimage: preimage } = await lnc.lnd.lightning.sendPaymentSync({ payment_request: bolt11 })
if (paymentError) throw new Error(paymentError)
if (!preimage) throw new Error('No preimage in response')
return preimage
})
}

45
wallets/logger.js Normal file
View File

@ -0,0 +1,45 @@
import { useCallback } from 'react'
import { decode as bolt11Decode } from 'bolt11'
import { formatMsats } from '@/lib/format'
import { walletTag } from '@/wallets/common'
import { useWalletLogManager } from '@/components/wallet-logger'
export function useWalletLoggerFactory () {
const { appendLog } = useWalletLogManager()
const log = useCallback((wallet, level) => (message, context = {}) => {
if (!wallet) {
return
}
if (context?.bolt11) {
// automatically populate context from bolt11 to avoid duplicating this code
const decoded = bolt11Decode(context.bolt11)
context = {
...context,
amount: formatMsats(decoded.millisatoshis),
payment_hash: decoded.tagsObject.payment_hash,
description: decoded.tagsObject.description,
created_at: new Date(decoded.timestamp * 1000).toISOString(),
expires_at: new Date(decoded.timeExpireDate * 1000).toISOString(),
// payments should affect wallet status
status: true
}
}
context.send = true
appendLog(wallet, level, message, context)
console[level !== 'error' ? 'info' : 'error'](`[${walletTag(wallet.def)}]`, message)
}, [appendLog])
return useCallback(wallet => ({
ok: (message, context) => log(wallet, 'ok')(message, context),
info: (message, context) => log(wallet, 'info')(message, context),
error: (message, context) => log(wallet, 'error')(message, context)
}), [log])
}
export function useWalletLogger (wallet) {
const factory = useWalletLoggerFactory()
return factory(wallet)
}

139
wallets/payment.js Normal file
View File

@ -0,0 +1,139 @@
import { useCallback } from 'react'
import { useSendWallets } from '@/wallets'
import { formatSats } from '@/lib/format'
import { useInvoice } from '@/components/payment'
import { FAST_POLL_INTERVAL } from '@/lib/constants'
import {
WalletsNotAvailableError, WalletSenderError, WalletAggregateError, WalletPaymentAggregateError,
WalletNotEnabledError, WalletSendNotConfiguredError, WalletPaymentError, WalletError
} from '@/wallets/errors'
import { canSend } from './common'
import { useWalletLoggerFactory } from './logger'
export function useWalletPayment () {
const wallets = useSendWallets()
const sendPayment = useSendPayment()
const invoiceHelper = useInvoice()
return useCallback(async (invoice, { waitFor, updateOnFallback }) => {
let aggregateError = new WalletAggregateError([])
let latestInvoice = invoice
// throw a special error that caller can handle separately if no payment was attempted
if (wallets.length === 0) {
throw new WalletsNotAvailableError()
}
for (const [i, wallet] of wallets.entries()) {
const controller = invoiceController(latestInvoice, invoiceHelper.isInvoice)
try {
return await new Promise((resolve, reject) => {
// can't await wallet payments since we might pay hold invoices and thus payments might not settle immediately.
// that's why we separately check if we received the payment with the invoice controller.
sendPayment(wallet, latestInvoice).catch(reject)
controller.wait(waitFor)
.then(resolve)
.catch(reject)
})
} catch (err) {
// cancel invoice to make sure it cannot be paid later and create new invoice to retry.
// we only need to do this if payment was attempted which is not the case if the wallet is not enabled.
if (err instanceof WalletPaymentError) {
await invoiceHelper.cancel(latestInvoice)
// is there another wallet to try?
const lastAttempt = i === wallets.length - 1
if (!lastAttempt) {
latestInvoice = await invoiceHelper.retry(latestInvoice, { update: updateOnFallback })
}
}
// TODO: receiver fallbacks
//
// if payment failed because of the receiver, we should use the same wallet again.
// if (err instanceof ReceiverError) { ... }
// try next wallet if the payment failed because of the wallet
// and not because it expired or was canceled
if (err instanceof WalletError) {
aggregateError = new WalletAggregateError([aggregateError, err], latestInvoice)
continue
}
// payment failed not because of the sender or receiver wallet. bail out of attemping wallets.
throw err
} finally {
controller.stop()
}
}
// if we reach this line, no wallet payment succeeded
throw new WalletPaymentAggregateError([aggregateError], latestInvoice)
}, [wallets, invoiceHelper, sendPayment])
}
function invoiceController (inv, isInvoice) {
const controller = new AbortController()
const signal = controller.signal
controller.wait = async (waitFor = inv => inv?.actionState === 'PAID') => {
return await new Promise((resolve, reject) => {
let updatedInvoice, paid
const interval = setInterval(async () => {
try {
({ invoice: updatedInvoice, check: paid } = await isInvoice(inv, waitFor))
if (paid) {
resolve(updatedInvoice)
clearInterval(interval)
signal.removeEventListener('abort', abort)
} else {
console.info(`invoice #${inv.id}: waiting for payment ...`)
}
} catch (err) {
reject(err)
clearInterval(interval)
signal.removeEventListener('abort', abort)
}
}, FAST_POLL_INTERVAL)
const abort = () => {
console.info(`invoice #${inv.id}: stopped waiting`)
resolve(updatedInvoice)
clearInterval(interval)
signal.removeEventListener('abort', abort)
}
signal.addEventListener('abort', abort)
})
}
controller.stop = () => controller.abort()
return controller
}
function useSendPayment () {
const factory = useWalletLoggerFactory()
return useCallback(async (wallet, invoice) => {
const logger = factory(wallet)
if (!wallet.config.enabled) {
throw new WalletNotEnabledError(wallet.def.name)
}
if (!canSend(wallet)) {
throw new WalletSendNotConfiguredError(wallet.def.name)
}
const { bolt11, satsRequested } = invoice
logger.info(`↗ sending payment: ${formatSats(satsRequested)}`, { bolt11 })
try {
const preimage = await wallet.def.sendPayment(bolt11, wallet.config, { logger })
logger.ok(`↗ payment sent: ${formatSats(satsRequested)}`, { bolt11, preimage })
} catch (err) {
const message = err.message || err.toString?.()
logger.error(`payment failed: ${message}`, { bolt11 })
throw new WalletSenderError(wallet.def.name, invoice, message)
}
}, [factory])
}