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
|
||||||
|
@ -202,3 +202,68 @@ From the [postgres docs](https://www.postgresql.org/docs/current/transaction-iso
|
|||||||
|
|
||||||
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