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, useMemo, 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 AddFileIcon from '@/svgs/file-upload-line.svg' import styles from './form.module.css' import Text from '@/components/text' import AddIcon from '@/svgs/add-fill.svg' import CloseIcon from '@/svgs/close-line.svg' import { gql, useLazyQuery } from '@apollo/client' import { USER_SUGGESTIONS } from '@/fragments/users' import TextareaAutosize from 'react-textarea-autosize' import { useToast } from './toast' import { numWithUnits } from '@/lib/format' import textAreaCaret from 'textarea-caret' import ReactDatePicker from 'react-datepicker' import 'react-datepicker/dist/react-datepicker.css' import useDebounceCallback, { debounce } from './use-debounce-callback' import { FileUpload } from './file-upload' import { AWS_S3_URL_REGEXP } from '@/lib/constants' import { whenRange } from '@/lib/time' import { useFeeButton } from './fee-button' import Thumb from '@/svgs/thumb-up-fill.svg' import Eye from '@/svgs/eye-fill.svg' import EyeClose from '@/svgs/eye-close-line.svg' import Info from './info' import { useMe } from './me' import classNames from 'classnames' export class SessionRequiredError extends Error { constructor () { super('session required') this.name = 'SessionRequiredError' } } export function SubmitButton ({ children, variant, valueName = 'submit', value, onClick, disabled, appendText, submittingText, className, ...props }) { const formik = useFormikContext() disabled ||= formik.isSubmitting submittingText ||= children return ( ) } export function CopyInput (props) { const toaster = useToast() const [copied, setCopied] = useState(false) const handleClick = async () => { try { await copy(props.placeholder) toaster.success('copied') setCopied(true) setTimeout(() => setCopied(false), 1500) } catch (err) { toaster.danger('failed to copy') } } return ( {copied ? : 'copy'} } {...props} /> ) } export function InputSkeleton ({ label, hint }) { return ( {label && {label}}
.
{hint && {hint} }
) } export function MarkdownInput ({ label, topLevel, groupClassName, onChange, 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 imageUploadRef = useRef(null) const previousTab = useRef(tab) const { merge, setDisabled: setSubmitDisabled } = useFeeButton() const [updateUploadFees] = useLazyQuery(gql` query uploadFees($s3Keys: [Int]!) { uploadFees(s3Keys: $s3Keys) { totalFees nUnpaid uploadFees bytes24h } }`, { fetchPolicy: 'no-cache', nextFetchPolicy: 'no-cache', onError: (err) => { console.error(err) }, onCompleted: ({ uploadFees }) => { merge({ uploadFees: { term: `+ ${numWithUnits(uploadFees.totalFees, { abbreviate: false })}`, label: 'upload fee', op: '+', modifier: cost => cost + uploadFees.totalFees, omit: !uploadFees.totalFees } }) } }) 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 [mention, setMention] = useState() const insertMention = useCallback((name) => { if (mention?.start === undefined || mention?.end === undefined) return const { start, end } = mention setMention(undefined) const first = `${meta?.value.substring(0, start)}@${name}` const second = meta?.value.substring(end) const updatedValue = `${first}${second}` helpers.setValue(updatedValue) setSelectionRange({ start: first.length, end: first.length }) innerRef.current.focus() }, [mention, meta?.value, helpers?.setValue]) const uploadFeesUpdate = useDebounceCallback( (text) => { const s3Keys = text ? [...text.matchAll(AWS_S3_URL_REGEXP)].map(m => Number(m[1])) : [] updateUploadFees({ variables: { s3Keys } }) }, 1000, [updateUploadFees]) const onChangeInner = useCallback((formik, e) => { if (onChange) onChange(formik, e) // check for mention editing const { value, selectionStart } = e.target uploadFeesUpdate(value) if (!value || selectionStart === undefined) { setMention(undefined) return } let priorSpace = -1 for (let i = selectionStart - 1; i >= 0; i--) { if (/[^\w@]/.test(value[i])) { priorSpace = i break } } let nextSpace = value.length for (let i = selectionStart; i <= value.length; i++) { if (/[^\w]/.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)) { const { top, left } = textAreaCaret(e.target, e.target.selectionStart) setMention({ query: currentSegment, start: priorSpace + 1, end: nextSpace, style: { position: 'absolute', top: `${top + Number(window.getComputedStyle(e.target).lineHeight.replace('px', ''))}px`, left: `${left}px` } }) } else { setMention(undefined) } }, [onChange, setMention, uploadFeesUpdate]) 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 === 'u') { // some browsers might use CTRL+U to do something else so prevent that behavior too e.preventDefault() imageUploadRef.current?.click() } 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]) const onPaste = useCallback((event) => { const items = event.clipboardData.items if (items.length === 0) { return } let isImagePasted = false const fileList = new window.DataTransfer() for (let i = 0; i < items.length; i++) { const item = items[i] if (item.type.indexOf('image') === 0) { const blob = item.getAsFile() const file = new File([blob], 'image', { type: blob.type }) fileList.items.add(file) isImagePasted = true } } if (isImagePasted) { event.preventDefault() const changeEvent = new Event('change', { bubbles: true }) imageUploadRef.current.files = fileList.files imageUploadRef.current.dispatchEvent(changeEvent) } }, [imageUploadRef]) const onDrop = useCallback((event) => { event.preventDefault() setDragStyle(null) const changeEvent = new Event('change', { bubbles: true }) imageUploadRef.current.files = event.dataTransfer.files imageUploadRef.current.dispatchEvent(changeEvent) }, [imageUploadRef]) const [dragStyle, setDragStyle] = useState(null) const onDragEnter = useCallback((e) => { setDragStyle('over') }, [setDragStyle]) const onDragLeave = useCallback((e) => { setDragStyle(null) }, [setDragStyle]) return (
{({ onKeyDown: userSuggestOnKeyDown, resetSuggestions }) => ( setTimeout(resetSuggestions, 500)} onDragEnter={onDragEnter} onDragLeave={onDragLeave} onDrop={onDrop} onPaste={onPaste} className={dragStyle === 'over' ? styles.dragOver : ''} />)}
{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, warn, showValid, onChange, onBlur, overrideValue, appendValue, 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 const isNumeric = /^[0-9]+$/.test(draft) const numericExpected = typeof field.value === 'number' helpers.setValue(isNumeric && numericExpected ? parseInt(draft) : draft) onChange && onChange(formik, { target: { value: draft } }) } } }, [overrideValue]) useEffect(() => { if (appendValue) { const updatedValue = meta.value ? `${meta.value}\n${appendValue}` : appendValue helpers.setValue(updatedValue) if (storageKey) { window.localStorage.setItem(storageKey, updatedValue) } innerRef?.current?.focus() } }, [appendValue]) 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} )} {warn && ( {warn} )} {!warn && 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 [getSuggestions] = useLazyQuery(USER_SUGGESTIONS, { onCompleted: data => { query !== undefined && setSuggestions({ array: data.userSuggestions .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]*$/, '') getSuggestions({ variables: { q, limit: 5 } }) } else { resetSuggestions() } }, [query, resetSuggestions, getSuggestions]) 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, 500)} /> )} ) } 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, type = 'checkbox', 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, meta, helpers] = useField({ ...props, type }) return ( {hiddenLabel && {label}} { field.onChange(e) handleChange && handleChange(e.target.checked, helpers.setValue) }} />
{label}
{extra &&
{extra}
}
) } export function CheckboxGroup ({ label, groupClassName, children, ...props }) { const [, meta] = useField(props) return ( {children} {/* force the feedback to display with d-block */} {meta.touched && meta.error} ) } const StorageKeyPrefixContext = createContext() export function Form ({ initial, validate, schema, onSubmit, children, initialError, validateImmediately, storageKeyPrefix, validateOnChange = true, requireSession, innerRef, enableReinitialize, ...props }) { const toaster = useToast() const initialErrorToasted = useRef(false) const { me } = useMe() useEffect(() => { if (initialError && !initialErrorToasted.current) { toaster.danger('form error: ' + 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]) const onSubmitInner = useCallback(async ({ amount, ...values }, ...args) => { const variables = { amount, ...values } if (requireSession && !me) { throw new SessionRequiredError() } try { if (onSubmit) { await onSubmit(variables, ...args) } } catch (err) { console.log(err.message, err) toaster.danger(err.message ?? err.toString?.()) return } if (!storageKeyPrefix) return clearLocalStorage(values) }, [me, onSubmit, clearLocalStorage, storageKeyPrefix]) return ( {children} ) } export function Select ({ label, items, info, groupClassName, onChange, noForm, overrideValue, hint, ...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 => { if (item && typeof item === 'object') { return ( {item.items.map(item => )} ) } else { return } })} {info && {info}} {meta.touched && meta.error} {hint && {hint} } ) } export function DatePicker ({ fromName, toName, noForm, onChange, when, from, to, className, ...props }) { const formik = noForm ? null : useFormikContext() const [,, fromHelpers] = noForm ? [{}, {}, {}] : useField({ ...props, name: fromName }) const [,, toHelpers] = noForm ? [{}, {}, {}] : useField({ ...props, name: toName }) const { minDate, maxDate } = props const [[innerFrom, innerTo], setRange] = useState(whenRange(when, from, to)) useEffect(() => { setRange(whenRange(when, from, to)) if (!noForm) { fromHelpers.setValue(from) toHelpers.setValue(to) } }, [when, from, to]) const dateFormat = useMemo(() => { const now = new Date(2013, 11, 31) let str = now.toLocaleDateString() str = str.replace('31', 'dd') str = str.replace('12', 'MM') str = str.replace('2013', 'yy') return str }, []) const innerOnChange = ([from, to], e) => { if (from) { from = new Date(new Date(from).setHours(0, 0, 0, 0)) } if (to) { to = new Date(new Date(to).setHours(23, 59, 59, 999)) } setRange([from, to]) if (!noForm) { fromHelpers.setValue(from) toHelpers.setValue(to) } if (!from || !to) return onChange?.(formik, [from, to], e) } const onChangeRawHandler = (e) => { // raw user data can be incomplete while typing, so quietly bail on exceptions try { const dateStrings = e.target.value.split('-', 2) const dates = dateStrings.map(s => new Date(s)) let [from, to] = dates if (from) { from = new Date(from.setHours(0, 0, 0, 0)) if (minDate) from = new Date(Math.max(from.getTime(), minDate.getTime())) try { if (to) { to = new Date(to.setHours(23, 59, 59, 999)) if (maxDate) to = new Date(Math.min(to.getTime(), maxDate.getTime())) } // if end date isn't valid, set it to the start date if (!(to instanceof Date && !isNaN(to)) || to < from) to = new Date(from.setHours(23, 59, 59, 999)) } catch { to = new Date(from.setHours(23, 59, 59, 999)) } innerOnChange([from, to], e) } } catch { } } return ( ) } export function DateTimeInput ({ label, groupClassName, name, ...props }) { const [, meta] = useField({ ...props, name }) return (
{meta.error}
) } function DateTimePicker ({ name, className, ...props }) { const [field, , helpers] = useField({ ...props, name }) return ( { helpers.setValue(val) }} /> ) } function Client (Component) { return ({ initialValue, ...props }) => { // This component can be used for Formik fields // where the initial value is not available on first render. // Example: value is stored in localStorage which is fetched // after first render using an useEffect hook. const [,, helpers] = useField(props) useEffect(() => { initialValue && helpers.setValue(initialValue) }, [initialValue]) return } } function PasswordHider ({ onClick, showPass }) { return ( {!showPass ? : } ) } export function PasswordInput ({ newPass, ...props }) { const [showPass, setShowPass] = useState(false) return ( setShowPass(!showPass)} />} /> ) } export const ClientInput = Client(Input) export const ClientCheckbox = Client(Checkbox)