ac45fdc234
* Use HODL invoices * Fix expiry check comparing string with Date * Fix unconfirmed user balance for HODL invoices This is done by syncing the data from LND to the Invoice table. If the columns is_held and msatsReceived are set, the frontend is told that we're ready to execute the action. We then update the user balance in the same tx as the action. We need to still keep checking the invoice for expiration though. * Fix worker acting upon deleted invoices * Prevent usage of invoice after expiration * Use onComplete from <Countdown> to show expired status * Remove unused lnd argument * Fix item destructuring from query * Fix balance added to every stacker * Fix hmac required * Fix invoices not used when logged in * refactor: move invoiceable code into form * renamed invoiceHash, invoiceHmac to hash, hmac since it's less verbose all over the place * form now supports `invoiceable` in its props * form then wraps `onSubmit` with `useInvoiceable` and passes optional invoice options * Show expired if expired and canceled * Also use useCallback for zapping * Always expire modal invoices after 3m * little styling thing --------- Co-authored-by: ekzyis <ek@stacker.news> Co-authored-by: keyan <keyan.kousha+huumn@gmail.com> Co-authored-by: Keyan <34140557+huumn@users.noreply.github.com>
571 lines
20 KiB
JavaScript
571 lines
20 KiB
JavaScript
import Button from 'react-bootstrap/Button'
|
|
import InputGroup from 'react-bootstrap/InputGroup'
|
|
import BootstrapForm from 'react-bootstrap/Form'
|
|
import { Formik, Form as FormikForm, useFormikContext, useField, FieldArray } from 'formik'
|
|
import React, { createContext, useContext, useEffect, useRef, useState } from 'react'
|
|
import copy from 'clipboard-copy'
|
|
import Thumb from '../svgs/thumb-up-fill.svg'
|
|
import Col from 'react-bootstrap/Col'
|
|
import Dropdown from 'react-bootstrap/Dropdown'
|
|
import Nav from 'react-bootstrap/Nav'
|
|
import Row from 'react-bootstrap/Row'
|
|
import Markdown from '../svgs/markdown-line.svg'
|
|
import styles from './form.module.css'
|
|
import Text from '../components/text'
|
|
import AddIcon from '../svgs/add-fill.svg'
|
|
import { mdHas } from '../lib/md'
|
|
import CloseIcon from '../svgs/close-line.svg'
|
|
import { useLazyQuery } from '@apollo/client'
|
|
import { USER_SEARCH } from '../fragments/users'
|
|
import TextareaAutosize from 'react-textarea-autosize'
|
|
import { useToast } from './toast'
|
|
import { useInvoiceable } from './invoice'
|
|
|
|
export function SubmitButton ({
|
|
children, variant, value, onClick, disabled, cost, ...props
|
|
}) {
|
|
const formik = useFormikContext()
|
|
useEffect(() => {
|
|
formik?.setFieldValue('cost', cost)
|
|
}, [formik?.setFieldValue, formik?.getFieldProps('cost').value, cost])
|
|
|
|
return (
|
|
<Button
|
|
variant={variant || 'main'}
|
|
type='submit'
|
|
disabled={disabled || formik.isSubmitting}
|
|
onClick={value
|
|
? e => {
|
|
formik.setFieldValue('submit', value)
|
|
onClick && onClick(e)
|
|
}
|
|
: onClick}
|
|
{...props}
|
|
>
|
|
{children}
|
|
</Button>
|
|
)
|
|
}
|
|
|
|
export function CopyInput (props) {
|
|
const [copied, setCopied] = useState(false)
|
|
|
|
const handleClick = () => {
|
|
copy(props.placeholder)
|
|
setCopied(true)
|
|
setTimeout(() => setCopied(false), 1500)
|
|
}
|
|
|
|
return (
|
|
<Input
|
|
onClick={handleClick}
|
|
append={
|
|
<Button
|
|
className={styles.appendButton}
|
|
size={props.size}
|
|
onClick={handleClick}
|
|
>
|
|
{copied ? <Thumb width={18} height={18} /> : 'copy'}
|
|
</Button>
|
|
}
|
|
{...props}
|
|
/>
|
|
)
|
|
}
|
|
|
|
export function InputSkeleton ({ label, hint }) {
|
|
return (
|
|
<BootstrapForm.Group>
|
|
{label && <BootstrapForm.Label>{label}</BootstrapForm.Label>}
|
|
<div className='form-control clouds' style={{ color: 'transparent' }}>.</div>
|
|
{hint &&
|
|
<BootstrapForm.Text>
|
|
{hint}
|
|
</BootstrapForm.Text>}
|
|
</BootstrapForm.Group>
|
|
)
|
|
}
|
|
|
|
export function MarkdownInput ({ label, topLevel, groupClassName, onChange, setHasImgLink, onKeyDown, innerRef, ...props }) {
|
|
const [tab, setTab] = useState('write')
|
|
const [, meta, helpers] = useField(props)
|
|
const [selectionRange, setSelectionRange] = useState({ start: 0, end: 0 })
|
|
innerRef = innerRef || useRef(null)
|
|
|
|
props.as ||= TextareaAutosize
|
|
props.rows ||= props.minRows || 6
|
|
|
|
useEffect(() => {
|
|
!meta.value && setTab('write')
|
|
}, [meta.value])
|
|
|
|
useEffect(() => {
|
|
if (selectionRange.start <= selectionRange.end && innerRef?.current) {
|
|
const { start, end } = selectionRange
|
|
const input = innerRef.current
|
|
input.setSelectionRange(start, end)
|
|
}
|
|
}, [innerRef, selectionRange.start, selectionRange.end])
|
|
|
|
return (
|
|
<FormGroup label={label} className={groupClassName}>
|
|
<div className={`${styles.markdownInput} ${tab === 'write' ? styles.noTopLeftRadius : ''}`}>
|
|
<Nav variant='tabs' defaultActiveKey='write' activeKey={tab} onSelect={tab => setTab(tab)}>
|
|
<Nav.Item>
|
|
<Nav.Link eventKey='write'>write</Nav.Link>
|
|
</Nav.Item>
|
|
<Nav.Item>
|
|
<Nav.Link eventKey='preview' disabled={!meta.value}>preview</Nav.Link>
|
|
</Nav.Item>
|
|
<a
|
|
className='ms-auto text-muted d-flex align-items-center'
|
|
href='https://guides.github.com/features/mastering-markdown/' target='_blank' rel='noreferrer'
|
|
>
|
|
<Markdown width={18} height={18} />
|
|
</a>
|
|
</Nav>
|
|
{tab === 'write'
|
|
? (
|
|
<div>
|
|
<InputInner
|
|
{...props} onChange={(formik, e) => {
|
|
if (onChange) onChange(formik, e)
|
|
if (setHasImgLink) {
|
|
setHasImgLink(mdHas(e.target.value, ['link', 'image']))
|
|
}
|
|
}}
|
|
innerRef={innerRef}
|
|
onKeyDown={(e) => {
|
|
const metaOrCtrl = e.metaKey || e.ctrlKey
|
|
if (metaOrCtrl) {
|
|
if (e.key === 'k') {
|
|
// some browsers use CTRL+K to focus search bar so we have to prevent that behavior
|
|
e.preventDefault()
|
|
insertMarkdownLinkFormatting(innerRef.current, helpers.setValue, setSelectionRange)
|
|
}
|
|
if (e.key === 'b') {
|
|
// some browsers use CTRL+B to open bookmarks so we have to prevent that behavior
|
|
e.preventDefault()
|
|
insertMarkdownBoldFormatting(innerRef.current, helpers.setValue, setSelectionRange)
|
|
}
|
|
if (e.key === 'i') {
|
|
// some browsers might use CTRL+I to do something else so prevent that behavior too
|
|
e.preventDefault()
|
|
insertMarkdownItalicFormatting(innerRef.current, helpers.setValue, setSelectionRange)
|
|
}
|
|
if (e.key === 'Tab' && e.altKey) {
|
|
e.preventDefault()
|
|
insertMarkdownTabFormatting(innerRef.current, helpers.setValue, setSelectionRange)
|
|
}
|
|
}
|
|
|
|
if (onKeyDown) onKeyDown(e)
|
|
}}
|
|
/>
|
|
</div>)
|
|
: (
|
|
<div className='form-group'>
|
|
<div className={`${styles.text} form-control`}>
|
|
<Text topLevel={topLevel} noFragments fetchOnlyImgProxy={false}>{meta.value}</Text>
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
</div>
|
|
</FormGroup>
|
|
)
|
|
}
|
|
|
|
function insertMarkdownFormatting (replaceFn, selectFn) {
|
|
return function (input, setValue, setSelectionRange) {
|
|
const start = input.selectionStart
|
|
const end = input.selectionEnd
|
|
const val = input.value
|
|
const selectedText = val.substring(start, end)
|
|
const mdFormatted = replaceFn(selectedText)
|
|
const newVal = val.substring(0, start) + mdFormatted + val.substring(end)
|
|
setValue(newVal)
|
|
// required for undo, see https://stackoverflow.com/a/27028258
|
|
document.execCommand('insertText', false, mdFormatted)
|
|
// see https://github.com/facebook/react/issues/6483
|
|
// for why we don't use `input.setSelectionRange` directly (hint: event order)
|
|
setSelectionRange(selectFn ? selectFn(start, end, mdFormatted) : { start: start + mdFormatted.length, end: start + mdFormatted.length })
|
|
}
|
|
}
|
|
|
|
const insertMarkdownTabFormatting = insertMarkdownFormatting(
|
|
val => `\t${val}`,
|
|
(start, end, mdFormatted) => ({ start: start + 1, end: end + 1 }) // move inside tab
|
|
)
|
|
const insertMarkdownLinkFormatting = insertMarkdownFormatting(
|
|
val => `[${val}](url)`,
|
|
(start, end, mdFormatted) => (
|
|
start === end
|
|
? { start: start + 1, end: end + 1 } // move inside brackets
|
|
: { start: start + mdFormatted.length - 4, end: start + mdFormatted.length - 1 }) // move to select url part
|
|
)
|
|
const insertMarkdownBoldFormatting = insertMarkdownFormatting(
|
|
val => `**${val}**`,
|
|
(start, end, mdFormatted) => ({ start: start + 2, end: end + 2 }) // move inside bold
|
|
)
|
|
const insertMarkdownItalicFormatting = insertMarkdownFormatting(
|
|
val => `_${val}_`,
|
|
(start, end, mdFormatted) => ({ start: start + 1, end: end + 1 }) // move inside italic
|
|
)
|
|
|
|
function FormGroup ({ className, label, children }) {
|
|
return (
|
|
<BootstrapForm.Group className={`form-group ${className}`}>
|
|
{label && <BootstrapForm.Label>{label}</BootstrapForm.Label>}
|
|
{children}
|
|
</BootstrapForm.Group>
|
|
)
|
|
}
|
|
|
|
function InputInner ({
|
|
prepend, append, hint, showValid, onChange, onBlur, overrideValue,
|
|
innerRef, noForm, clear, onKeyDown, inputGroupClassName, debounce, ...props
|
|
}) {
|
|
const [field, meta, helpers] = noForm ? [{}, {}, {}] : useField(props)
|
|
const formik = noForm ? null : useFormikContext()
|
|
const storageKeyPrefix = useContext(StorageKeyPrefixContext)
|
|
|
|
const storageKey = storageKeyPrefix ? storageKeyPrefix + '-' + props.name : undefined
|
|
|
|
useEffect(() => {
|
|
if (overrideValue) {
|
|
helpers.setValue(overrideValue)
|
|
if (storageKey) {
|
|
window.localStorage.setItem(storageKey, overrideValue)
|
|
}
|
|
} else if (storageKey) {
|
|
const draft = window.localStorage.getItem(storageKey)
|
|
if (draft) {
|
|
// for some reason we have to turn off validation to get formik to
|
|
// not assume this is invalid
|
|
helpers.setValue(draft, false)
|
|
}
|
|
}
|
|
}, [overrideValue])
|
|
|
|
const invalid = (!formik || formik.submitCount > 0) && meta.touched && meta.error
|
|
|
|
const debounceRef = useRef(-1)
|
|
|
|
useEffect(() => {
|
|
if (debounceRef.current !== -1) {
|
|
clearTimeout(debounceRef.current)
|
|
}
|
|
if (!noForm && !isNaN(debounce) && debounce > 0) {
|
|
debounceRef.current = setTimeout(() => formik.validateForm(), debounce)
|
|
}
|
|
return () => clearTimeout(debounceRef.current)
|
|
}, [noForm, formik, field.value])
|
|
|
|
return (
|
|
<>
|
|
<InputGroup hasValidation className={inputGroupClassName}>
|
|
{prepend}
|
|
<BootstrapForm.Control
|
|
onKeyDown={(e) => {
|
|
const metaOrCtrl = e.metaKey || e.ctrlKey
|
|
if (metaOrCtrl) {
|
|
if (e.key === 'Enter') formik?.submitForm()
|
|
}
|
|
|
|
if (onKeyDown) onKeyDown(e)
|
|
}}
|
|
ref={innerRef}
|
|
{...field} {...props}
|
|
onChange={(e) => {
|
|
field.onChange(e)
|
|
|
|
if (storageKey) {
|
|
window.localStorage.setItem(storageKey, e.target.value)
|
|
}
|
|
|
|
if (onChange) {
|
|
onChange(formik, e)
|
|
}
|
|
}}
|
|
onBlur={(e) => {
|
|
field.onBlur(e)
|
|
onBlur && onBlur(e)
|
|
}}
|
|
isInvalid={invalid}
|
|
isValid={showValid && meta.initialValue !== meta.value && meta.touched && !meta.error}
|
|
/>
|
|
{(clear && field.value) &&
|
|
<Button
|
|
variant={null}
|
|
onClick={(e) => {
|
|
helpers.setValue('')
|
|
if (storageKey) {
|
|
window.localStorage.removeItem(storageKey)
|
|
}
|
|
if (onChange) {
|
|
onChange(formik, { target: { value: '' } })
|
|
}
|
|
}}
|
|
className={`${styles.clearButton} ${styles.appendButton} ${invalid ? styles.isInvalid : ''}`}
|
|
><CloseIcon className='fill-grey' height={20} width={20} />
|
|
</Button>}
|
|
{append}
|
|
<BootstrapForm.Control.Feedback type='invalid'>
|
|
{meta.touched && meta.error}
|
|
</BootstrapForm.Control.Feedback>
|
|
</InputGroup>
|
|
{hint && (
|
|
<BootstrapForm.Text>
|
|
{hint}
|
|
</BootstrapForm.Text>
|
|
)}
|
|
</>
|
|
)
|
|
}
|
|
|
|
export function InputUserSuggest ({ label, groupClassName, ...props }) {
|
|
const [getSuggestions] = useLazyQuery(USER_SEARCH, {
|
|
onCompleted: data => {
|
|
setSuggestions({ array: data.searchUsers, index: 0 })
|
|
}
|
|
})
|
|
|
|
const INITIAL_SUGGESTIONS = { array: [], index: 0 }
|
|
const [suggestions, setSuggestions] = useState(INITIAL_SUGGESTIONS)
|
|
const [ovalue, setOValue] = useState()
|
|
return (
|
|
<FormGroup label={label} className={groupClassName}>
|
|
<InputInner
|
|
{...props}
|
|
autoComplete='off'
|
|
onChange={(_, e) => {
|
|
setOValue(e.target.value)
|
|
getSuggestions({ variables: { q: e.target.value.replace(/^[@ ]+|[ ]+$/g, '') } })
|
|
}}
|
|
overrideValue={ovalue}
|
|
onKeyDown={(e) => {
|
|
switch (e.code) {
|
|
case 'ArrowUp':
|
|
e.preventDefault()
|
|
setSuggestions(
|
|
{
|
|
...suggestions,
|
|
index: Math.max(suggestions.index - 1, 0)
|
|
})
|
|
break
|
|
case 'ArrowDown':
|
|
e.preventDefault()
|
|
setSuggestions(
|
|
{
|
|
...suggestions,
|
|
index: Math.min(suggestions.index + 1, suggestions.array.length - 1)
|
|
})
|
|
break
|
|
case 'Enter':
|
|
e.preventDefault()
|
|
setOValue(suggestions.array[suggestions.index].name)
|
|
setSuggestions(INITIAL_SUGGESTIONS)
|
|
break
|
|
case 'Escape':
|
|
e.preventDefault()
|
|
setSuggestions(INITIAL_SUGGESTIONS)
|
|
break
|
|
default:
|
|
break
|
|
}
|
|
}}
|
|
/>
|
|
<Dropdown show={suggestions.array.length > 0}>
|
|
<Dropdown.Menu className={styles.suggestionsMenu}>
|
|
{suggestions.array.map((v, i) =>
|
|
<Dropdown.Item
|
|
key={v.name}
|
|
active={suggestions.index === i}
|
|
onClick={() => {
|
|
setOValue(v.name)
|
|
setSuggestions(INITIAL_SUGGESTIONS)
|
|
}}
|
|
>
|
|
{v.name}
|
|
</Dropdown.Item>)}
|
|
</Dropdown.Menu>
|
|
</Dropdown>
|
|
</FormGroup>
|
|
)
|
|
}
|
|
|
|
export function Input ({ label, groupClassName, ...props }) {
|
|
return (
|
|
<FormGroup label={label} className={groupClassName}>
|
|
<InputInner {...props} />
|
|
</FormGroup>
|
|
)
|
|
}
|
|
|
|
export function VariableInput ({ label, groupClassName, name, hint, max, min, readOnlyLen, children, emptyItem = '', ...props }) {
|
|
return (
|
|
<FormGroup label={label} className={groupClassName}>
|
|
<FieldArray name={name} hasValidation>
|
|
{({ form, ...fieldArrayHelpers }) => {
|
|
const options = form.values[name]
|
|
return (
|
|
<>
|
|
{options?.map((_, i) => (
|
|
<div key={i}>
|
|
<Row className='mb-2'>
|
|
<Col>
|
|
{children
|
|
? children({ index: i, readOnly: i < readOnlyLen, placeholder: i >= min ? 'optional' : undefined })
|
|
: <InputInner name={`${name}[${i}]`} {...props} readOnly={i < readOnlyLen} placeholder={i >= min ? 'optional' : undefined} />}
|
|
</Col>
|
|
<Col className='d-flex ps-0' xs='auto'>
|
|
{options.length - 1 === i && options.length !== max
|
|
? <AddIcon className='fill-grey align-self-center justify-self-center pointer' onClick={() => fieldArrayHelpers.push(emptyItem)} />
|
|
// filler div for col alignment across rows
|
|
: <div style={{ width: '24px', height: '24px' }} />}
|
|
</Col>
|
|
{options.length - 1 === i &&
|
|
<>
|
|
{hint && <BootstrapForm.Text>{hint}</BootstrapForm.Text>}
|
|
{form.touched[name] && typeof form.errors[name] === 'string' &&
|
|
<div className='invalid-feedback d-block'>{form.errors[name]}</div>}
|
|
</>}
|
|
</Row>
|
|
</div>
|
|
))}
|
|
</>
|
|
)
|
|
}}
|
|
</FieldArray>
|
|
</FormGroup>
|
|
)
|
|
}
|
|
|
|
export function Checkbox ({ children, label, groupClassName, hiddenLabel, extra, handleChange, inline, disabled, ...props }) {
|
|
// React treats radios and checkbox inputs differently other input types, select, and textarea.
|
|
// Formik does this too! When you specify `type` to useField(), it will
|
|
// return the correct bag of props for you
|
|
const [field,, helpers] = useField({ ...props, type: 'checkbox' })
|
|
return (
|
|
<FormGroup className={groupClassName}>
|
|
{hiddenLabel && <BootstrapForm.Label className='invisible'>{label}</BootstrapForm.Label>}
|
|
<BootstrapForm.Check
|
|
id={props.id || props.name}
|
|
inline={inline}
|
|
>
|
|
<BootstrapForm.Check.Input
|
|
{...field} {...props} disabled={disabled} type='checkbox' onChange={(e) => {
|
|
field.onChange(e)
|
|
handleChange && handleChange(e.target.checked, helpers.setValue)
|
|
}}
|
|
/>
|
|
<BootstrapForm.Check.Label className={'d-inline-flex flex-nowrap align-items-center' + (disabled ? ' text-muted' : '')}>
|
|
<div className='flex-grow-1'>{label}</div>
|
|
{extra &&
|
|
<div className={styles.checkboxExtra}>
|
|
{extra}
|
|
</div>}
|
|
</BootstrapForm.Check.Label>
|
|
</BootstrapForm.Check>
|
|
</FormGroup>
|
|
)
|
|
}
|
|
|
|
const StorageKeyPrefixContext = createContext()
|
|
|
|
export function Form ({
|
|
initial, schema, onSubmit, children, initialError, validateImmediately, storageKeyPrefix, validateOnChange = true, invoiceable, ...props
|
|
}) {
|
|
const toaster = useToast()
|
|
useEffect(() => {
|
|
if (initialError) {
|
|
toaster.danger(initialError.message || initialError.toString?.())
|
|
}
|
|
}, [])
|
|
|
|
// if `invoiceable` is set,
|
|
// support for payment per invoice if they are lurking or don't have enough balance
|
|
// is added to submit handlers.
|
|
// submit handlers need to accept { satsReceived, hash, hmac } in their first argument
|
|
// and use them as variables in their GraphQL mutation
|
|
if (invoiceable && onSubmit) {
|
|
const options = typeof invoiceable === 'object' ? invoiceable : undefined
|
|
onSubmit = useInvoiceable(onSubmit, options)
|
|
}
|
|
|
|
return (
|
|
<Formik
|
|
initialValues={initial}
|
|
validateOnChange={validateOnChange}
|
|
validationSchema={schema}
|
|
initialTouched={validateImmediately && initial}
|
|
validateOnBlur={false}
|
|
onSubmit={async (values, ...args) => {
|
|
try {
|
|
if (onSubmit) {
|
|
const options = await onSubmit(values, ...args)
|
|
if (!storageKeyPrefix || options?.keepLocalStorage) return
|
|
Object.keys(values).forEach(v => {
|
|
window.localStorage.removeItem(storageKeyPrefix + '-' + v)
|
|
if (Array.isArray(values[v])) {
|
|
values[v].forEach(
|
|
(iv, i) => {
|
|
Object.keys(iv).forEach(k => {
|
|
window.localStorage.removeItem(`${storageKeyPrefix}-${v}[${i}].${k}`)
|
|
})
|
|
window.localStorage.removeItem(`${storageKeyPrefix}-${v}[${i}]`)
|
|
})
|
|
}
|
|
})
|
|
}
|
|
} catch (err) {
|
|
console.log(err)
|
|
toaster.danger(err.message || err.toString?.())
|
|
}
|
|
}}
|
|
>
|
|
<FormikForm {...props} noValidate>
|
|
<StorageKeyPrefixContext.Provider value={storageKeyPrefix}>
|
|
{children}
|
|
</StorageKeyPrefixContext.Provider>
|
|
</FormikForm>
|
|
</Formik>
|
|
)
|
|
}
|
|
|
|
export function Select ({ label, items, groupClassName, onChange, noForm, overrideValue, ...props }) {
|
|
const [field, meta, helpers] = noForm ? [{}, {}, {}] : useField(props)
|
|
const formik = noForm ? null : useFormikContext()
|
|
const invalid = meta.touched && meta.error
|
|
|
|
useEffect(() => {
|
|
if (overrideValue) {
|
|
helpers.setValue(overrideValue)
|
|
}
|
|
}, [overrideValue])
|
|
|
|
return (
|
|
<FormGroup label={label} className={groupClassName}>
|
|
<BootstrapForm.Select
|
|
{...field} {...props}
|
|
onChange={(e) => {
|
|
if (field?.onChange) {
|
|
field.onChange(e)
|
|
}
|
|
|
|
if (onChange) {
|
|
onChange(formik, e)
|
|
}
|
|
}}
|
|
isInvalid={invalid}
|
|
>
|
|
{items.map(item => <option key={item}>{item}</option>)}
|
|
</BootstrapForm.Select>
|
|
<BootstrapForm.Control.Feedback type='invalid'>
|
|
{meta.touched && meta.error}
|
|
</BootstrapForm.Control.Feedback>
|
|
</FormGroup>
|
|
)
|
|
}
|