import { useState, useEffect, useMemo, useCallback } from 'react' import AccordianItem from './accordian-item' import { Input, InputUserSuggest, VariableInput, Checkbox } from './form' import InputGroup from 'react-bootstrap/InputGroup' import { BOOST_MIN, BOOST_MULT, MAX_FORWARDS, SSR } from '@/lib/constants' import { DEFAULT_CROSSPOSTING_RELAYS } from '@/lib/nostr' import Info from './info' import { abbrNum, numWithUnits } from '@/lib/format' import styles from './adv-post-form.module.css' import { useMe } from './me' import { useFeeButton } from './fee-button' import { useRouter } from 'next/router' import { useFormikContext } from 'formik' import { gql, useQuery } from '@apollo/client' import useDebounceCallback from './use-debounce-callback' import { Button } from 'react-bootstrap' import classNames from 'classnames' const EMPTY_FORWARD = { nym: '', pct: '' } export function AdvPostInitial ({ forward, boost }) { return { boost: boost || '', forward: forward?.length ? forward : [EMPTY_FORWARD] } } const FormStatus = { DIRTY: 'dirty', ERROR: 'error' } export function BoostHelp () { return ( <ol style={{ lineHeight: 1.25 }}> <li>Boost ranks items higher based on the amount</li> <li>The highest boost in a territory over the last 30 days is pinned to the top of the territory</li> <li>The highest boost across all territories over the last 30 days is pinned to the top of the homepage</li> <li>The minimum boost is {numWithUnits(BOOST_MIN, { abbreviate: false })}</li> <li>Each {numWithUnits(BOOST_MULT, { abbreviate: false })} of boost is equivalent to a zap-vote from a maximally trusted stacker <ul> <li>e.g. {numWithUnits(BOOST_MULT * 5, { abbreviate: false })} is like five zap-votes from a maximally trusted stacker</li> </ul> </li> <li>The decay of boost "votes" increases at 1.25x the rate of organic votes <ul> <li>i.e. boost votes fall out of ranking faster</li> </ul> </li> <li>boost can take a few minutes to show higher ranking in feed</li> <li>100% of boost goes to the territory founder and top stackers as rewards</li> </ol> ) } export function BoostInput ({ onChange, ...props }) { const feeButton = useFeeButton() let merge if (feeButton) { ({ merge } = feeButton) } return ( <Input label={ <div className='d-flex align-items-center'>boost <Info> <BoostHelp /> </Info> </div> } name='boost' onChange={(_, e) => { merge?.({ boost: { term: `+ ${e.target.value}`, label: 'boost', op: '+', modifier: cost => cost + Number(e.target.value) } }) onChange && onChange(_, e) }} hint={<span className='text-muted'>ranks posts higher temporarily based on the amount</span>} append={<InputGroup.Text className='text-monospace'>sats</InputGroup.Text>} {...props} /> ) } const BoostMaxes = ({ subName, homeMax, subMax, boost, updateBoost }) => { return ( <div className='d-flex flex-row mb-2'> <Button className={classNames(styles.boostMax, 'me-2', homeMax + BOOST_MULT <= (boost || 0) && 'invisible')} size='sm' onClick={() => updateBoost(homeMax + BOOST_MULT)} > {abbrNum(homeMax + BOOST_MULT)} <small>top of homepage</small> </Button> {subName && <Button className={classNames(styles.boostMax, subMax + BOOST_MULT <= (boost || 0) && 'invisible')} size='sm' onClick={() => updateBoost(subMax + BOOST_MULT)} > {abbrNum(subMax + BOOST_MULT)} <small>top of ~{subName}</small> </Button>} </div> ) } // act means we are adding to existing boost export function BoostItemInput ({ item, sub, act = false, ...props }) { // act adds boost to existing boost const existingBoost = act ? Number(item?.boost || 0) : 0 const [boost, setBoost] = useState(act ? 0 : Number(item?.boost || 0)) const { data, previousData, refetch } = useQuery(gql` query BoostPosition($sub: String, $id: ID, $boost: Int) { boostPosition(sub: $sub, id: $id, boost: $boost) { home sub homeMaxBoost subMaxBoost } }`, { variables: { sub: item?.subName || sub?.name, boost: existingBoost + boost, id: item?.id }, fetchPolicy: 'cache-and-network', skip: !!item?.parentId || SSR }) const getPositionDebounce = useDebounceCallback((...args) => refetch(...args), 1000, [refetch]) const updateBoost = useCallback((boost) => { const boostToUse = Number(boost || 0) setBoost(boostToUse) getPositionDebounce({ sub: item?.subName || sub?.name, boost: Number(existingBoost + boostToUse), id: item?.id }) }, [getPositionDebounce, item?.id, item?.subName, sub?.name, existingBoost]) const dat = data || previousData const boostMessage = useMemo(() => { if (!item?.parentId && boost >= BOOST_MULT) { if (dat?.boostPosition?.home || dat?.boostPosition?.sub || boost > dat?.boostPosition?.homeMaxBoost || boost > dat?.boostPosition?.subMaxBoost) { const boostPinning = [] if (dat?.boostPosition?.home || boost > dat?.boostPosition?.homeMaxBoost) { boostPinning.push('homepage') } if ((item?.subName || sub?.name) && (dat?.boostPosition?.sub || boost > dat?.boostPosition?.subMaxBoost)) { boostPinning.push(`~${item?.subName || sub?.name}`) } return `pins to the top of ${boostPinning.join(' and ')}` } } return 'ranks posts higher based on the amount' }, [boost, dat?.boostPosition?.home, dat?.boostPosition?.sub, item?.subName, sub?.name]) return ( <> <BoostInput hint={<span className='text-muted'>{boostMessage}</span>} onChange={(_, e) => { if (e.target.value >= 0) { updateBoost(Number(e.target.value)) } }} overrideValue={boost} {...props} groupClassName='mb-1' /> {!item?.parentId && <BoostMaxes subName={item?.subName || sub?.name} homeMax={(dat?.boostPosition?.homeMaxBoost || 0) - existingBoost} subMax={(dat?.boostPosition?.subMaxBoost || 0) - existingBoost} boost={existingBoost + boost} updateBoost={updateBoost} />} </> ) } export default function AdvPostForm ({ children, item, sub, storageKeyPrefix }) { const { me } = useMe() const router = useRouter() const [itemType, setItemType] = useState() const formik = useFormikContext() const [show, setShow] = useState(false) useEffect(() => { const isDirty = formik?.values.forward?.[0].nym !== '' || formik?.values.forward?.[0].pct !== '' || formik?.values.boost !== '' || (router.query?.type === 'link' && formik?.values.text !== '') // if the adv post form is dirty on first render, show the accordian if (isDirty) { setShow(FormStatus.DIRTY) } // HACK ... TODO: we should generically handle this kind of local storage stuff // in the form component, overriding the initial values if (storageKeyPrefix) { for (let i = 0; i < MAX_FORWARDS; i++) { ['nym', 'pct'].forEach(key => { const value = window.localStorage.getItem(`${storageKeyPrefix}-forward[${i}].${key}`) if (value) { formik?.setFieldValue(`forward[${i}].${key}`, value) } }) } } }, [formik?.values, storageKeyPrefix]) useEffect(() => { // force show the accordian if there is an error and the form is submitting const hasError = !!formik?.errors?.boost || formik?.errors?.forward?.length > 0 // if it's open we don't want to collapse on submit setShow(show => hasError && formik?.isSubmitting ? FormStatus.ERROR : show) }, [formik?.isSubmitting]) useEffect(() => { const determineItemType = () => { if (router && router.query.type) { return router.query.type } else if (item) { const typeMap = { url: 'link', bounty: 'bounty', pollCost: 'poll' } for (const [key, type] of Object.entries(typeMap)) { if (item[key]) { return type } } return 'discussion' } } const type = determineItemType() setItemType(type) }, [item, router]) function renderCrosspostDetails (itemType) { switch (itemType) { case 'discussion': return <li>crosspost this discussion as a NIP-23 event</li> case 'link': return <li>crosspost this link as a NIP-01 event</li> case 'bounty': return <li>crosspost this bounty as a NIP-99 event</li> case 'poll': return <li>crosspost this poll as a NIP-41 event</li> default: return null } } return ( <AccordianItem header={<div style={{ fontWeight: 'bold', fontSize: '92%' }}>options</div>} show={show} body={ <> {children} <BoostItemInput item={item} sub={sub} /> <VariableInput label='forward sats to' name='forward' min={0} max={MAX_FORWARDS} emptyItem={EMPTY_FORWARD} hint={<span className='text-muted'>Forward sats to up to 5 other stackers. Any remaining sats go to you.</span>} > {({ index, placeholder }) => { return ( <div key={index} className='d-flex flex-row'> <InputUserSuggest name={`forward[${index}].nym`} prepend={<InputGroup.Text>@</InputGroup.Text>} showValid groupClassName={`${styles.name} me-3 mb-0`} /> <Input name={`forward[${index}].pct`} type='number' step={5} min={1} max={100} append={<InputGroup.Text className='text-monospace'>%</InputGroup.Text>} groupClassName={`${styles.percent} mb-0`} /> </div> ) }} </VariableInput> {me && itemType && <Checkbox label={ <div className='d-flex align-items-center'>crosspost to nostr <Info> <ul> {renderCrosspostDetails(itemType)} <li>requires NIP-07 extension for signing</li> <li>we use your NIP-05 relays if set</li> <li>we use these relays by default:</li> <ul> {DEFAULT_CROSSPOSTING_RELAYS.map((relay, i) => ( <li key={i}>{relay}</li> ))} </ul> </ul> </Info> </div> } name='crosspost' />} </> } /> ) }