Compare commits
4 Commits
d3ca87a78b
...
dc0370ba17
Author | SHA1 | Date | |
---|---|---|---|
|
dc0370ba17 | ||
|
4964e2c7d1 | ||
|
3d1f7834ca | ||
|
628a0466fd |
4
.github/workflows/lint.yml
vendored
4
.github/workflows/lint.yml
vendored
@ -1,8 +1,8 @@
|
|||||||
name: Eslint Check
|
name: Lint Check
|
||||||
on: [pull_request]
|
on: [pull_request]
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
eslint-run:
|
lint-run:
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout
|
- name: Checkout
|
||||||
|
@ -201,4 +201,69 @@ From the [postgres docs](https://www.postgresql.org/docs/current/transaction-iso
|
|||||||
> UPDATE, DELETE, SELECT FOR UPDATE, and SELECT FOR SHARE commands behave the same as SELECT in terms of searching for target rows: they will only find target rows that were committed as of the command start time. However, such a target row might have already been updated (or deleted or locked) by another concurrent transaction by the time it is found. In this case, the would-be updater will wait for the first updating transaction to commit or roll back (if it is still in progress). If the first updater rolls back, then its effects are negated and the second updater can proceed with updating the originally found row. If the first updater commits, the second updater will ignore the row if the first updater deleted it, otherwise it will attempt to apply its operation to the updated version of the row. The search condition of the command (the WHERE clause) is re-evaluated to see if the updated version of the row still matches the search condition. If so, the second updater proceeds with its operation using the updated version of the row. In the case of SELECT FOR UPDATE and SELECT FOR SHARE, this means it is the updated version of the row that is locked and returned to the client.
|
> UPDATE, DELETE, SELECT FOR UPDATE, and SELECT FOR SHARE commands behave the same as SELECT in terms of searching for target rows: they will only find target rows that were committed as of the command start time. However, such a target row might have already been updated (or deleted or locked) by another concurrent transaction by the time it is found. In this case, the would-be updater will wait for the first updating transaction to commit or roll back (if it is still in progress). If the first updater rolls back, then its effects are negated and the second updater can proceed with updating the originally found row. If the first updater commits, the second updater will ignore the row if the first updater deleted it, otherwise it will attempt to apply its operation to the updated version of the row. The search condition of the command (the WHERE clause) is re-evaluated to see if the updated version of the row still matches the search condition. If so, the second updater proceeds with its operation using the updated version of the row. In the case of SELECT FOR UPDATE and SELECT FOR SHARE, this means it is the updated version of the row that is locked and returned to the client.
|
||||||
|
|
||||||
From the [postgres source docs](https://git.postgresql.org/gitweb/?p=postgresql.git;a=blob;f=src/backend/executor/README#l350):
|
From the [postgres source docs](https://git.postgresql.org/gitweb/?p=postgresql.git;a=blob;f=src/backend/executor/README#l350):
|
||||||
> It is also possible that there are relations in the query that are not to be locked (they are neither the UPDATE/DELETE/MERGE target nor specified to be locked in SELECT FOR UPDATE/SHARE). When re-running the test query ***we want to use the same rows*** from these relations that were joined to the locked rows.
|
> It is also possible that there are relations in the query that are not to be locked (they are neither the UPDATE/DELETE/MERGE target nor specified to be locked in SELECT FOR UPDATE/SHARE). When re-running the test query ***we want to use the same rows*** from these relations that were joined to the locked rows.
|
||||||
|
|
||||||
|
## `IMPORTANT: deadlocks`
|
||||||
|
|
||||||
|
Deadlocks can occur when two transactions are waiting for each other to release locks. This can happen when two transactions lock rows in different orders whether explicit or implicit.
|
||||||
|
|
||||||
|
### Incorrect
|
||||||
|
|
||||||
|
```sql
|
||||||
|
-- transaction 1
|
||||||
|
BEGIN;
|
||||||
|
UPDATE users set msats = msats + 1 WHERE id = 1;
|
||||||
|
-- transaction 2
|
||||||
|
BEGIN;
|
||||||
|
UPDATE users set msats = msats + 1 WHERE id = 2;
|
||||||
|
-- transaction 1 (blocks here until transaction 2 commits)
|
||||||
|
UPDATE users set msats = msats + 1 WHERE id = 2;
|
||||||
|
-- transaction 2 (blocks here until transaction 1 commits)
|
||||||
|
UPDATE users set msats = msats + 1 WHERE id = 1;
|
||||||
|
-- deadlock occurs because neither transaction can proceed to here
|
||||||
|
```
|
||||||
|
|
||||||
|
If both transactions lock the rows in the same order, the deadlock is avoided.
|
||||||
|
|
||||||
|
Most often this occurs when selecting multiple rows for update in different orders. Recently, we had a deadlock when spliting zaps to multiple users. The solution was to select the rows for update in the same order.
|
||||||
|
|
||||||
|
### Incorrect
|
||||||
|
|
||||||
|
```sql
|
||||||
|
WITH forwardees AS (
|
||||||
|
SELECT "userId", (($1::BIGINT * pct) / 100)::BIGINT AS msats
|
||||||
|
FROM "ItemForward"
|
||||||
|
WHERE "itemId" = $2::INTEGER
|
||||||
|
),
|
||||||
|
UPDATE users
|
||||||
|
SET
|
||||||
|
msats = users.msats + forwardees.msats,
|
||||||
|
"stackedMsats" = users."stackedMsats" + forwardees.msats
|
||||||
|
FROM forwardees
|
||||||
|
WHERE users.id = forwardees."userId";
|
||||||
|
```
|
||||||
|
|
||||||
|
If forwardees are selected in a different order in two concurrent transactions, e.g. (1,2) in tx 1 and (2,1) in tx 2, a deadlock can occur. To avoid this, always select rows for update in the same order.
|
||||||
|
|
||||||
|
### Correct
|
||||||
|
|
||||||
|
We fixed the deadlock by selecting the forwardees in the same order in these transactions.
|
||||||
|
|
||||||
|
```sql
|
||||||
|
WITH forwardees AS (
|
||||||
|
SELECT "userId", (($1::BIGINT * pct) / 100)::BIGINT AS msats
|
||||||
|
FROM "ItemForward"
|
||||||
|
WHERE "itemId" = $2::INTEGER
|
||||||
|
ORDER BY "userId" ASC
|
||||||
|
),
|
||||||
|
UPDATE users
|
||||||
|
SET
|
||||||
|
msats = users.msats + forwardees.msats,
|
||||||
|
"stackedMsats" = users."stackedMsats" + forwardees.msats
|
||||||
|
FROM forwardees
|
||||||
|
WHERE users.id = forwardees."userId";
|
||||||
|
```
|
||||||
|
|
||||||
|
### More resources
|
||||||
|
|
||||||
|
- https://www.postgresql.org/docs/current/explicit-locking.html#LOCKING-DEADLOCKS
|
||||||
|
@ -77,19 +77,19 @@ export async function onPaid ({ invoice, actIds }, { models, tx }) {
|
|||||||
), total_forwarded AS (
|
), total_forwarded AS (
|
||||||
SELECT COALESCE(SUM(msats), 0) as msats
|
SELECT COALESCE(SUM(msats), 0) as msats
|
||||||
FROM forwardees
|
FROM forwardees
|
||||||
), forward AS (
|
), recipients AS (
|
||||||
UPDATE users
|
SELECT "userId", msats FROM forwardees
|
||||||
SET
|
UNION
|
||||||
msats = users.msats + forwardees.msats,
|
SELECT ${itemAct.item.userId}::INTEGER as "userId",
|
||||||
"stackedMsats" = users."stackedMsats" + forwardees.msats
|
${itemAct.msats}::BIGINT - (SELECT msats FROM total_forwarded)::BIGINT as msats
|
||||||
FROM forwardees
|
ORDER BY "userId" ASC -- order to prevent deadlocks
|
||||||
WHERE users.id = forwardees."userId"
|
|
||||||
)
|
)
|
||||||
UPDATE users
|
UPDATE users
|
||||||
SET
|
SET
|
||||||
msats = msats + ${itemAct.msats}::BIGINT - (SELECT msats FROM total_forwarded)::BIGINT,
|
msats = users.msats + recipients.msats,
|
||||||
"stackedMsats" = "stackedMsats" + ${itemAct.msats}::BIGINT - (SELECT msats FROM total_forwarded)::BIGINT
|
"stackedMsats" = users."stackedMsats" + recipients.msats
|
||||||
WHERE id = ${itemAct.item.userId}::INTEGER`
|
FROM recipients
|
||||||
|
WHERE users.id = recipients."userId"`
|
||||||
|
|
||||||
// perform denomormalized aggregates: weighted votes, upvotes, msats, lastZapAt
|
// perform denomormalized aggregates: weighted votes, upvotes, msats, lastZapAt
|
||||||
// NOTE: for the rows that might be updated by a concurrent zap, we use UPDATE for implicit locking
|
// NOTE: for the rows that might be updated by a concurrent zap, we use UPDATE for implicit locking
|
||||||
|
@ -1004,6 +1004,12 @@ export default {
|
|||||||
})
|
})
|
||||||
|
|
||||||
return relays?.map(r => r.nostrRelayAddr)
|
return relays?.map(r => r.nostrRelayAddr)
|
||||||
|
},
|
||||||
|
tipRandom: async (user, args, { me }) => {
|
||||||
|
if (!me || me.id !== user.id) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
return !!user.tipRandomMin && !!user.tipRandomMax
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
|
@ -98,6 +98,8 @@ export default gql`
|
|||||||
noteItemMentions: Boolean!
|
noteItemMentions: Boolean!
|
||||||
nsfwMode: Boolean!
|
nsfwMode: Boolean!
|
||||||
tipDefault: Int!
|
tipDefault: Int!
|
||||||
|
tipRandomMin: Int
|
||||||
|
tipRandomMax: Int
|
||||||
turboTipping: Boolean!
|
turboTipping: Boolean!
|
||||||
zapUndos: Int
|
zapUndos: Int
|
||||||
wildWestMode: Boolean!
|
wildWestMode: Boolean!
|
||||||
@ -165,6 +167,9 @@ export default gql`
|
|||||||
noteItemMentions: Boolean!
|
noteItemMentions: Boolean!
|
||||||
nsfwMode: Boolean!
|
nsfwMode: Boolean!
|
||||||
tipDefault: Int!
|
tipDefault: Int!
|
||||||
|
tipRandom: Boolean!
|
||||||
|
tipRandomMin: Int
|
||||||
|
tipRandomMax: Int
|
||||||
turboTipping: Boolean!
|
turboTipping: Boolean!
|
||||||
zapUndos: Int
|
zapUndos: Int
|
||||||
wildWestMode: Boolean!
|
wildWestMode: Boolean!
|
||||||
|
@ -7,7 +7,7 @@ import UpBolt from '@/svgs/bolt.svg'
|
|||||||
import { amountSchema } from '@/lib/validate'
|
import { amountSchema } from '@/lib/validate'
|
||||||
import { useToast } from './toast'
|
import { useToast } from './toast'
|
||||||
import { useLightning } from './lightning'
|
import { useLightning } from './lightning'
|
||||||
import { nextTip } from './upvote'
|
import { nextTip, defaultTipIncludingRandom } from './upvote'
|
||||||
import { ZAP_UNDO_DELAY_MS } from '@/lib/constants'
|
import { ZAP_UNDO_DELAY_MS } from '@/lib/constants'
|
||||||
import { usePaidMutation } from './use-paid-mutation'
|
import { usePaidMutation } from './use-paid-mutation'
|
||||||
import { ACT_MUTATION } from '@/fragments/paidAction'
|
import { ACT_MUTATION } from '@/fragments/paidAction'
|
||||||
@ -101,7 +101,7 @@ export default function ItemAct ({ onClose, item, down, children, abortSignal })
|
|||||||
return (
|
return (
|
||||||
<Form
|
<Form
|
||||||
initial={{
|
initial={{
|
||||||
amount: me?.privates?.tipDefault || defaultTips[0],
|
amount: defaultTipIncludingRandom(me?.privates) || defaultTips[0],
|
||||||
default: false
|
default: false
|
||||||
}}
|
}}
|
||||||
schema={amountSchema}
|
schema={amountSchema}
|
||||||
|
@ -26,7 +26,7 @@ const UpvotePopover = ({ target, show, handleClose }) => {
|
|||||||
<button type='button' className='btn-close' onClick={handleClose}><span className='visually-hidden-focusable'>Close alert</span></button>
|
<button type='button' className='btn-close' onClick={handleClose}><span className='visually-hidden-focusable'>Close alert</span></button>
|
||||||
</Popover.Header>
|
</Popover.Header>
|
||||||
<Popover.Body>
|
<Popover.Body>
|
||||||
<div className='mb-2'>Press the bolt again to zap {me?.privates?.tipDefault || 1} more sat{me?.privates?.tipDefault > 1 ? 's' : ''}.</div>
|
<div className='mb-2'>Press the bolt again to zap {me?.privates?.tipRandom ? 'a random amount of' : `${me?.privates?.tipDefault || 1} more`} sat{me?.privates?.tipDefault > 1 ? 's' : ''}.</div>
|
||||||
<div>Repeatedly press the bolt to zap more sats.</div>
|
<div>Repeatedly press the bolt to zap more sats.</div>
|
||||||
</Popover.Body>
|
</Popover.Body>
|
||||||
</Popover>
|
</Popover>
|
||||||
@ -67,11 +67,20 @@ export function DropdownItemUpVote ({ item }) {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
export const nextTip = (meSats, { tipDefault, turboTipping }) => {
|
export const defaultTipIncludingRandom = ({ tipDefault, tipRandom, tipRandomMin, tipRandomMax }) =>
|
||||||
// what should our next tip be?
|
tipRandom
|
||||||
if (!turboTipping) return (tipDefault || 1)
|
? Math.floor((Math.random() * (tipRandomMax - tipRandomMin)) + tipRandomMin)
|
||||||
|
: (tipDefault || 1)
|
||||||
|
|
||||||
let sats = tipDefault || 1
|
export const nextTip = (meSats, { tipDefault, turboTipping, tipRandom, tipRandomMin, tipRandomMax }) => {
|
||||||
|
// what should our next tip be?
|
||||||
|
const calculatedDefault = defaultTipIncludingRandom({ tipDefault, tipRandom, tipRandomMin, tipRandomMax })
|
||||||
|
|
||||||
|
if (!turboTipping) {
|
||||||
|
return calculatedDefault
|
||||||
|
}
|
||||||
|
|
||||||
|
let sats = calculatedDefault
|
||||||
if (turboTipping) {
|
if (turboTipping) {
|
||||||
while (meSats >= sats) {
|
while (meSats >= sats) {
|
||||||
sats *= 10
|
sats *= 10
|
||||||
@ -138,11 +147,17 @@ export default function UpVote ({ item, className }) {
|
|||||||
|
|
||||||
// what should our next tip be?
|
// what should our next tip be?
|
||||||
const sats = nextTip(meSats, { ...me?.privates })
|
const sats = nextTip(meSats, { ...me?.privates })
|
||||||
|
let overlayTextContent
|
||||||
|
if (me) {
|
||||||
|
overlayTextContent = me.privates?.tipRandom ? 'random' : numWithUnits(sats, { abbreviate: false })
|
||||||
|
} else {
|
||||||
|
overlayTextContent = 'zap it'
|
||||||
|
}
|
||||||
|
|
||||||
return [
|
return [
|
||||||
meSats, me ? numWithUnits(sats, { abbreviate: false }) : 'zap it',
|
meSats, overlayTextContent,
|
||||||
getColor(meSats), getColor(meSats + sats)]
|
getColor(meSats), getColor(meSats + sats)]
|
||||||
}, [item?.meSats, item?.meAnonSats, me?.privates?.tipDefault, me?.privates?.turboDefault])
|
}, [item?.meSats, item?.meAnonSats, me?.privates?.tipDefault, me?.privates?.turboDefault, me?.privates?.tipRandom, me?.privates?.tipRandomMin, me?.privates?.tipRandomMax])
|
||||||
|
|
||||||
const handleModalClosed = () => {
|
const handleModalClosed = () => {
|
||||||
setHover(false)
|
setHover(false)
|
||||||
|
@ -41,6 +41,9 @@ export const ME = gql`
|
|||||||
noteItemMentions
|
noteItemMentions
|
||||||
sats
|
sats
|
||||||
tipDefault
|
tipDefault
|
||||||
|
tipRandom
|
||||||
|
tipRandomMin
|
||||||
|
tipRandomMax
|
||||||
tipPopover
|
tipPopover
|
||||||
turboTipping
|
turboTipping
|
||||||
zapUndos
|
zapUndos
|
||||||
@ -66,6 +69,9 @@ export const SETTINGS_FIELDS = gql`
|
|||||||
fragment SettingsFields on User {
|
fragment SettingsFields on User {
|
||||||
privates {
|
privates {
|
||||||
tipDefault
|
tipDefault
|
||||||
|
tipRandom
|
||||||
|
tipRandomMin
|
||||||
|
tipRandomMax
|
||||||
turboTipping
|
turboTipping
|
||||||
zapUndos
|
zapUndos
|
||||||
fiatCurrency
|
fiatCurrency
|
||||||
|
@ -540,8 +540,24 @@ export const actSchema = object({
|
|||||||
act: string().required('required').oneOf(['TIP', 'DONT_LIKE_THIS'])
|
act: string().required('required').oneOf(['TIP', 'DONT_LIKE_THIS'])
|
||||||
})
|
})
|
||||||
|
|
||||||
export const settingsSchema = object({
|
export const settingsSchema = object().shape({
|
||||||
tipDefault: intValidator.required('required').positive('must be positive'),
|
tipDefault: intValidator.required('required').positive('must be positive'),
|
||||||
|
tipRandomMin: intValidator.nullable().positive('must be positive')
|
||||||
|
.when(['tipRandomMax'], ([max], schema) => {
|
||||||
|
let res = schema
|
||||||
|
if (max) {
|
||||||
|
res = schema.required('minimum and maximum must either both be omitted or specified').nonNullable()
|
||||||
|
}
|
||||||
|
return res.lessThan(max, 'must be less than maximum')
|
||||||
|
}),
|
||||||
|
tipRandomMax: intValidator.nullable().positive('must be positive')
|
||||||
|
.when(['tipRandomMin'], ([min], schema) => {
|
||||||
|
let res = schema
|
||||||
|
if (min) {
|
||||||
|
res = schema.required('minimum and maximum must either both be omitted or specified').nonNullable()
|
||||||
|
}
|
||||||
|
return res.moreThan(min, 'must be more than minimum')
|
||||||
|
}),
|
||||||
fiatCurrency: string().required('required').oneOf(SUPPORTED_CURRENCIES),
|
fiatCurrency: string().required('required').oneOf(SUPPORTED_CURRENCIES),
|
||||||
withdrawMaxFeeDefault: intValidator.required('required').positive('must be positive'),
|
withdrawMaxFeeDefault: intValidator.required('required').positive('must be positive'),
|
||||||
nostrPubkey: string().nullable()
|
nostrPubkey: string().nullable()
|
||||||
@ -561,7 +577,8 @@ export const settingsSchema = object({
|
|||||||
noReferralLinks: boolean(),
|
noReferralLinks: boolean(),
|
||||||
hideIsContributor: boolean(),
|
hideIsContributor: boolean(),
|
||||||
zapUndos: intValidator.nullable().min(0, 'must be greater or equal to 0')
|
zapUndos: intValidator.nullable().min(0, 'must be greater or equal to 0')
|
||||||
})
|
// exclude from cyclic analysis. see https://github.com/jquense/yup/issues/720
|
||||||
|
}, [['tipRandomMax', 'tipRandomMin']])
|
||||||
|
|
||||||
const warningMessage = 'If I logout, even accidentally, I will never be able to access my account again'
|
const warningMessage = 'If I logout, even accidentally, I will never be able to access my account again'
|
||||||
export const lastAuthRemovalSchema = object({
|
export const lastAuthRemovalSchema = object({
|
||||||
|
@ -112,6 +112,9 @@ export default function Settings ({ ssrData }) {
|
|||||||
<Form
|
<Form
|
||||||
initial={{
|
initial={{
|
||||||
tipDefault: settings?.tipDefault || 21,
|
tipDefault: settings?.tipDefault || 21,
|
||||||
|
tipRandom: settings?.tipRandom,
|
||||||
|
tipRandomMin: settings?.tipRandomMin || 1,
|
||||||
|
tipRandomMax: settings?.tipRandomMax || settings?.tipDefault || 21,
|
||||||
turboTipping: settings?.turboTipping,
|
turboTipping: settings?.turboTipping,
|
||||||
zapUndos: settings?.zapUndos || (settings?.tipDefault ? 100 * settings.tipDefault : 2100),
|
zapUndos: settings?.zapUndos || (settings?.tipDefault ? 100 * settings.tipDefault : 2100),
|
||||||
zapUndosEnabled: settings?.zapUndos !== null,
|
zapUndosEnabled: settings?.zapUndos !== null,
|
||||||
@ -149,7 +152,7 @@ export default function Settings ({ ssrData }) {
|
|||||||
noReferralLinks: settings?.noReferralLinks
|
noReferralLinks: settings?.noReferralLinks
|
||||||
}}
|
}}
|
||||||
schema={settingsSchema}
|
schema={settingsSchema}
|
||||||
onSubmit={async ({ tipDefault, withdrawMaxFeeDefault, zapUndos, zapUndosEnabled, nostrPubkey, nostrRelays, ...values }) => {
|
onSubmit={async ({ tipDefault, tipRandom, tipRandomMin, tipRandomMax, withdrawMaxFeeDefault, zapUndos, zapUndosEnabled, nostrPubkey, nostrRelays, ...values }) => {
|
||||||
if (nostrPubkey.length === 0) {
|
if (nostrPubkey.length === 0) {
|
||||||
nostrPubkey = null
|
nostrPubkey = null
|
||||||
} else {
|
} else {
|
||||||
@ -166,6 +169,8 @@ export default function Settings ({ ssrData }) {
|
|||||||
variables: {
|
variables: {
|
||||||
settings: {
|
settings: {
|
||||||
tipDefault: Number(tipDefault),
|
tipDefault: Number(tipDefault),
|
||||||
|
tipRandomMin: tipRandom ? Number(tipRandomMin) : null,
|
||||||
|
tipRandomMax: tipRandom ? Number(tipRandomMax) : null,
|
||||||
withdrawMaxFeeDefault: Number(withdrawMaxFeeDefault),
|
withdrawMaxFeeDefault: Number(withdrawMaxFeeDefault),
|
||||||
zapUndos: zapUndosEnabled ? Number(zapUndos) : null,
|
zapUndos: zapUndosEnabled ? Number(zapUndos) : null,
|
||||||
nostrPubkey,
|
nostrPubkey,
|
||||||
@ -190,7 +195,7 @@ export default function Settings ({ ssrData }) {
|
|||||||
append={<InputGroup.Text className='text-monospace'>sats</InputGroup.Text>}
|
append={<InputGroup.Text className='text-monospace'>sats</InputGroup.Text>}
|
||||||
hint={<small className='text-muted'>note: you can also press and hold the lightning bolt to zap custom amounts</small>}
|
hint={<small className='text-muted'>note: you can also press and hold the lightning bolt to zap custom amounts</small>}
|
||||||
/>
|
/>
|
||||||
<div className='mb-2'>
|
<div className='pb-4'>
|
||||||
<AccordianItem
|
<AccordianItem
|
||||||
show={settings?.turboTipping}
|
show={settings?.turboTipping}
|
||||||
header={<div style={{ fontWeight: 'bold', fontSize: '92%' }}>advanced</div>}
|
header={<div style={{ fontWeight: 'bold', fontSize: '92%' }}>advanced</div>}
|
||||||
@ -224,6 +229,7 @@ export default function Settings ({ ssrData }) {
|
|||||||
groupClassName='mb-0'
|
groupClassName='mb-0'
|
||||||
/>
|
/>
|
||||||
<ZapUndosField />
|
<ZapUndosField />
|
||||||
|
<TipRandomField />
|
||||||
</>
|
</>
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
@ -995,31 +1001,79 @@ function ApiKeyDeleteObstacle ({ onClose }) {
|
|||||||
const ZapUndosField = () => {
|
const ZapUndosField = () => {
|
||||||
const [checkboxField] = useField({ name: 'zapUndosEnabled' })
|
const [checkboxField] = useField({ name: 'zapUndosEnabled' })
|
||||||
return (
|
return (
|
||||||
<div className='d-flex flex-row align-items-center'>
|
<>
|
||||||
<Input
|
<Checkbox
|
||||||
name='zapUndos'
|
name='zapUndosEnabled'
|
||||||
disabled={!checkboxField.value}
|
groupClassName='mb-0'
|
||||||
label={
|
label={
|
||||||
<Checkbox
|
<div className='d-flex align-items-center'>
|
||||||
name='zapUndosEnabled'
|
zap undos
|
||||||
groupClassName='mb-0'
|
<Info>
|
||||||
label={
|
<ul className='fw-bold'>
|
||||||
<div className='d-flex align-items-center'>
|
<li>After every zap that exceeds or is equal to the threshold, the bolt will pulse</li>
|
||||||
zap undos
|
<li>You can undo the zap if you click the bolt while it's pulsing</li>
|
||||||
<Info>
|
<li>The bolt will pulse for {ZAP_UNDO_DELAY_MS / 1000} seconds</li>
|
||||||
<ul className='fw-bold'>
|
</ul>
|
||||||
<li>After every zap that exceeds or is equal to the threshold, the bolt will pulse</li>
|
</Info>
|
||||||
<li>You can undo the zap if you click the bolt while it's pulsing</li>
|
</div>
|
||||||
<li>The bolt will pulse for {ZAP_UNDO_DELAY_MS / 1000} seconds</li>
|
}
|
||||||
</ul>
|
|
||||||
</Info>
|
|
||||||
</div>
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
}
|
|
||||||
append={<InputGroup.Text className='text-monospace'>sats</InputGroup.Text>}
|
|
||||||
hint={<small className='text-muted'>threshold at which undo button is shown</small>}
|
|
||||||
/>
|
/>
|
||||||
</div>
|
{checkboxField.value &&
|
||||||
|
<Input
|
||||||
|
name='zapUndos'
|
||||||
|
append={<InputGroup.Text className='text-monospace'>sats</InputGroup.Text>}
|
||||||
|
hint={<small className='text-muted'>threshold at which undo button is shown</small>}
|
||||||
|
/>}
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const TipRandomField = () => {
|
||||||
|
const [tipRandomField] = useField({ name: 'tipRandom' })
|
||||||
|
const [tipRandomMinField] = useField({ name: 'tipRandomMin' })
|
||||||
|
const [tipRandomMaxField] = useField({ name: 'tipRandomMax' })
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<Checkbox
|
||||||
|
name='tipRandom'
|
||||||
|
groupClassName='mb-0'
|
||||||
|
label={
|
||||||
|
<div className='d-flex align-items-center'>
|
||||||
|
Enable random zap values
|
||||||
|
<Info>
|
||||||
|
<ul className='fw-bold'>
|
||||||
|
<li>Set a minimum and maximum zap amount</li>
|
||||||
|
<li>Each time you zap something, a random amount of sats between your minimum and maximum will be zapped</li>
|
||||||
|
<li>If this setting is enabled, it will ignore your default zap amount</li>
|
||||||
|
</ul>
|
||||||
|
</Info>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
{tipRandomField.value &&
|
||||||
|
<>
|
||||||
|
<Input
|
||||||
|
type='number'
|
||||||
|
label='minimum random zap'
|
||||||
|
name='tipRandomMin'
|
||||||
|
disabled={!tipRandomField.value}
|
||||||
|
groupClassName='mb-1'
|
||||||
|
required
|
||||||
|
autoFocus
|
||||||
|
max={tipRandomMaxField.value ? tipRandomMaxField.value - 1 : undefined}
|
||||||
|
append={<InputGroup.Text className='text-monospace'>sats</InputGroup.Text>}
|
||||||
|
/>
|
||||||
|
<Input
|
||||||
|
type='number'
|
||||||
|
label='maximum random zap'
|
||||||
|
name='tipRandomMax'
|
||||||
|
disabled={!tipRandomField.value}
|
||||||
|
required
|
||||||
|
autoFocus
|
||||||
|
min={tipRandomMinField.value ? tipRandomMinField.value + 1 : undefined}
|
||||||
|
append={<InputGroup.Text className='text-monospace'>sats</InputGroup.Text>}
|
||||||
|
/>
|
||||||
|
</>}
|
||||||
|
</>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
@ -1,5 +1,5 @@
|
|||||||
import { getGetServerSideProps } from '@/api/ssrApollo'
|
import { getGetServerSideProps } from '@/api/ssrApollo'
|
||||||
import { Form, ClientInput, ClientCheckbox, PasswordInput } from '@/components/form'
|
import { Form, ClientInput, ClientCheckbox, PasswordInput, CheckboxGroup } from '@/components/form'
|
||||||
import { CenterLayout } from '@/components/layout'
|
import { CenterLayout } from '@/components/layout'
|
||||||
import { WalletSecurityBanner } from '@/components/banners'
|
import { WalletSecurityBanner } from '@/components/banners'
|
||||||
import { WalletLogs } from '@/components/wallet-logger'
|
import { WalletLogs } from '@/components/wallet-logger'
|
||||||
@ -10,7 +10,6 @@ import Info from '@/components/info'
|
|||||||
import Text from '@/components/text'
|
import Text from '@/components/text'
|
||||||
import { AutowithdrawSettings } from '@/components/autowithdraw-shared'
|
import { AutowithdrawSettings } from '@/components/autowithdraw-shared'
|
||||||
import dynamic from 'next/dynamic'
|
import dynamic from 'next/dynamic'
|
||||||
import { useEffect, useState } from 'react'
|
|
||||||
|
|
||||||
const WalletButtonBar = dynamic(() => import('@/components/wallet-buttonbar.js'), { ssr: false })
|
const WalletButtonBar = dynamic(() => import('@/components/wallet-buttonbar.js'), { ssr: false })
|
||||||
|
|
||||||
@ -22,14 +21,6 @@ export default function WalletSettings () {
|
|||||||
const { wallet: name } = router.query
|
const { wallet: name } = router.query
|
||||||
const wallet = useWallet(name)
|
const wallet = useWallet(name)
|
||||||
|
|
||||||
const [mounted, setMounted] = useState(false)
|
|
||||||
useEffect(() => {
|
|
||||||
// mounted is required since available might depend
|
|
||||||
// on values that are only available on the client (and not during SSR)
|
|
||||||
// and thus we need to render the component again on the client
|
|
||||||
setMounted(true)
|
|
||||||
}, [])
|
|
||||||
|
|
||||||
const initial = wallet.fields.reduce((acc, field) => {
|
const initial = wallet.fields.reduce((acc, field) => {
|
||||||
// We still need to run over all wallet fields via reduce
|
// We still need to run over all wallet fields via reduce
|
||||||
// even though we use wallet.config as the initial value
|
// even though we use wallet.config as the initial value
|
||||||
@ -47,8 +38,6 @@ export default function WalletSettings () {
|
|||||||
? { validate: wallet.fieldValidation }
|
? { validate: wallet.fieldValidation }
|
||||||
: { schema: wallet.fieldValidation }
|
: { schema: wallet.fieldValidation }
|
||||||
|
|
||||||
const available = mounted && wallet.available !== undefined ? wallet.available : wallet.isConfigured
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<CenterLayout>
|
<CenterLayout>
|
||||||
<h2 className='pb-2'>{wallet.card.title}</h2>
|
<h2 className='pb-2'>{wallet.card.title}</h2>
|
||||||
@ -84,12 +73,15 @@ export default function WalletSettings () {
|
|||||||
{wallet.walletType
|
{wallet.walletType
|
||||||
? <AutowithdrawSettings wallet={wallet} />
|
? <AutowithdrawSettings wallet={wallet} />
|
||||||
: (
|
: (
|
||||||
<ClientCheckbox
|
<CheckboxGroup name='enabled'>
|
||||||
disabled={!available}
|
<ClientCheckbox
|
||||||
initialValue={wallet.status === Status.Enabled}
|
disabled={!wallet.isConfigured}
|
||||||
label='enabled'
|
initialValue={wallet.status === Status.Enabled}
|
||||||
name='enabled'
|
label='enabled'
|
||||||
/>
|
name='enabled'
|
||||||
|
groupClassName='mb-0'
|
||||||
|
/>
|
||||||
|
</CheckboxGroup>
|
||||||
)}
|
)}
|
||||||
<WalletButtonBar
|
<WalletButtonBar
|
||||||
wallet={wallet} onDelete={async () => {
|
wallet={wallet} onDelete={async () => {
|
||||||
|
@ -0,0 +1,3 @@
|
|||||||
|
-- AlterTable
|
||||||
|
ALTER TABLE "users" ADD COLUMN "tipRandomMax" INTEGER,
|
||||||
|
ADD COLUMN "tipRandomMin" INTEGER;
|
@ -30,6 +30,8 @@ model User {
|
|||||||
apiKeyHash String? @unique(map: "users.apikeyhash_unique") @db.Char(64)
|
apiKeyHash String? @unique(map: "users.apikeyhash_unique") @db.Char(64)
|
||||||
apiKeyEnabled Boolean @default(false)
|
apiKeyEnabled Boolean @default(false)
|
||||||
tipDefault Int @default(100)
|
tipDefault Int @default(100)
|
||||||
|
tipRandomMin Int?
|
||||||
|
tipRandomMax Int?
|
||||||
bioId Int?
|
bioId Int?
|
||||||
inviteId String?
|
inviteId String?
|
||||||
tipPopover Boolean @default(false)
|
tipPopover Boolean @default(false)
|
||||||
|
@ -64,10 +64,6 @@ Since `name` will also be used in [wallet logs](https://stacker.news/wallet/logs
|
|||||||
|
|
||||||
Wallet fields define what this wallet requires for configuration and thus are used to construct the forms like the one you can see at [/settings/wallets/lnbits](https://stacker.news/settings/walletslnbits).
|
Wallet fields define what this wallet requires for configuration and thus are used to construct the forms like the one you can see at [/settings/wallets/lnbits](https://stacker.news/settings/walletslnbits).
|
||||||
|
|
||||||
- `available?: boolean`
|
|
||||||
|
|
||||||
This property can be used to override the default behavior of the `enabled` checkbox in the wallet configuration form. By default, it will be clickable when a wallet is configured. However, if a wallet does not have any configuration, this checkbox will always be disabled. You can set `available` to an expression that will determine when a wallet can be enabled.
|
|
||||||
|
|
||||||
- `card: WalletCard`
|
- `card: WalletCard`
|
||||||
|
|
||||||
Wallet cards are the components you can see at [/settings/wallets](https://stacker.news/settings/wallets). This property customizes this card for this wallet.
|
Wallet cards are the components you can see at [/settings/wallets](https://stacker.news/settings/wallets). This property customizes this card for this wallet.
|
||||||
|
@ -137,11 +137,6 @@ function useConfig (wallet) {
|
|||||||
...(hasServerConfig ? serverConfig : {})
|
...(hasServerConfig ? serverConfig : {})
|
||||||
}
|
}
|
||||||
|
|
||||||
if (wallet?.available !== undefined && config.enabled !== undefined) {
|
|
||||||
// wallet must be available to be enabled
|
|
||||||
config.enabled &&= wallet.available
|
|
||||||
}
|
|
||||||
|
|
||||||
const saveConfig = useCallback(async (config) => {
|
const saveConfig = useCallback(async (config) => {
|
||||||
if (hasLocalConfig) setLocalConfig(config)
|
if (hasLocalConfig) setLocalConfig(config)
|
||||||
if (hasServerConfig) await setServerConfig(config)
|
if (hasServerConfig) await setServerConfig(config)
|
||||||
@ -265,13 +260,7 @@ export function getEnabledWallet (me) {
|
|||||||
const priority = config?.priority
|
const priority = config?.priority
|
||||||
return { ...def, config, priority }
|
return { ...def, config, priority }
|
||||||
})
|
})
|
||||||
.filter(({ available, config }) => {
|
.filter(({ config }) => config?.enabled)
|
||||||
if (available !== undefined && config?.enabled !== undefined) {
|
|
||||||
// wallet must be available to be enabled
|
|
||||||
config.enabled &&= available
|
|
||||||
}
|
|
||||||
return config?.enabled
|
|
||||||
})
|
|
||||||
.sort(walletPrioritySort)[0]
|
.sort(walletPrioritySort)[0]
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -1,10 +1,18 @@
|
|||||||
import { SSR } from '@/lib/constants'
|
|
||||||
|
|
||||||
export const name = 'webln'
|
export const name = 'webln'
|
||||||
|
|
||||||
export const fields = []
|
export const fields = []
|
||||||
|
|
||||||
export const available = SSR ? false : typeof window.webln !== 'undefined'
|
export const fieldValidation = ({ enabled }) => {
|
||||||
|
if (typeof window.webln === 'undefined') {
|
||||||
|
// don't prevent disabling WebLN if no WebLN provider found
|
||||||
|
if (enabled) {
|
||||||
|
return {
|
||||||
|
enabled: 'no WebLN provider found'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return {}
|
||||||
|
}
|
||||||
|
|
||||||
export const card = {
|
export const card = {
|
||||||
title: 'WebLN',
|
title: 'WebLN',
|
||||||
|
Loading…
x
Reference in New Issue
Block a user