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}`) 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) { if (!failedInvoice) {
throw new Error(`retryPaidAction - missing invoice ${actionType}`) throw new Error(`retryPaidAction - missing invoice ${actionType}`)
} }
const { msatsRequested, actionId, actionArgs } = failedInvoice const { msatsRequested, actionId, actionArgs, actionOptimistic } = failedInvoice
const retryContext = { const retryContext = {
...incomingContext, ...incomingContext,
optimistic: true, optimistic: actionOptimistic,
me: await models.user.findUnique({ where: { id: me.id } }), me: await models.user.findUnique({ where: { id: me.id } }),
cost: BigInt(msatsRequested), cost: BigInt(msatsRequested),
actionId 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 => { return await models.$transaction(async tx => {
const context = { ...retryContext, tx, invoiceArgs } const context = { ...retryContext, tx, invoiceArgs }
@ -345,9 +360,9 @@ export async function retryPaidAction (actionType, args, incomingContext) {
const invoice = await createDbInvoice(actionType, actionArgs, context) const invoice = await createDbInvoice(actionType, actionArgs, context)
return { return {
result: await action.retry({ invoiceId: failedInvoice.id, newInvoiceId: invoice.id }, context), result: await action.retry?.({ invoiceId: failedInvoice.id, newInvoiceId: invoice.id }, context),
invoice, invoice,
paymentMethod: 'OPTIMISTIC' paymentMethod: actionOptimistic ? 'OPTIMISTIC' : 'PESSIMISTIC'
} }
}, { isolationLevel: Prisma.TransactionIsolationLevel.ReadCommitted }) }, { isolationLevel: Prisma.TransactionIsolationLevel.ReadCommitted })
} }

View File

@ -1,7 +1,8 @@
import { inviteSchema, validateSchema } from '@/lib/validate' import { inviteSchema, validateSchema } from '@/lib/validate'
import { msatsToSats } from '@/lib/format' import { msatsToSats } from '@/lib/format'
import assertApiKeyNotPermitted from './apiKey' import assertApiKeyNotPermitted from './apiKey'
import { GqlAuthenticationError } from '@/lib/error' import { GqlAuthenticationError, GqlInputError } from '@/lib/error'
import { Prisma } from '@prisma/client'
export default { export default {
Query: { Query: {
@ -9,7 +10,6 @@ export default {
if (!me) { if (!me) {
throw new GqlAuthenticationError() throw new GqlAuthenticationError()
} }
return await models.invite.findMany({ return await models.invite.findMany({
where: { where: {
userId: me.id userId: me.id
@ -29,27 +29,48 @@ export default {
}, },
Mutation: { Mutation: {
createInvite: async (parent, { gift, limit }, { me, models }) => { createInvite: async (parent, { id, gift, limit, description }, { me, models }) => {
if (!me) { if (!me) {
throw new GqlAuthenticationError() throw new GqlAuthenticationError()
} }
assertApiKeyNotPermitted({ me }) assertApiKeyNotPermitted({ me })
await validateSchema(inviteSchema, { gift, limit }) await validateSchema(inviteSchema, { id, gift, limit, description })
try {
return await models.invite.create({ return await models.invite.create({
data: { gift, limit, userId: me.id } 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 }) => { revokeInvite: async (parent, { id }, { me, models }) => {
if (!me) { if (!me) {
throw new GqlAuthenticationError() throw new GqlAuthenticationError()
} }
return await models.invite.update({ try {
where: { id }, return await models.invite.update({
data: { revoked: true } 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 }) => { poor: async (invite, args, { me, models }) => {
const user = await models.user.findUnique({ where: { id: invite.userId } }) const user = await models.user.findUnique({ where: { id: invite.userId } })
return msatsToSats(user.msats) < invite.gift 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'] return ['"Item".bio = true', '"Item"."parentId" IS NULL']
case 'bounties': case 'bounties':
return ['"Item".bounty IS NOT NULL', '"Item"."parentId" IS NULL'] 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': case 'comments':
return '"Item"."parentId" IS NOT NULL' return '"Item"."parentId" IS NOT NULL'
case 'freebies': case 'freebies':
@ -423,6 +425,7 @@ export default {
subClause(sub, 5, subClauseTable(type), me, showNsfw), subClause(sub, 5, subClauseTable(type), me, showNsfw),
typeClause(type), typeClause(type),
whenClause(when, 'Item'), whenClause(when, 'Item'),
activeOrMine(me),
await filterClause(me, models, type), await filterClause(me, models, type),
by === 'boost' && '"Item".boost > 0', by === 'boost' && '"Item".boost > 0',
muteClause(me))} muteClause(me))}

View File

@ -13,6 +13,8 @@ import { BLOCK_HEIGHT } from '@/fragments/blockHeight'
import { CHAIN_FEE } from '@/fragments/chainFee' import { CHAIN_FEE } from '@/fragments/chainFee'
import { getServerSession } from 'next-auth/next' import { getServerSession } from 'next-auth/next'
import { getAuthOptions } from '@/pages/api/auth/[...nextauth]' 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 }) { export default async function getSSRApolloClient ({ req, res, me = null }) {
const session = req && await getServerSession(req, res, getAuthOptions(req)) const session = req && await getServerSession(req, res, getAuthOptions(req))
@ -64,7 +66,17 @@ function oneDayReferral (request, { me }) {
let prismaPromise, getData let prismaPromise, getData
if (referrer.startsWith('item-')) { 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 => ({ getData = item => ({
referrerId: item.userId, referrerId: item.userId,
refereeId: parseInt(me.id), refereeId: parseInt(me.id),

View File

@ -7,7 +7,7 @@ export default gql`
} }
extend type Mutation { extend type Mutation {
createInvite(gift: Int!, limit: Int): Invite createInvite(id: String, gift: Int!, limit: Int, description: String): Invite
revokeInvite(id: ID!): Invite revokeInvite(id: ID!): Invite
} }
@ -20,5 +20,6 @@ export default gql`
user: User! user: User!
revoked: Boolean! revoked: Boolean!
poor: 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) if (onSelect) await onSelect?.(file, s3Upload)
else await s3Upload(file) else await s3Upload(file)
} catch (e) { } 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 continue
} }
} }

View File

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

View File

@ -8,7 +8,7 @@ import Bolt11Info from './bolt11-info'
import { useQuery } from '@apollo/client' import { useQuery } from '@apollo/client'
import { INVOICE } from '@/fragments/wallet' import { INVOICE } from '@/fragments/wallet'
import { FAST_POLL_INTERVAL, SSR } from '@/lib/constants' 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 ItemJob from './item-job'
import Item from './item' import Item from './item'
import { CommentFlat } from './comment' import { CommentFlat } from './comment'
@ -19,7 +19,7 @@ import styles from './invoice.module.css'
export default function Invoice ({ export default function Invoice ({
id, query = INVOICE, modal, onPayment, onExpired, onCanceled, info, successVerb = 'deposited', 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 const { data, error } = useQuery(query, SSR
? {} ? {}
@ -79,15 +79,12 @@ export default function Invoice ({
{invoice.forwardedSats && <Badge className={styles.badge} bg={null}>p2p</Badge>} {invoice.forwardedSats && <Badge className={styles.badge} bg={null}>p2p</Badge>}
</> </>
) )
useWallet = false
} else if (expired) { } else if (expired) {
variant = 'failed' variant = 'failed'
status = 'expired' status = 'expired'
useWallet = false
} else if (invoice.cancelled) { } else if (invoice.cancelled) {
variant = 'failed' variant = 'failed'
status = 'cancelled' status = 'cancelled'
useWallet = false
} else if (invoice.isHeld) { } else if (invoice.isHeld) {
variant = 'pending' variant = 'pending'
status = ( status = (
@ -95,7 +92,6 @@ export default function Invoice ({
<Moon className='spin fill-grey me-2' /> {heldVerb} <Moon className='spin fill-grey me-2' /> {heldVerb}
</div> </div>
) )
useWallet = false
} else { } else {
variant = 'pending' variant = 'pending'
status = ( status = (
@ -107,13 +103,9 @@ export default function Invoice ({
return ( return (
<> <>
{walletError && !(walletError instanceof NoAttachedWalletError) && <WalletError error={walletError} />
<div className='text-center fw-bold text-info mb-3' style={{ lineHeight: 1.25 }}>
Paying from attached wallet failed:
<code> {walletError.message}</code>
</div>}
<Qr <Qr
useWallet={useWallet} value={invoice.bolt11} value={invoice.bolt11}
description={numWithUnits(invoice.satsRequested, { abbreviate: false })} description={numWithUnits(invoice.satsRequested, { abbreviate: false })}
statusVariant={variant} status={status} statusVariant={variant} status={status}
/> />
@ -209,3 +201,23 @@ function ActionInfo ({ invoice }) {
</div> </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 { ACT_MUTATION } from '@/fragments/paidAction'
import { meAnonSats } from '@/lib/apollo' import { meAnonSats } from '@/lib/apollo'
import { BoostItemInput } from './adv-post-form' import { BoostItemInput } from './adv-post-form'
import { useWallet } from '@/wallets/index' import { useSendWallets } from '@/wallets/index'
const defaultTips = [100, 1000, 10_000, 100_000] 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 }) { export default function ItemAct ({ onClose, item, act = 'TIP', step, children, abortSignal }) {
const inputRef = useRef(null) const inputRef = useRef(null)
const { me } = useMe() const { me } = useMe()
const wallet = useWallet() const wallets = useSendWallets()
const [oValue, setOValue] = useState() const [oValue, setOValue] = useState()
useEffect(() => { useEffect(() => {
@ -117,7 +117,7 @@ export default function ItemAct ({ onClose, item, act = 'TIP', step, children, a
if (!me) setItemMeAnonSats({ id: item.id, amount }) 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) { if (closeImmediately) {
onPaid() onPaid()
} }
@ -143,7 +143,7 @@ export default function ItemAct ({ onClose, item, act = 'TIP', step, children, a
}) })
if (error) throw error if (error) throw error
addCustomTip(Number(amount)) 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' return act === 'BOOST'
? <BoostForm step={step} onSubmit={onSubmit} item={item} inputRef={inputRef} act={act}>{children}</BoostForm> ? <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 () { export function useZap () {
const wallet = useWallet() const wallets = useSendWallets()
const act = useAct() const act = useAct()
const strike = useLightning() const strike = useLightning()
const toaster = useToast() const toaster = useToast()
@ -278,17 +278,18 @@ export function useZap () {
await abortSignal.pause({ me, amount: sats }) await abortSignal.pause({ me, amount: sats })
strike() strike()
// batch zaps if wallet is enabled or using fee credits so they can be executed serially in a single request // 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 if (error) throw error
} catch (error) { } catch (error) {
if (error instanceof ActCanceledError) { if (error instanceof ActCanceledError) {
return return
} }
const reason = error?.message || error?.toString?.() // TODO: we should selectively toast based on error type
toaster.danger(reason) // 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 { export class ActCanceledError extends Error {

View File

@ -6,7 +6,7 @@ import Dropdown from 'react-bootstrap/Dropdown'
import Countdown from './countdown' import Countdown from './countdown'
import { abbrNum, numWithUnits } from '@/lib/format' import { abbrNum, numWithUnits } from '@/lib/format'
import { newComments, commentsViewedAt } from '@/lib/new-comments' import { newComments, commentsViewedAt } from '@/lib/new-comments'
import { datePivot, timeSince } from '@/lib/time' import { timeSince } from '@/lib/time'
import { DeleteDropdownItem } from './delete' import { DeleteDropdownItem } from './delete'
import styles from './item.module.css' import styles from './item.module.css'
import { useMe } from './me' import { useMe } from './me'
@ -27,6 +27,8 @@ import { useRetryCreateItem } from './use-item-submit'
import { useToast } from './toast' import { useToast } from './toast'
import { useShowModal } from './modal' import { useShowModal } from './modal'
import classNames from 'classnames' import classNames from 'classnames'
import SubPopover from './sub-popover'
import useCanEdit from './use-can-edit'
export default function ItemInfo ({ export default function ItemInfo ({
item, full, commentsText = 'comments', item, full, commentsText = 'comments',
@ -34,12 +36,12 @@ export default function ItemInfo ({
onQuoteReply, extraBadges, nested, pinnable, showActionDropdown = true, showUser = true, onQuoteReply, extraBadges, nested, pinnable, showActionDropdown = true, showUser = true,
setDisableRetry, disableRetry setDisableRetry, disableRetry
}) { }) {
const editThreshold = datePivot(new Date(item.invoice?.confirmedAt ?? item.createdAt), { minutes: 10 })
const { me } = useMe() const { me } = useMe()
const router = useRouter() const router = useRouter()
const [hasNewComments, setHasNewComments] = useState(false) const [hasNewComments, setHasNewComments] = useState(false)
const root = useRoot() const root = useRoot()
const sub = item?.sub || root?.sub const sub = item?.sub || root?.sub
const [canEdit, setCanEdit, editThreshold] = useCanEdit(item)
useEffect(() => { useEffect(() => {
if (!full) { if (!full) {
@ -47,19 +49,6 @@ export default function ItemInfo ({
} }
}, [item]) }, [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 // territory founders can pin any post in their territory
// and OPs can pin any root reply in their post // and OPs can pin any root reply in their post
const isPost = !item.parentId const isPost = !item.parentId
@ -134,9 +123,11 @@ export default function ItemInfo ({
</>} </>}
</span> </span>
{item.subName && {item.subName &&
<Link href={`/~${item.subName}`}> <SubPopover sub={item.subName}>
{' '}<Badge className={styles.newComment} bg={null}>{item.subName}</Badge> <Link href={`/~${item.subName}`}>
</Link>} {' '}<Badge className={styles.newComment} bg={null}>{item.subName}</Badge>
</Link>
</SubPopover>}
{sub?.nsfw && {sub?.nsfw &&
<Badge className={styles.newComment} bg={null}>nsfw</Badge>} <Badge className={styles.newComment} bg={null}>nsfw</Badge>}
{(item.outlawed && !item.mine && {(item.outlawed && !item.mine &&
@ -157,7 +148,7 @@ export default function ItemInfo ({
<> <>
<EditInfo <EditInfo
item={item} edit={edit} canEdit={canEdit} 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} /> <PaymentInfo item={item} disableRetry={disableRetry} setDisableRetry={setDisableRetry} />
<ActionDropdown> <ActionDropdown>

View File

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

View File

@ -283,7 +283,7 @@ function Invitification ({ n }) {
<> <>
<NoteHeader color='secondary'> <NoteHeader color='secondary'>
your invite has been redeemed by your invite has been redeemed by
{numWithUnits(n.invite.invitees.length, { {' ' + numWithUnits(n.invite.invitees.length, {
abbreviate: false, abbreviate: false,
unitSingular: 'stacker', unitSingular: 'stacker',
unitPlural: 'stackers' unitPlural: 'stackers'
@ -370,6 +370,29 @@ function useActRetry ({ invoice }) {
invoice.item.root?.bounty === invoice.satsRequested && invoice.item.root?.mine invoice.item.root?.bounty === invoice.satsRequested && invoice.item.root?.mine
? payBountyCacheMods ? 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({ return useAct({
query: RETRY_PAID_ACTION, query: RETRY_PAID_ACTION,
onPayError: (e, cache, { data }) => { onPayError: (e, cache, { data }) => {
@ -380,27 +403,8 @@ function useActRetry ({ invoice }) {
paidActionCacheMods?.onPaid?.(cache, { data }) paidActionCacheMods?.onPaid?.(cache, { data })
bountyCacheMods?.onPaid?.(cache, { data }) bountyCacheMods?.onPaid?.(cache, { data })
}, },
update: (cache, { data }) => { update,
const response = Object.values(data)[0] updateOnFallback: update
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 })
}
}) })
} }

View File

@ -1,22 +1,16 @@
import { useCallback } from 'react' import { useCallback } from 'react'
import { gql, useApolloClient, useMutation } from '@apollo/client' import { useApolloClient, useMutation } from '@apollo/client'
import { useWallet } from '@/wallets/index' import { CANCEL_INVOICE, INVOICE } from '@/fragments/wallet'
import { FAST_POLL_INTERVAL } from '@/lib/constants'
import { INVOICE } from '@/fragments/wallet'
import Invoice from '@/components/invoice' import Invoice from '@/components/invoice'
import { useShowModal } from './modal' 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 = () => { export const useInvoice = () => {
const client = useApolloClient() const client = useApolloClient()
const [retryPaidAction] = useMutation(RETRY_PAID_ACTION)
const [cancelInvoice] = useMutation(gql` const [cancelInvoice] = useMutation(CANCEL_INVOICE)
mutation cancelInvoice($hash: String!, $hmac: String!) {
cancelInvoice(hash: $hash, hmac: $hmac) {
id
}
}
`)
const isInvoice = useCallback(async ({ id }, that) => { const isInvoice = useCallback(async ({ id }, that) => {
const { data, error } = await client.query({ query: INVOICE, fetchPolicy: 'network-only', variables: { id } }) const { data, error } = await client.query({ query: INVOICE, fetchPolicy: 'network-only', variables: { id } })
@ -24,15 +18,15 @@ export const useInvoice = () => {
throw error 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) const expired = cancelledAt && new Date(expiresAt) < new Date(cancelledAt)
if (expired) { if (expired) {
throw new InvoiceExpiredError(hash) throw new InvoiceExpiredError(data.invoice)
} }
if (cancelled || actionError) { if (cancelled || actionError) {
throw new InvoiceCanceledError(hash, actionError) throw new InvoiceCanceledError(data.invoice, actionError)
} }
// write to cache if paid // write to cache if paid
@ -40,7 +34,7 @@ export const useInvoice = () => {
client.writeQuery({ query: INVOICE, variables: { id }, data: { invoice: data.invoice } }) client.writeQuery({ query: INVOICE, variables: { id }, data: { invoice: data.invoice } })
} }
return that(data.invoice) return { invoice: data.invoice, check: that(data.invoice) }
}, [client]) }, [client])
const cancel = useCallback(async ({ hash, hmac }) => { const cancel = useCallback(async ({ hash, hmac }) => {
@ -49,77 +43,22 @@ export const useInvoice = () => {
} }
console.log('canceling invoice:', hash) console.log('canceling invoice:', hash)
const inv = await cancelInvoice({ variables: { hash, hmac } }) const { data } = await cancelInvoice({ variables: { hash, hmac } })
return inv return data.cancelInvoice
}, [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 newInvoice = data.retryPaidAction.invoice
const controller = new AbortController() console.log('new invoice:', newInvoice?.hash)
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 abort = () => { return newInvoice
console.info(`invoice #${id}: stopped waiting`) }, [retryPaidAction])
resolve()
clearInterval(interval)
signal.removeEventListener('abort', abort)
}
signal.addEventListener('abort', abort)
})
}
controller.stop = () => controller.abort() return { cancel, retry, isInvoice }
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
} }
export const useQrPayment = () => { export const useQrPayment = () => {
@ -138,10 +77,10 @@ export const useQrPayment = () => {
let paid let paid
const cancelAndReject = async (onClose) => { const cancelAndReject = async (onClose) => {
if (!paid && cancelOnClose) { if (!paid && cancelOnClose) {
await invoice.cancel(inv).catch(console.error) const updatedInv = await invoice.cancel(inv).catch(console.error)
reject(new InvoiceCanceledError(inv?.hash)) reject(new InvoiceCanceledError(updatedInv))
} }
resolve() resolve(inv)
} }
showModal(onClose => showModal(onClose =>
<Invoice <Invoice
@ -150,12 +89,11 @@ export const useQrPayment = () => {
description description
status='loading' status='loading'
successVerb='received' successVerb='received'
useWallet={false}
walletError={walletError} walletError={walletError}
waitFor={waitFor} waitFor={waitFor}
onExpired={inv => reject(new InvoiceExpiredError(inv?.hash))} onExpired={inv => reject(new InvoiceExpiredError(inv))}
onCanceled={inv => { onClose(); reject(new InvoiceCanceledError(inv?.hash, inv?.actionError)) }} onCanceled={inv => { onClose(); reject(new InvoiceCanceledError(inv, inv?.actionError)) }}
onPayment={() => { paid = true; onClose(); resolve() }} onPayment={(inv) => { paid = true; onClose(); resolve(inv) }}
poll poll
/>, />,
{ keepOpen, persistOnNavigate, onClose: cancelAndReject }) { keepOpen, persistOnNavigate, onClose: cancelAndReject })

View File

@ -1,8 +1,6 @@
import { QRCodeSVG } from 'qrcode.react' import { QRCodeSVG } from 'qrcode.react'
import { CopyInput, InputSkeleton } from './form' import { CopyInput, InputSkeleton } from './form'
import InvoiceStatus from './invoice-status' import InvoiceStatus from './invoice-status'
import { useEffect } from 'react'
import { useWallet } from '@/wallets/index'
import Bolt11Info from './bolt11-info' import Bolt11Info from './bolt11-info'
export const qrImageSettings = { export const qrImageSettings = {
@ -14,22 +12,8 @@ export const qrImageSettings = {
excavate: true 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 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 ( return (
<> <>

View File

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

View File

@ -113,7 +113,7 @@ export default forwardRef(function Reply ({
} }
}, },
onSuccessfulSubmit: (data, { resetForm }) => { onSuccessfulSubmit: (data, { resetForm }) => {
resetForm({ text: '' }) resetForm({ values: { text: '' } })
setReply(replyOpen || false) setReply(replyOpen || false)
}, },
navigateOnSubmit: 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 { useRouter } from 'next/router'
import { Select } from './form' import { Select } from './form'
import { EXTRA_LONG_POLL_INTERVAL, SSR } from '@/lib/constants' import { EXTRA_LONG_POLL_INTERVAL, SSR } from '@/lib/constants'
import { SUBS } from '@/fragments/subs' import { SUBS } from '@/fragments/subs'
import { useQuery } from '@apollo/client' import { useQuery } from '@apollo/client'
import { useEffect, useState } from 'react'
import styles from './sub-select.module.css' import styles from './sub-select.module.css'
import { useMe } from './me'
export function SubSelectInitial ({ sub }) { export function SubSelectInitial ({ sub }) {
const router = useRouter() const router = useRouter()
@ -20,19 +21,27 @@ const DEFAULT_APPEND_SUBS = []
const DEFAULT_FILTER_SUBS = () => true const DEFAULT_FILTER_SUBS = () => true
export function useSubs ({ prependSubs = DEFAULT_PREPEND_SUBS, sub, filterSubs = DEFAULT_FILTER_SUBS, appendSubs = DEFAULT_APPEND_SUBS }) { 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, pollInterval: EXTRA_LONG_POLL_INTERVAL,
nextFetchPolicy: 'cache-and-network' nextFetchPolicy: 'cache-and-network'
}) })
const { me } = useMe()
useEffect(() => {
refetch()
}, [me?.privates?.nsfwMode])
const [subs, setSubs] = useState([ const [subs, setSubs] = useState([
...prependSubs.filter(s => s !== sub), ...prependSubs.filter(s => s !== sub),
...(sub ? [sub] : []), ...(sub ? [sub] : []),
...appendSubs.filter(s => s !== sub)]) ...appendSubs.filter(s => s !== sub)])
useEffect(() => { useEffect(() => {
if (!data) return if (!data) return
const joined = data.subs.filter(filterSubs).filter(s => !s.meMuteSub).map(s => s.name) 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 muted = data.subs.filter(filterSubs).filter(s => s.meMuteSub).map(s => s.name)
const mutedSection = muted.length ? [{ label: 'muted', items: muted }] : [] 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 }) { export function TerritoryInfo ({ sub }) {
return ( return (
<> <>

View File

@ -12,6 +12,7 @@ import { useRouter } from 'next/router'
import Link from 'next/link' import Link from 'next/link'
import { UNKNOWN_LINK_REL } from '@/lib/constants' import { UNKNOWN_LINK_REL } from '@/lib/constants'
import isEqual from 'lodash/isEqual' import isEqual from 'lodash/isEqual'
import SubPopover from './sub-popover'
import UserPopover from './user-popover' import UserPopover from './user-popover'
import ItemPopover from './item-popover' import ItemPopover from './item-popover'
import classNames from 'classnames' import classNames from 'classnames'
@ -183,8 +184,12 @@ function Mention ({ children, node, href, name, id }) {
) )
} }
function Sub ({ children, node, href, ...props }) { function Sub ({ children, node, href, name, ...props }) {
return <Link href={href}>{children}</Link> return (
<SubPopover sub={name}>
<Link href={href}>{children}</Link>
</SubPopover>
)
} }
function Item ({ children, node, href, id }) { 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 { useApolloClient, useLazyQuery, useMutation } from '@apollo/client'
import { useCallback, useState } from 'react' import { useCallback, useState } from 'react'
import { useInvoice, useQrPayment, useWalletPayment } from './payment' import { useInvoice, useQrPayment } from './payment'
import { InvoiceCanceledError, InvoiceExpiredError } from '@/wallets/errors' import { InvoiceCanceledError, InvoiceExpiredError, WalletError, WalletPaymentError } from '@/wallets/errors'
import { GET_PAID_ACTION } from '@/fragments/paidAction' import { GET_PAID_ACTION } from '@/fragments/paidAction'
import { useWalletPayment } from '@/wallets/payment'
/* /*
this is just like useMutation with a few changes: 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 // innerResult is used to store/control the result of the mutation when innerMutate runs
const [innerResult, setInnerResult] = useState(result) 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 walletError
let walletInvoice = invoice
const start = Date.now() const start = Date.now()
try { try {
return await waitForWalletPayment(invoice, waitFor) return await waitForWalletPayment(walletInvoice, { waitFor, updateOnFallback })
} catch (err) { } catch (err) {
if ( walletError = null
(!alwaysShowQROnFailure && Date.now() - start > 1000) || if (err instanceof WalletError) {
err instanceof InvoiceCanceledError || walletError = err
err instanceof InvoiceExpiredError) { // get the last invoice that was attempted but failed and was canceled
// bail since qr code payment will also fail if (err.invoice) walletInvoice = err.invoice
// 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) 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 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]) }, [waitForWalletPayment, waitForQrPayment, invoiceHelper])
const innerMutate = useCallback(async ({ const innerMutate = useCallback(async ({
@ -60,7 +77,7 @@ export function usePaidMutation (mutation,
// use the most inner callbacks/options if they exist // use the most inner callbacks/options if they exist
const { const {
onPaid, onPayError, forceWaitForPayment, persistOnNavigate, onPaid, onPayError, forceWaitForPayment, persistOnNavigate,
update, waitFor = inv => inv?.actionState === 'PAID' update, waitFor = inv => inv?.actionState === 'PAID', updateOnFallback
} = { ...options, ...innerOptions } } = { ...options, ...innerOptions }
const ourOnCompleted = innerOnCompleted || onCompleted const ourOnCompleted = innerOnCompleted || onCompleted
@ -69,7 +86,7 @@ export function usePaidMutation (mutation,
throw new Error('usePaidMutation: exactly one mutation at a time is supported') throw new Error('usePaidMutation: exactly one mutation at a time is supported')
} }
const response = Object.values(data)[0] const response = Object.values(data)[0]
const invoice = response?.invoice let invoice = response?.invoice
// if the mutation returns an invoice, pay it // if the mutation returns an invoice, pay it
if (invoice) { if (invoice) {
@ -81,15 +98,28 @@ export function usePaidMutation (mutation,
error: e instanceof InvoiceCanceledError && e.actionError ? e : undefined 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? // should we wait for the invoice to be paid?
if (response?.paymentMethod === 'OPTIMISTIC' && !forceWaitForPayment) { if (response?.paymentMethod === 'OPTIMISTIC' && !forceWaitForPayment) {
// onCompleted is called before the invoice is paid for optimistic updates // onCompleted is called before the invoice is paid for optimistic updates
ourOnCompleted?.(data) ourOnCompleted?.(data)
// don't wait to pay the invoice // 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 }) onPaid?.(client.cache, { data })
}).catch(e => { }).catch(e => {
console.error('usePaidMutation: failed to pay invoice', 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 // onPayError is called after the invoice fails to pay
// useful for updating invoiceActionState to FAILED // useful for updating invoiceActionState to FAILED
onPayError?.(e, client.cache, { data }) onPayError?.(e, client.cache, { data })
@ -99,18 +129,14 @@ export function usePaidMutation (mutation,
// the action is pessimistic // the action is pessimistic
try { try {
// wait for the invoice to be paid // 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 (!response.result) {
// if the mutation didn't return any data, ie pessimistic, we need to fetch it // 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) } }) const { data: { paidAction } } = await getPaidAction({ variables: { invoiceId: parseInt(invoice.id) } })
// create new data object // create new data object
// ( hmac is only returned on invoice creation so we need to add it back to the data ) // ( hmac is only returned on invoice creation so we need to add it back to the data )
data = { data = mergeData({ ...paidAction, invoice: { ...paidAction.invoice, hmac: invoice.hmac } })
[Object.keys(data)[0]]: {
...paidAction,
invoice: { ...paidAction.invoice, hmac: invoice.hmac }
}
}
// we need to run update functions on mutations now that we have the data // we need to run update functions on mutations now that we have the data
update?.(client.cache, { data }) update?.(client.cache, { data })
} }

View File

@ -5,13 +5,11 @@ import { Button } from 'react-bootstrap'
import { useToast } from './toast' import { useToast } from './toast'
import { useShowModal } from './modal' import { useShowModal } from './modal'
import { WALLET_LOGS } from '@/fragments/wallet' 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 { gql, useLazyQuery, useMutation } from '@apollo/client'
import { useMe } from './me' import { useMe } from './me'
import useIndexedDB, { getDbName } from './use-indexeddb' import useIndexedDB, { getDbName } from './use-indexeddb'
import { SSR } from '@/lib/constants' import { SSR } from '@/lib/constants'
import { decode as bolt11Decode } from 'bolt11'
import { formatMsats } from '@/lib/format'
import { useRouter } from 'next/router' import { useRouter } from 'next/router'
export function WalletLogs ({ wallet, embedded }) { export function WalletLogs ({ wallet, embedded }) {
@ -61,7 +59,7 @@ export function WalletLogs ({ wallet, embedded }) {
} }
function DeleteWalletLogsObstacle ({ wallet, setLogs, onClose }) { function DeleteWalletLogsObstacle ({ wallet, setLogs, onClose }) {
const { deleteLogs } = useWalletLogger(wallet, setLogs) const { deleteLogs } = useWalletLogManager(setLogs)
const toaster = useToast() const toaster = useToast()
const prompt = `Do you really want to delete all ${wallet ? '' : 'wallet'} logs ${wallet ? 'of this wallet' : ''}?` 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 } return { add, getPage, clear, error, notSupported }
} }
export function useWalletLogger (wallet, setLogs) { export function useWalletLogManager (setLogs) {
const { add, clear, notSupported } = useWalletLogDB() const { add, clear, notSupported } = useWalletLogDB()
const appendLog = useCallback(async (wallet, level, message, context) => { 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 { try {
if (notSupported) { if (notSupported) {
console.log('cannot persist wallet log: indexeddb not supported') console.log('cannot persist wallet log: indexeddb not supported')
@ -146,56 +144,20 @@ export function useWalletLogger (wallet, setLogs) {
} }
if (!wallet || wallet.sendPayment) { if (!wallet || wallet.sendPayment) {
try { try {
const walletTag = wallet ? tag(wallet) : null const tag = wallet ? walletTag(wallet.def) : null
if (notSupported) { if (notSupported) {
console.log('cannot clear wallet logs: indexeddb not supported') console.log('cannot clear wallet logs: indexeddb not supported')
} else { } 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) { } catch (e) {
console.error('failed to delete logs', e) console.error('failed to delete logs', e)
} }
} }
}, [clear, deleteServerWalletLogs, setLogs, notSupported]) }, [clear, deleteServerWalletLogs, setLogs, notSupported])
const log = useCallback(level => (message, context = {}) => { return { appendLog, deleteLogs }
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
} }
export function useWalletLogs (wallet, initialPage = 1, logsPerPage = 10) { 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') console.log('cannot get client wallet logs: indexeddb not supported')
} else { } else {
const indexName = walletDef ? 'wallet_ts' : 'ts' 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') result = await getPage(page, pageSize, indexName, query, 'prev')
// if given wallet has no walletType it means logs are only stored in local IDB // 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 }) => ({ const newLogs = data.walletLogs.entries.map(({ createdAt, wallet: walletType, ...log }) => ({
ts: +new Date(createdAt), ts: +new Date(createdAt),
wallet: tag(getWalletByType(walletType)), wallet: walletTag(getWalletByType(walletType)),
...log ...log
})) }))
const combinedLogs = uniqueSort([...result.data, ...newLogs]) const combinedLogs = uniqueSort([...result.data, ...newLogs])

View File

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

View File

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

View File

@ -221,3 +221,12 @@ export const SET_WALLET_PRIORITY = gql`
setWalletPriority(id: $id, priority: $priority) 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/png',
'image/jpeg', 'image/jpeg',
'image/webp', 'image/webp',
'video/quicktime',
'video/mp4', 'video/mp4',
'video/mpeg', 'video/mpeg',
'video/webm' 'video/webm'

View File

@ -204,3 +204,49 @@ export const toPositive = (x) => {
if (typeof x === 'bigint') return toPositiveBigInt(x) if (typeof x === 'bigint') return toPositiveBigInt(x)
return toPositiveNumber(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({ export const inviteSchema = object({
gift: intValidator.positive('must be greater than 0').required('required'), 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({ export const pushSubscriptionSchema = object({

View File

@ -340,10 +340,10 @@ export async function notifyEarner (userId, earnings) {
export async function notifyDeposit (userId, invoice) { export async function notifyDeposit (userId, invoice) {
try { try {
await sendUserNotification(userId, { 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, body: invoice.comment || undefined,
tag: 'DEPOSIT', tag: 'DEPOSIT',
data: { sats: msatsToSats(invoice.received_mtokens) } data: { sats: msatsToSats(invoice.msatsReceived) }
}) })
} catch (err) { } catch (err) {
console.error(err) console.error(err)

View File

@ -7,7 +7,7 @@ import { PriceProvider } from '@/components/price'
import { BlockHeightProvider } from '@/components/block-height' import { BlockHeightProvider } from '@/components/block-height'
import Head from 'next/head' import Head from 'next/head'
import { useRouter } from 'next/dist/client/router' import { useRouter } from 'next/dist/client/router'
import { useEffect } from 'react' import { useCallback, useEffect } from 'react'
import { ShowModalProvider } from '@/components/modal' import { ShowModalProvider } from '@/components/modal'
import ErrorBoundary from '@/components/error-boundary' import ErrorBoundary from '@/components/error-boundary'
import { LightningProvider } from '@/components/lightning' import { LightningProvider } from '@/components/lightning'
@ -46,9 +46,17 @@ export default function MyApp ({ Component, pageProps: { ...props } }) {
const client = getApolloClient() const client = getApolloClient()
const router = useRouter() const router = useRouter()
const shouldShowProgressBar = useCallback((newPathname, shallow) => {
return !shallow || newPathname !== router.pathname
}, [router.pathname])
useEffect(() => { useEffect(() => {
const nprogressStart = (_, { shallow }) => !shallow && NProgress.start() const nprogressStart = (newPathname, { shallow }) => {
const nprogressDone = (_, { shallow }) => !shallow && NProgress.done() shouldShowProgressBar(newPathname, shallow) && NProgress.start()
}
const nprogressDone = (newPathname, { shallow }) => {
NProgress.done()
}
router.events.on('routeChangeStart', nprogressStart) router.events.on('routeChangeStart', nprogressStart)
router.events.on('routeChangeComplete', nprogressDone) router.events.on('routeChangeComplete', nprogressDone)
@ -77,7 +85,7 @@ export default function MyApp ({ Component, pageProps: { ...props } }) {
router.events.off('routeChangeComplete', nprogressDone) router.events.off('routeChangeComplete', nprogressDone)
router.events.off('routeChangeError', nprogressDone) router.events.off('routeChangeError', nprogressDone)
} }
}, [router.asPath, props?.apollo]) }, [router.asPath, props?.apollo, shouldShowProgressBar])
useEffect(() => { useEffect(() => {
// hack to disable ios pwa prompt for https://github.com/stackernews/stacker.news/issues/953 // 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 { inviteSchema } from '@/lib/validate'
import { SSR } from '@/lib/constants' import { SSR } from '@/lib/constants'
import { getGetServerSideProps } from '@/api/ssrApollo' import { getGetServerSideProps } from '@/api/ssrApollo'
import Info from '@/components/info'
import Text from '@/components/text'
// force SSR to include CSP nonces // force SSR to include CSP nonces
export const getServerSideProps = getGetServerSideProps({ query: null }) export const getServerSideProps = getGetServerSideProps({ query: null })
@ -17,8 +19,8 @@ function InviteForm () {
const [createInvite] = useMutation( const [createInvite] = useMutation(
gql` gql`
${INVITE_FIELDS} ${INVITE_FIELDS}
mutation createInvite($gift: Int!, $limit: Int) { mutation createInvite($id: String, $gift: Int!, $limit: Int, $description: String) {
createInvite(gift: $gift, limit: $limit) { createInvite(id: $id, gift: $gift, limit: $limit, description: $description) {
...InviteFields ...InviteFields
} }
}`, { }`, {
@ -39,20 +41,28 @@ function InviteForm () {
} }
) )
const initialValues = {
id: '',
gift: 1000,
limit: 1,
description: ''
}
return ( return (
<Form <Form
initial={{ initial={initialValues}
gift: 100,
limit: 1
}}
schema={inviteSchema} schema={inviteSchema}
onSubmit={async ({ limit, gift }) => { onSubmit={async ({ id, gift, limit, description }, { resetForm }) => {
const { error } = await createInvite({ const { error } = await createInvite({
variables: { 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 if (error) throw error
resetForm({ values: initialValues })
}} }}
> >
<Input <Input
@ -65,8 +75,40 @@ function InviteForm () {
label={<>invitee limit <small className='text-muted ms-2'>optional</small></>} label={<>invitee limit <small className='text-muted ms-2'>optional</small></>}
name='limit' name='limit'
/> />
<AccordianItem
<SubmitButton variant='secondary'>create</SubmitButton> 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> </Form>
) )
} }

View File

@ -12,7 +12,7 @@ export default function FullInvoice () {
return ( return (
<CenterLayout> <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> </CenterLayout>
) )
} }

View File

@ -12,6 +12,7 @@ import { useRouter } from 'next/router'
import PageLoading from '@/components/page-loading' import PageLoading from '@/components/page-loading'
import { FeeButtonProvider } from '@/components/fee-button' import { FeeButtonProvider } from '@/components/fee-button'
import SubSelect from '@/components/sub-select' import SubSelect from '@/components/sub-select'
import useCanEdit from '@/components/use-can-edit'
export const getServerSideProps = getGetServerSideProps({ export const getServerSideProps = getGetServerSideProps({
query: ITEM, query: ITEM,
@ -26,7 +27,7 @@ export default function PostEdit ({ ssrData }) {
const { item } = data || ssrData const { item } = data || ssrData
const [sub, setSub] = useState(item.subName) 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 FormType = DiscussionForm
let itemType = 'DISCUSSION' let itemType = 'DISCUSSION'

View File

@ -9,8 +9,21 @@ import { useQuery } from '@apollo/client'
import PageLoading from '@/components/page-loading' import PageLoading from '@/components/page-loading'
const staticVariables = { sort: 'recent' } 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({ export const getServerSideProps = getGetServerSideProps({
query: SUB_ITEMS, 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) user User @relation("Invites", fields: [userId], references: [id], onDelete: Cascade)
invitees User[] invitees User[]
description String?
@@index([createdAt], map: "Invite.created_at_index") @@index([createdAt], map: "Invite.created_at_index")
@@index([userId], map: "Invite.userId_index") @@index([userId], map: "Invite.userId_index")
} }

View File

@ -27,6 +27,10 @@ export function getStorageKey (name, userId) {
return storageKey return storageKey
} }
export function walletTag (walletDef) {
return walletDef.shortName || walletDef.name
}
export function walletPrioritySort (w1, w2) { export function walletPrioritySort (w1, w2) {
// enabled/configured wallets always come before disabled/unconfigured wallets // enabled/configured wallets always come before disabled/unconfigured wallets
if ((w1.config?.enabled && !w2.config?.enabled) || (isConfigured(w1) && !isConfigured(w2))) { 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 { useMutation } from '@apollo/client'
import { generateMutation } from './graphql' import { generateMutation } from './graphql'
import { REMOVE_WALLET } from '@/fragments/wallet' import { REMOVE_WALLET } from '@/fragments/wallet'
import { useWalletLogger } from '@/components/wallet-logger' import { useWalletLogger } from '@/wallets/logger'
import { useWallets } from '.' import { useWallets } from '.'
import validateWallet from './validate' import validateWallet from './validate'
@ -13,7 +13,7 @@ export function useWalletConfigurator (wallet) {
const { me } = useMe() const { me } = useMe()
const { reloadLocalWallets } = useWallets() const { reloadLocalWallets } = useWallets()
const { encrypt, isActive } = useVault() const { encrypt, isActive } = useVault()
const { logger } = useWalletLogger(wallet?.def) const logger = useWalletLogger(wallet)
const [upsertWallet] = useMutation(generateMutation(wallet?.def)) const [upsertWallet] = useMutation(generateMutation(wallet?.def))
const [removeWallet] = useMutation(REMOVE_WALLET) const [removeWallet] = useMutation(REMOVE_WALLET)
@ -59,7 +59,7 @@ export function useWalletConfigurator (wallet) {
} }
return { clientConfig, serverConfig } return { clientConfig, serverConfig }
}, [wallet]) }, [wallet, logger])
const _detachFromServer = useCallback(async () => { const _detachFromServer = useCallback(async () => {
await removeWallet({ variables: { id: wallet.config.id } }) await removeWallet({ variables: { id: wallet.config.id } })

View File

@ -1,22 +1,87 @@
export class InvoiceCanceledError extends Error { export class InvoiceCanceledError extends Error {
constructor (hash, actionError) { constructor (invoice, actionError) {
super(actionError ?? `invoice canceled: ${hash}`) super(actionError ?? `invoice canceled: ${invoice.hash}`)
this.name = 'InvoiceCanceledError' this.name = 'InvoiceCanceledError'
this.hash = hash this.invoice = invoice
this.actionError = actionError this.actionError = actionError
} }
} }
export class NoAttachedWalletError extends Error { export class InvoiceExpiredError extends Error {
constructor () { constructor (invoice) {
super('no attached wallet found') super(`invoice expired: ${invoice.hash}`)
this.name = 'NoAttachedWalletError' this.name = 'InvoiceExpiredError'
this.invoice = invoice
} }
} }
export class InvoiceExpiredError extends Error { export class WalletError extends Error {}
constructor (hash) { export class WalletPaymentError extends WalletError {}
super(`invoice expired: ${hash}`) export class WalletConfigurationError extends WalletError {}
this.name = 'InvoiceExpiredError'
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 { createContext, useCallback, useContext, useEffect, useMemo, useState } from 'react'
import { getStorageKey, getWalletByType, walletPrioritySort, canSend, isConfigured, upsertWalletVariables, siftConfig, saveWalletLocally } from './common' import { getStorageKey, getWalletByType, walletPrioritySort, canSend, isConfigured, upsertWalletVariables, siftConfig, saveWalletLocally } from './common'
import useVault from '@/components/vault/use-vault' 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 walletDefs from '@/wallets/client'
import { generateMutation } from './graphql' import { generateMutation } from './graphql'
import { formatSats } from '@/lib/format'
const WalletsContext = createContext({ const WalletsContext = createContext({
wallets: [] 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) return Object.values(merged).sort(walletPrioritySort)
}, [serverWallets, localWallets]) }, [serverWallets, localWallets])
@ -218,34 +215,13 @@ export function useWallets () {
export function useWallet (name) { export function useWallet (name) {
const { wallets } = useWallets() const { wallets } = useWallets()
return wallets.find(w => w.def.name === name)
const wallet = useMemo(() => { }
if (name) {
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 the first enabled wallet that is available and can send return wallets
return wallets .filter(w => !w.def.isAvailable || w.def.isAvailable())
.filter(w => !w.def.isAvailable || w.def.isAvailable()) .filter(w => w.config?.enabled && canSend(w))
.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 }
} }

View File

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

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])
}