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, useCallback, useContext, useEffect, useRef, useState } from 'react' import copy from 'clipboard-copy' 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 { TOP_USERS, USER_SEARCH } from '../fragments/users' import TextareaAutosize from 'react-textarea-autosize' import { useToast } from './toast' import { useInvoiceable } from './invoice' import { numWithUnits } from '../lib/format' import textAreaCaret from 'textarea-caret' import ReactDatePicker from 'react-datepicker' import 'react-datepicker/dist/react-datepicker.css' import { debounce } from './use-debounce-callback' 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 ( ) } export function CopyInput (props) { const toaster = useToast() const handleClick = async () => { try { await copy(props.placeholder) toaster.success('copied') } catch (err) { toaster.danger('failed to copy') } } return ( copy } {...props} /> ) } export function InputSkeleton ({ label, hint }) { return ( {label && {label}}
.
{hint && {hint} }
) } const DEFAULT_MENTION_INDICES = { start: -1, end: -1 } 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) const previousTab = useRef(tab) props.as ||= TextareaAutosize props.rows ||= props.minRows || 6 useEffect(() => { !meta.value && setTab('write') }, [meta.value]) useEffect(() => { // focus on input when switching to write tab from preview tab if (innerRef?.current && tab === 'write' && previousTab?.current !== 'write') { innerRef.current.focus() } previousTab.current = tab }, [tab]) 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]) const [mentionQuery, setMentionQuery] = useState() const [mentionIndices, setMentionIndices] = useState(DEFAULT_MENTION_INDICES) const [userSuggestDropdownStyle, setUserSuggestDropdownStyle] = useState({}) const insertMention = useCallback((name) => { const { start, end } = mentionIndices const first = `${innerRef.current.value.substring(0, start)}@${name}` const second = innerRef.current.value.substring(end) const updatedValue = `${first}${second}` innerRef.current.value = updatedValue helpers.setValue(updatedValue) setSelectionRange({ start: first.length, end: first.length }) innerRef.current.focus() }, [mentionIndices, innerRef, helpers?.setValue]) const onChangeInner = useCallback((formik, e) => { if (onChange) onChange(formik, e) if (setHasImgLink) { setHasImgLink(mdHas(e.target.value, ['link', 'image'])) } // check for mention editing const { value, selectionStart } = e.target let priorSpace = -1 for (let i = selectionStart - 1; i >= 0; i--) { if (/\s|\n/.test(value[i])) { priorSpace = i break } } let nextSpace = value.length for (let i = selectionStart; i <= value.length; i++) { if (/\s|\n/.test(value[i])) { nextSpace = i break } } const currentSegment = value.substring(priorSpace + 1, nextSpace) // set the query to the current character segment and note where it appears if (/^@[\w_]*$/.test(currentSegment)) { setMentionQuery(currentSegment) setMentionIndices({ start: priorSpace + 1, end: nextSpace }) const { top, left } = textAreaCaret(e.target, e.target.selectionStart) setUserSuggestDropdownStyle({ position: 'absolute', top: `${top + Number(window.getComputedStyle(e.target).lineHeight.replace('px', ''))}px`, left: `${left}px` }) } else { setMentionQuery(undefined) setMentionIndices(DEFAULT_MENTION_INDICES) } }, [onChange, setHasImgLink, setMentionQuery, setMentionIndices, setUserSuggestDropdownStyle]) const onKeyDownInner = useCallback((userSuggestOnKeyDown) => { return (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 (!metaOrCtrl) { userSuggestOnKeyDown(e) } if (onKeyDown) onKeyDown(e) } }, [innerRef, helpers?.setValue, setSelectionRange, onKeyDown]) return (
{({ onKeyDown: userSuggestOnKeyDown, resetSuggestions }) => ( setTimeout(resetSuggestions, 100)} />)}
{tab !== 'write' &&
{meta.value}
}
) } 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 ( {label && {label}} {children} ) } function InputInner ({ prepend, append, hint, showValid, onChange, onBlur, overrideValue, innerRef, noForm, clear, onKeyDown, inputGroupClassName, debounce: debounceTime, maxLength, ...props }) { const [field, meta, helpers] = noForm ? [{}, {}, {}] : useField(props) const formik = noForm ? null : useFormikContext() const storageKeyPrefix = useContext(StorageKeyPrefixContext) const storageKey = storageKeyPrefix ? storageKeyPrefix + '-' + props.name : undefined const onKeyDownInner = useCallback((e) => { const metaOrCtrl = e.metaKey || e.ctrlKey if (metaOrCtrl) { if (e.key === 'Enter') formik?.submitForm() } if (onKeyDown) onKeyDown(e) }, [formik?.submitForm, onKeyDown]) const onChangeInner = useCallback((e) => { field?.onChange(e) if (storageKey) { window.localStorage.setItem(storageKey, e.target.value) } if (onChange) { onChange(formik, e) } }, [field?.onChange, storageKey, onChange]) const onBlurInner = useCallback((e) => { field?.onBlur?.(e) onBlur && onBlur(e) }, [field?.onBlur, onBlur]) 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 useEffect(debounce(() => { if (!noForm && !isNaN(debounceTime) && debounceTime > 0) { formik.validateForm() } }, debounceTime), [noForm, formik, field.value]) const remaining = maxLength && maxLength - (field.value || '').length return ( <> {prepend} {(clear && field.value) && } {append} {meta.touched && meta.error} {hint && ( {hint} )} {maxLength && !(meta.touched && meta.error && invalid) && ( {`${numWithUnits(remaining, { abbreviate: false, unitSingular: 'character', unitPlural: 'characters' })} remaining`} )} ) } const INITIAL_SUGGESTIONS = { array: [], index: 0 } export function UserSuggest ({ query, onSelect, dropdownStyle, children, transformUser = user => user, selectWithTab = true, filterUsers = () => true }) { const [getUsers] = useLazyQuery(TOP_USERS, { onCompleted: data => { setSuggestions({ array: data.topUsers.users .filter((...args) => filterUsers(query, ...args)) .map(transformUser), index: 0 }) } }) const [getSuggestions] = useLazyQuery(USER_SEARCH, { onCompleted: data => { setSuggestions({ array: data.searchUsers .filter((...args) => filterUsers(query, ...args)) .map(transformUser), index: 0 }) } }) const [suggestions, setSuggestions] = useState(INITIAL_SUGGESTIONS) const resetSuggestions = useCallback(() => setSuggestions(INITIAL_SUGGESTIONS), []) useEffect(() => { if (query !== undefined) { // remove both the leading @ and any @domain after nym const q = query?.replace(/^[@ ]+|[ ]+$/g, '').replace(/@[^\s]*$/, '') if (q === '') { getUsers({ variables: { by: 'stacked', when: 'week', limit: 5 } }) } else { getSuggestions({ variables: { q } }) } } else { resetSuggestions() } }, [query]) const onKeyDown = useCallback(e => { switch (e.code) { case 'ArrowUp': if (suggestions.array.length === 0) { break } e.preventDefault() setSuggestions(suggestions => ({ ...suggestions, index: Math.max(suggestions.index - 1, 0) })) break case 'ArrowDown': if (suggestions.array.length === 0) { break } e.preventDefault() setSuggestions(suggestions => ({ ...suggestions, index: Math.min(suggestions.index + 1, suggestions.array.length - 1) })) break case 'Tab': case 'Enter': if (e.code === 'Tab' && !selectWithTab) { break } if (suggestions.array?.length === 0) { break } e.preventDefault() onSelect(suggestions.array[suggestions.index].name) resetSuggestions() break case 'Escape': e.preventDefault() resetSuggestions() break default: break } }, [onSelect, resetSuggestions, suggestions]) return ( <> {children?.({ onKeyDown, resetSuggestions })} 0} style={dropdownStyle}> {suggestions.array.map((v, i) => { onSelect(v.name) resetSuggestions() }} > {v.name} )} ) } export function InputUserSuggest ({ label, groupClassName, transformUser, filterUsers, selectWithTab, onChange, transformQuery, ...props }) { const [ovalue, setOValue] = useState() const [query, setQuery] = useState() return ( { // HACK ... ovalue does not trigger onChange onChange && onChange(undefined, { target: { value: v } }) setOValue(v) }} query={query} > {({ onKeyDown, resetSuggestions }) => ( { onChange && onChange(formik, e) setOValue(e.target.value) setQuery(e.target.value.replace(/^[@ ]+|[ ]+$/g, '')) }} overrideValue={ovalue} onKeyDown={onKeyDown} onBlur={() => setTimeout(resetSuggestions, 100)} /> )} ) } export function Input ({ label, groupClassName, ...props }) { return ( ) } export function VariableInput ({ label, groupClassName, name, hint, max, min, readOnlyLen, children, emptyItem = '', ...props }) { return ( {({ form, ...fieldArrayHelpers }) => { const options = form.values[name] return ( <> {options?.map((_, i) => (
{children ? children({ index: i, readOnly: i < readOnlyLen, placeholder: i >= min ? 'optional' : undefined }) : = min ? 'optional' : undefined} />} {options.length - 1 === i && options.length !== max ? fieldArrayHelpers.push(emptyItem)} /> // filler div for col alignment across rows :
} {options.length - 1 === i && <> {hint && {hint}} {form.touched[name] && typeof form.errors[name] === 'string' &&
{form.errors[name]}
} }
))} ) }} ) } 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 ( {hiddenLabel && {label}} { field.onChange(e) handleChange && handleChange(e.target.checked, helpers.setValue) }} />
{label}
{extra &&
{extra}
}
) } const StorageKeyPrefixContext = createContext() export function Form ({ initial, schema, onSubmit, children, initialError, validateImmediately, storageKeyPrefix, validateOnChange = true, invoiceable, innerRef, ...props }) { const toaster = useToast() const initialErrorToasted = useRef(false) useEffect(() => { if (initialError && !initialErrorToasted.current) { toaster.danger(initialError.message || initialError.toString?.()) initialErrorToasted.current = true } }, []) const clearLocalStorage = useCallback((values) => { 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}]`) }) } }) }, [storageKeyPrefix]) // 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, { callback: clearLocalStorage, ...options }) } const onSubmitInner = useCallback(async (values, ...args) => { try { if (onSubmit) { const options = await onSubmit(values, ...args) if (!storageKeyPrefix || options?.keepLocalStorage) return clearLocalStorage(values) } } catch (err) { console.log(err) toaster.danger(err.message || err.toString?.()) } }, [onSubmit, toaster, clearLocalStorage, storageKeyPrefix]) return ( {children} ) } 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 ( { if (field?.onChange) { field.onChange(e) } if (onChange) { onChange(formik, e) } }} isInvalid={invalid} > {items.map(item => )} {meta.touched && meta.error} ) } export function DatePicker ({ fromName, toName, noForm, onMount, ...props }) { const formik = noForm ? null : useFormikContext() const onChangeHandler = props.onChange const [,, fromHelpers] = noForm ? [{}, {}, {}] : useField({ ...props, name: fromName }) const [,, toHelpers] = noForm ? [{}, {}, {}] : useField({ ...props, name: toName }) useEffect(() => { if (onMount) { const [from, to] = onMount() fromHelpers.setValue(from) toHelpers.setValue(to) } }, []) return ( { fromHelpers.setValue(from?.toISOString()) toHelpers.setValue(to?.toISOString()) onChangeHandler(formik, [from, to], e) }} /> ) }