make fee button less of a hack

This commit is contained in:
keyan 2023-11-10 18:18:10 -06:00
parent 3499f92436
commit c23e49872a
17 changed files with 445 additions and 312 deletions

View File

@ -8,6 +8,7 @@ import { numWithUnits } from '../lib/format'
import styles from './adv-post-form.module.css'
import { useMe } from './me'
import { useRouter } from 'next/router'
import { useFeeButton } from './fee-button'
const EMPTY_FORWARD = { nym: '', pct: '' }
@ -21,6 +22,7 @@ export function AdvPostInitial ({ forward, boost }) {
export default function AdvPostForm ({ children }) {
const me = useMe()
const router = useRouter()
const { merge } = useFeeButton()
return (
<AccordianItem
@ -51,6 +53,13 @@ export default function AdvPostForm ({ children }) {
</div>
}
name='boost'
onChange={(_, e) => merge({
boost: {
term: `+ ${e.target.value}`,
label: 'boost',
modifier: cost => cost + Number(e.target.value)
}
})}
hint={<span className='text-muted'>ranks posts higher temporarily based on the amount</span>}
append={<InputGroup.Text className='text-monospace'>sats</InputGroup.Text>}
/>

View File

@ -1,9 +1,9 @@
import { Form, Input, MarkdownInput, SubmitButton } from '../components/form'
import { Form, Input, MarkdownInput } from '../components/form'
import { useRouter } from 'next/router'
import { gql, useApolloClient, useMutation } from '@apollo/client'
import Countdown from './countdown'
import AdvPostForm, { AdvPostInitial } from './adv-post-form'
import FeeButton, { EditFeeButton } from './fee-button'
import FeeButton from './fee-button'
import InputGroup from 'react-bootstrap/InputGroup'
import { bountySchema } from '../lib/validate'
import { SubSelectInitial } from './sub-select-form'
@ -133,29 +133,12 @@ export function BountyForm ({
}
/>
<AdvPostForm edit={!!item} />
<div className='mt-3'>
{item
? (
<div className='d-flex'>
<CancelButton />
<EditFeeButton
paidSats={item.meSats}
parentId={null}
text='save'
ChildButton={SubmitButton}
variant='secondary'
/>
</div>
)
: (
<FeeButton
baseFee={1}
parentId={null}
text={buttonText}
ChildButton={SubmitButton}
variant='secondary'
/>
)}
<div className='d-flex mt-3'>
<CancelButton />
<FeeButton
text={buttonText}
variant='secondary'
/>
</div>
</Form>
)

View File

@ -4,6 +4,6 @@ import Button from 'react-bootstrap/Button'
export default function CancelButton ({ onClick }) {
const router = useRouter()
return (
<Button className='me-3 text-muted nav-link fw-bold' variant='link' onClick={onClick || (() => router.back())}>cancel</Button>
<Button className='me-4 text-muted nav-link fw-bold' variant='link' onClick={onClick || (() => router.back())}>cancel</Button>
)
}

View File

@ -1,10 +1,10 @@
import { Form, MarkdownInput, SubmitButton } from '../components/form'
import { Form, MarkdownInput } from '../components/form'
import { gql, useMutation } from '@apollo/client'
import styles from './reply.module.css'
import { EditFeeButton } from './fee-button'
import Button from 'react-bootstrap/Button'
import Delete from './delete'
import { commentSchema } from '../lib/validate'
import FeeButton, { FeeButtonProvider } from './fee-button'
export default function CommentEdit ({ comment, editThreshold, onSuccess, onCancel }) {
const [upsertComment] = useMutation(
@ -29,37 +29,41 @@ export default function CommentEdit ({ comment, editThreshold, onSuccess, onCanc
return (
<div className={`${styles.reply} mt-2`}>
<Form
initial={{
text: comment.text
}}
schema={commentSchema}
onSubmit={async (values, { resetForm }) => {
const { error } = await upsertComment({ variables: { ...values, id: comment.id } })
if (error) {
throw new Error({ message: error.toString() })
}
if (onSuccess) {
onSuccess()
}
}}
>
<MarkdownInput
name='text'
minRows={6}
autoFocus
required
/>
<div className='d-flex justify-content-between'>
<Delete itemId={comment.id} onDelete={onSuccess} type='comment'>
<Button variant='grey-medium'>delete</Button>
</Delete>
<EditFeeButton
paidSats={comment.meSats}
parentId={comment.parentId} text='save' ChildButton={SubmitButton} variant='secondary'
<FeeButtonProvider>
<Form
initial={{
text: comment.text
}}
schema={commentSchema}
onSubmit={async (values, { resetForm }) => {
const { error } = await upsertComment({ variables: { ...values, id: comment.id } })
if (error) {
throw new Error({ message: error.toString() })
}
if (onSuccess) {
onSuccess()
}
}}
>
<MarkdownInput
name='text'
minRows={6}
autoFocus
required
/>
</div>
</Form>
<div className='d-flex justify-content-between'>
<Delete itemId={comment.id} onDelete={onSuccess} type='comment'>
<Button variant='grey-medium'>delete</Button>
</Delete>
<div className='d-flex mt-3'>
<FeeButton
text='save'
variant='secondary'
/>
</div>
</div>
</Form>
</FeeButtonProvider>
</div>
)
}

View File

@ -1,9 +1,9 @@
import { Form, Input, MarkdownInput, SubmitButton } from '../components/form'
import { Form, Input, MarkdownInput } from '../components/form'
import { useRouter } from 'next/router'
import { gql, useApolloClient, useLazyQuery, useMutation } from '@apollo/client'
import Countdown from './countdown'
import AdvPostForm, { AdvPostInitial } from './adv-post-form'
import FeeButton, { EditFeeButton } from './fee-button'
import FeeButton from './fee-button'
import { ITEM_FIELDS } from '../fragments/items'
import AccordianItem from './accordian-item'
import Item from './item'
@ -134,24 +134,19 @@ export function DiscussionForm ({
/>
<AdvPostForm edit={!!item} />
<div className='mt-3'>
{item
? (
<div className='d-flex justify-content-between'>
<Delete itemId={item.id} onDelete={() => router.push(`/items/${item.id}`)}>
<Button variant='grey-medium'>delete</Button>
</Delete>
<div className='d-flex'>
<CancelButton />
<EditFeeButton
paidSats={item.meSats}
parentId={null} text='save' ChildButton={SubmitButton} variant='secondary'
/>
</div>
</div>)
: <FeeButton
baseFee={1} parentId={null} text={buttonText}
ChildButton={SubmitButton} variant='secondary'
/>}
<div className='d-flex justify-content-between'>
{item &&
<Delete itemId={item.id} onDelete={() => router.push(`/items/${item.id}`)}>
<Button variant='grey-medium'>delete</Button>
</Delete>}
<div className='d-flex align-items-center ms-auto'>
<CancelButton />
<FeeButton
text={buttonText}
variant='secondary'
/>
</div>
</div>
</div>
{!item &&
<div className={`mt-3 ${related.length > 0 ? '' : 'invisible'}`}>

View File

@ -1,44 +1,131 @@
import { useEffect } from 'react'
import { useEffect, useContext, createContext, useState, useCallback, useMemo } from 'react'
import Table from 'react-bootstrap/Table'
import ActionTooltip from './action-tooltip'
import Info from './info'
import styles from './fee-button.module.css'
import { gql, useQuery } from '@apollo/client'
import { useFormikContext } from 'formik'
import { SSR, ANON_COMMENT_FEE, ANON_POST_FEE } from '../lib/constants'
import { SSR } from '../lib/constants'
import { numWithUnits } from '../lib/format'
import { useMe } from './me'
import AnonIcon from '../svgs/spy-fill.svg'
import { useShowModal } from './modal'
import Link from 'next/link'
import { SubmitButton } from './form'
function Receipt ({ cost, repetition, imageFeesInfo, baseFee, parentId, boost }) {
const FeeButtonContext = createContext()
export function postCommentBaseLineItems ({ baseCost = 1, comment = false, me }) {
// XXX this doesn't match the logic on the server but it has the same
// result on fees ... will need to change the server logic to match
const anonCharge = me
? {}
: {
anonCharge: {
term: 'x 100',
label: 'anon mult',
modifier: (cost) => cost * 100
}
}
return {
baseCost: {
term: baseCost,
label: `${comment ? 'comment' : 'post'} cost`,
modifier: (cost) => cost + baseCost
},
...anonCharge
}
}
export function postCommentUseRemoteLineItems ({ parentId, me } = {}) {
if (!me) return () => {}
const query = parentId
? gql`{ itemRepetition(parentId: "${parentId}") }`
: gql`{ itemRepetition }`
return function useRemoteLineItems () {
const [line, setLine] = useState({})
const { data } = useQuery(query, SSR ? {} : { pollInterval: 1000, nextFetchPolicy: 'cache-and-network' })
useEffect(() => {
const repetition = data?.itemRepetition || 0
if (!repetition) return
setLine({
itemRepetition: {
term: <>x 10<sup>{repetition}</sup></>,
label: <>{repetition} {parentId ? 'repeat or self replies' : 'posts'} in 10m</>,
modifier: (cost) => cost * Math.pow(10, repetition)
}
})
}, [data?.itemRepetition])
return line
}
}
export function FeeButtonProvider ({ baseLineItems = {}, useRemoteLineItems = () => null, children }) {
const [lineItems, setLineItems] = useState({})
const remoteLineItems = useRemoteLineItems()
const mergeLineItems = useCallback((newLineItems) => {
setLineItems(lineItems => ({
...lineItems,
...newLineItems
}))
}, [setLineItems])
const value = useMemo(() => {
const lines = { ...baseLineItems, ...lineItems, ...remoteLineItems }
return {
lines,
merge: mergeLineItems,
total: Object.values(lines).reduce((acc, { modifier }) => modifier(acc), 0)
}
}, [baseLineItems, lineItems, remoteLineItems, mergeLineItems])
return (
<FeeButtonContext.Provider value={value}>
{children}
</FeeButtonContext.Provider>
)
}
export function useFeeButton () {
return useContext(FeeButtonContext)
}
export default function FeeButton ({ ChildButton = SubmitButton, variant, text, disabled }) {
const me = useMe()
const { lines, total } = useFeeButton()
return (
<div className={styles.feeButton}>
<ActionTooltip overlayText={numWithUnits(total, { abbreviate: false })}>
<ChildButton variant={variant} disabled={disabled}>{text}{total > 1 && <small> {numWithUnits(total, { abbreviate: false, format: true })}</small>}</ChildButton>
</ActionTooltip>
{!me && <AnonInfo />}
{total > 1 &&
<Info>
<Receipt lines={lines} total={total} />
</Info>}
</div>
)
}
function Receipt ({ lines, total }) {
return (
<Table className={styles.receipt} borderless size='sm'>
<tbody>
<tr>
<td>{numWithUnits(baseFee, { abbreviate: false })}</td>
<td align='right' className='font-weight-light'>{parentId ? 'reply' : 'post'} fee</td>
</tr>
{repetition > 0 &&
<tr>
<td>x 10<sup>{repetition}</sup></td>
<td className='font-weight-light' align='right'>{repetition} {parentId ? 'repeat or self replies' : 'posts'} in 10m</td>
</tr>}
{imageFeesInfo.totalFees > 0 &&
<tr>
<td>+ {imageFeesInfo.nUnpaid} x {numWithUnits(imageFeesInfo.imageFee, { abbreviate: false })}</td>
<td align='right' className='font-weight-light'>image fee</td>
</tr>}
{boost > 0 &&
<tr>
<td>+ {numWithUnits(boost, { abbreviate: false })}</td>
<td className='font-weight-light' align='right'>boost</td>
</tr>}
{Object.entries(lines).map(([key, { term, label, omit }]) => (
!omit &&
<tr key={key}>
<td>{term}</td>
<td align='right' className='font-weight-light'>{label}</td>
</tr>))}
</tbody>
<tfoot>
<tr>
<td className='fw-bold'>{numWithUnits(cost, { abbreviate: false })}</td>
<td className='fw-bold'>{numWithUnits(total, { abbreviate: false, format: true })}</td>
<td align='right' className='font-weight-light'>total fee</td>
</tr>
</tfoot>
@ -68,92 +155,3 @@ function AnonInfo () {
/>
)
}
export default function FeeButton ({ parentId, baseFee, ChildButton, variant, text, alwaysShow, disabled }) {
const me = useMe()
baseFee = me ? baseFee : (parentId ? ANON_COMMENT_FEE : ANON_POST_FEE)
const query = parentId
? gql`{ itemRepetition(parentId: "${parentId}") }`
: gql`{ itemRepetition }`
const { data } = useQuery(query, SSR ? {} : { pollInterval: 1000, nextFetchPolicy: 'cache-and-network' })
const repetition = me ? data?.itemRepetition || 0 : 0
const formik = useFormikContext()
const boost = Number(formik?.values?.boost) || 0
const cost = baseFee * Math.pow(10, repetition) + Number(boost)
useEffect(() => {
formik?.setFieldValue('cost', cost)
}, [formik?.getFieldProps('cost').value, cost])
const imageFeesInfo = formik?.getFieldProps('imageFeesInfo').value || { totalFees: 0 }
const totalCost = cost + imageFeesInfo.totalFees
const show = alwaysShow || !formik?.isSubmitting
return (
<div className={styles.feeButton}>
<ActionTooltip overlayText={numWithUnits(totalCost, { abbreviate: false })}>
<ChildButton variant={variant} disabled={disabled}>{text}{totalCost > 1 && show && <small> {numWithUnits(totalCost, { abbreviate: false })}</small>}</ChildButton>
</ActionTooltip>
{!me && <AnonInfo />}
{totalCost > baseFee && show &&
<Info>
<Receipt baseFee={baseFee} imageFeesInfo={imageFeesInfo} repetition={repetition} cost={totalCost} parentId={parentId} boost={boost} />
</Info>}
</div>
)
}
function EditReceipt ({ cost, paidSats, imageFeesInfo, boost, parentId }) {
return (
<Table className={styles.receipt} borderless size='sm'>
<tbody>
<tr>
<td>{numWithUnits(0, { abbreviate: false })}</td>
<td align='right' className='font-weight-light'>edit fee</td>
</tr>
{imageFeesInfo.totalFees > 0 &&
<tr>
<td>+ {imageFeesInfo.nUnpaid} x {numWithUnits(imageFeesInfo.imageFee, { abbreviate: false })}</td>
<td align='right' className='font-weight-light'>image fee</td>
</tr>}
{boost > 0 &&
<tr>
<td>+ {numWithUnits(boost, { abbreviate: false })}</td>
<td className='font-weight-light' align='right'>boost</td>
</tr>}
</tbody>
<tfoot>
<tr>
<td className='fw-bold'>{numWithUnits(cost)}</td>
<td align='right' className='font-weight-light'>total fee</td>
</tr>
</tfoot>
</Table>
)
}
export function EditFeeButton ({ paidSats, ChildButton, variant, text, alwaysShow, parentId }) {
const formik = useFormikContext()
const boost = (formik?.values?.boost || 0) - (formik?.initialValues?.boost || 0)
const cost = Number(boost)
useEffect(() => {
formik?.setFieldValue('cost', cost)
}, [formik?.getFieldProps('cost').value, cost])
const imageFeesInfo = formik?.getFieldProps('imageFeesInfo').value || { totalFees: 0 }
const totalCost = cost + imageFeesInfo.totalFees
const show = alwaysShow || !formik?.isSubmitting
return (
<div className='d-flex align-items-center'>
<ActionTooltip overlayText={numWithUnits(totalCost >= 0 ? totalCost : 0, { abbreviate: false })}>
<ChildButton variant={variant}>{text}{totalCost > 0 && show && <small> {numWithUnits(totalCost, { abbreviate: false })}</small>}</ChildButton>
</ActionTooltip>
{totalCost > 0 && show &&
<Info>
<EditReceipt paidSats={paidSats} imageFeesInfo={imageFeesInfo} cost={totalCost} parentId={parentId} boost={boost} />
</Info>}
</div>
)
}

View File

@ -23,20 +23,17 @@ 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'
import useDebounceCallback, { debounce } from './use-debounce-callback'
import { ImageUpload } from './image'
import { AWS_S3_URL_REGEXP } from '../lib/constants'
import { dayMonthYear, dayMonthYearToDate, whenToFrom } from '../lib/time'
import { useFeeButton } from './fee-button'
import Thumb from '../svgs/thumb-up-fill.svg'
export function SubmitButton ({
children, variant, value, onClick, disabled, cost, ...props
children, variant, value, onClick, disabled, ...props
}) {
const formik = useFormikContext()
useEffect(() => {
if (cost) {
formik?.setFieldValue('cost', cost)
}
}, [formik?.setFieldValue, formik?.getFieldProps('cost').value, cost])
return (
<Button
@ -58,11 +55,14 @@ export function SubmitButton ({
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')
}
@ -76,7 +76,7 @@ export function CopyInput (props) {
className={styles.appendButton}
size={props.size}
onClick={handleClick}
>copy
>{copied ? <Thumb width={18} height={18} /> : 'copy'}
</Button>
}
{...props}
@ -105,7 +105,7 @@ export function MarkdownInput ({ label, topLevel, groupClassName, onChange, onKe
innerRef = innerRef || useRef(null)
const imageUploadRef = useRef(null)
const previousTab = useRef(tab)
const formik = useFormikContext()
const { merge } = useFeeButton()
const toaster = useToast()
const [updateImageFeesInfo] = useLazyQuery(gql`
query imageFeesInfo($s3Keys: [Int]!) {
@ -123,7 +123,14 @@ export function MarkdownInput ({ label, topLevel, groupClassName, onChange, onKe
toaster.danger(err.message || err.toString?.())
},
onCompleted: ({ imageFeesInfo }) => {
formik?.setFieldValue('imageFeesInfo', imageFeesInfo)
merge({
imageFee: {
term: `+ ${numWithUnits(imageFeesInfo.totalFees, { abbreviate: false })}`,
label: 'image fee',
modifier: cost => cost + imageFeesInfo.totalFees,
omit: !imageFeesInfo.totalFees
}
})
}
})
@ -164,11 +171,11 @@ export function MarkdownInput ({ label, topLevel, groupClassName, onChange, onKe
innerRef.current.focus()
}, [mentionIndices, innerRef, helpers?.setValue])
const imageFeesUpdate = useCallback(debounce(
const imageFeesUpdate = useDebounceCallback(
(text) => {
const s3Keys = text ? [...text.matchAll(AWS_S3_URL_REGEXP)].map(m => Number(m[1])) : []
updateImageFeesInfo({ variables: { s3Keys } })
}, 1000), [debounce, updateImageFeesInfo])
}, 1000, [updateImageFeesInfo])
const onChangeInner = useCallback((formik, e) => {
if (onChange) onChange(formik, e)
@ -426,6 +433,7 @@ function InputInner ({
// for some reason we have to turn off validation to get formik to
// not assume this is invalid
helpers.setValue(draft, false)
onChange && onChange(formik, { target: { value: draft } })
}
}
}, [overrideValue])
@ -720,6 +728,7 @@ export function Form ({
}) {
const toaster = useToast()
const initialErrorToasted = useRef(false)
const feeButton = useFeeButton()
useEffect(() => {
if (initialError && !initialErrorToasted.current) {
toaster.danger(initialError.message || initialError.toString?.())
@ -757,11 +766,8 @@ export function Form ({
if (onSubmit) {
// extract cost from formik fields
// (cost may also be set in a formik field named 'amount')
let cost = values?.cost || values?.amount
const cost = feeButton?.total || values?.amount
if (cost) {
// add potential image fees which are set in a different field
// to differentiate between fees (in receipts for example)
cost += (values?.imageFeesInfo?.totalFees || 0)
values.cost = cost
}

View File

@ -1,4 +1,4 @@
import { Checkbox, Form, Input, MarkdownInput, SubmitButton } from './form'
import { Checkbox, Form, Input, MarkdownInput } from './form'
import Row from 'react-bootstrap/Row'
import Col from 'react-bootstrap/Col'
import InputGroup from 'react-bootstrap/InputGroup'
@ -14,10 +14,10 @@ import { useRouter } from 'next/router'
import Link from 'next/link'
import { usePrice } from './price'
import Avatar from './avatar'
import ActionTooltip from './action-tooltip'
import { jobSchema } from '../lib/validate'
import CancelButton from './cancel-button'
import { MAX_TITLE_LENGTH } from '../lib/constants'
import FeeButton from './fee-button'
function satsMin2Mo (minute) {
return minute * 30 * 24 * 60
@ -156,18 +156,11 @@ export default function JobForm ({ item, sub }) {
<PromoteJob item={item} sub={sub} />
{item && <StatusControl item={item} />}
<div className='d-flex align-items-center justify-content-end mt-3'>
{item
? (
<div className='d-flex'>
<CancelButton />
<SubmitButton variant='secondary'>save</SubmitButton>
</div>
)
: (
<ActionTooltip overlayText='1000 sats'>
<SubmitButton cost={1000} variant='secondary'>post <small> 1000 sats</small></SubmitButton>
</ActionTooltip>
)}
<CancelButton />
<FeeButton
text={item ? 'save' : 'post'}
variant='secondary'
/>
</div>
</Form>
</>

View File

@ -1,5 +1,5 @@
import { useState, useEffect, useCallback } from 'react'
import { Form, Input, MarkdownInput, SubmitButton } from '../components/form'
import { Form, Input, MarkdownInput } from '../components/form'
import { useRouter } from 'next/router'
import { gql, useApolloClient, useLazyQuery, useMutation } from '@apollo/client'
import Countdown from './countdown'
@ -7,7 +7,7 @@ import AdvPostForm, { AdvPostInitial } from './adv-post-form'
import { ITEM_FIELDS } from '../fragments/items'
import Item from './item'
import AccordianItem from './accordian-item'
import FeeButton, { EditFeeButton } from './fee-button'
import FeeButton from './fee-button'
import Delete from './delete'
import Button from 'react-bootstrap/Button'
import { linkSchema } from '../lib/validate'
@ -194,33 +194,24 @@ export function LinkForm ({ item, sub, editThreshold, children }) {
/>
</AdvPostForm>
<div className='mt-3'>
{item
? (
<div className='d-flex justify-content-between'>
<div className='d-flex justify-content-between'>
{item
? (
<Delete itemId={item.id} onDelete={() => router.push(`/items/${item.id}`)}>
<Button variant='grey-medium'>delete</Button>
</Delete>
<div className='d-flex'>
<CancelButton />
<EditFeeButton
paidSats={item.meSats}
parentId={null} text='save' ChildButton={SubmitButton} variant='secondary'
/>
</div>
</div>)
: (
<div className='d-flex align-items-center'>
<FeeButton
baseFee={1} parentId={null} text='post' disabled={postDisabled}
ChildButton={SubmitButton} variant='secondary'
/>
{dupesLoading &&
<div className='d-flex ms-3 justify-content-center'>
<Moon className='spin fill-grey' />
<div className='ms-2 text-muted' style={{ fontWeight: '600' }}>searching for dupes</div>
</div>}
</div>
)}
</Delete>)
: dupesLoading &&
<div className='d-flex justify-content-center'>
<Moon className='spin fill-grey' />
<div className='ms-2 text-muted' style={{ fontWeight: '600' }}>searching for dupes</div>
</div>}
<div className='d-flex align-items-center ms-auto'>
<CancelButton />
<FeeButton
text={item ? 'save' : 'post'} disabled={postDisabled} variant='secondary'
/>
</div>
</div>
</div>
{!item &&
<>

View File

@ -1,10 +1,10 @@
import { Form, Input, MarkdownInput, SubmitButton, VariableInput } from '../components/form'
import { Form, Input, MarkdownInput, VariableInput } from '../components/form'
import { useRouter } from 'next/router'
import { gql, useApolloClient, useMutation } from '@apollo/client'
import Countdown from './countdown'
import AdvPostForm, { AdvPostInitial } from './adv-post-form'
import { MAX_POLL_CHOICE_LENGTH, MAX_POLL_NUM_CHOICES, MAX_TITLE_LENGTH } from '../lib/constants'
import FeeButton, { EditFeeButton } from './fee-button'
import FeeButton from './fee-button'
import Delete from './delete'
import Button from 'react-bootstrap/Button'
import { pollSchema } from '../lib/validate'
@ -99,24 +99,20 @@ export function PollForm ({ item, sub, editThreshold, children }) {
/>
<AdvPostForm edit={!!item} />
<div className='mt-3'>
{item
? (
<div className='d-flex justify-content-between'>
<div className='mt-3'>
<div className='d-flex justify-content-between'>
{item &&
<Delete itemId={item.id} onDelete={() => router.push(`/items/${item.id}`)}>
<Button variant='grey-medium'>delete</Button>
</Delete>
<div className='d-flex'>
<CancelButton />
<EditFeeButton
paidSats={item.meSats}
parentId={null} text='save' ChildButton={SubmitButton} variant='secondary'
/>
</div>
</div>)
: <FeeButton
baseFee={1} parentId={null} text='post'
ChildButton={SubmitButton} variant='secondary'
/>}
</Delete>}
<div className='d-flex align-items-center ms-auto'>
<CancelButton />
<FeeButton
text={item ? 'save' : 'post'} variant='secondary'
/>
</div>
</div>
</div>
</div>
</Form>
)

View File

@ -12,6 +12,7 @@ import { BountyForm } from './bounty-form'
import SubSelect from './sub-select-form'
import Info from './info'
import { useCallback, useState } from 'react'
import { FeeButtonProvider, postCommentBaseLineItems, postCommentUseRemoteLineItems } from './fee-button'
function FreebieDialog () {
return (
@ -94,7 +95,14 @@ export function PostForm ({ type, sub, children }) {
FormType = BountyForm
}
return <FormType sub={sub}>{children}</FormType>
return (
<FeeButtonProvider
baseLineItems={sub ? postCommentBaseLineItems({ baseCost: sub.baseCost, me: !!me }) : undefined}
useRemoteLineItems={postCommentUseRemoteLineItems({ me: !!me })}
>
<FormType sub={sub}>{children}</FormType>
</FeeButtonProvider>
)
}
export default function Post ({ sub }) {

View File

@ -1,11 +1,11 @@
import { Form, MarkdownInput, SubmitButton } from '../components/form'
import { Form, MarkdownInput } from '../components/form'
import { gql, useMutation } from '@apollo/client'
import styles from './reply.module.css'
import { COMMENTS } from '../fragments/comments'
import { useMe } from './me'
import { forwardRef, useCallback, useEffect, useState, useRef, useImperativeHandle } from 'react'
import Link from 'next/link'
import FeeButton from './fee-button'
import FeeButton, { FeeButtonProvider, postCommentBaseLineItems, postCommentUseRemoteLineItems } from './fee-button'
import { commentsViewedAfterComment } from '../lib/new-comments'
import { commentSchema } from '../lib/validate'
import Info from './info'
@ -183,35 +183,43 @@ export default forwardRef(function Reply ({ item, onSuccess, replyOpen, children
{/* HACK if we need more items, we should probably do a comment toolbar */}
{children}
</div>)}
<div className={styles.reply} style={{ display: reply ? 'block' : 'none' }}>
<Form
initial={{
text: ''
}}
schema={commentSchema}
invoiceable
onSubmit={onSubmit}
storageKeyPrefix={`reply-${parentId}`}
innerRef={formInnerRef}
>
<MarkdownInput
name='text'
minRows={6}
autoFocus={!replyOpen}
required
placeholder={placeholder}
hint={me?.sats < 1 && <FreebieDialog />}
innerRef={replyInput}
/>
{reply &&
<div className='mt-1'>
<FeeButton
baseFee={1} parentId={parentId} text='reply'
ChildButton={SubmitButton} variant='secondary' alwaysShow
{reply &&
<div className={styles.reply}>
<FeeButtonProvider
baseLineItems={postCommentBaseLineItems({ baseCost: 1, comment: true, me: !!me })}
useRemoteLineItems={postCommentUseRemoteLineItems({ parentId: item.id, me: !!me })}
>
<Form
initial={{
text: ''
}}
schema={commentSchema}
invoiceable
onSubmit={onSubmit}
storageKeyPrefix={`reply-${parentId}`}
innerRef={formInnerRef}
>
<MarkdownInput
name='text'
minRows={6}
autoFocus={!replyOpen}
required
placeholder={placeholder}
hint={me?.sats < 1 && <FreebieDialog />}
innerRef={replyInput}
/>
</div>}
</Form>
</div>
<div className='d-flex mt-1'>
<div className='ms-auto'>
<FeeButton
text='reply'
variant='secondary'
alwaysShow
/>
</div>
</div>
</Form>
</FeeButtonProvider>
</div>}
</div>
)
})

View File

@ -29,11 +29,18 @@ function getClient (uri) {
return new ApolloClient({
link: new HttpLink({ uri }),
ssrMode: SSR,
connectToDevTools: process.env.NODE_ENV !== 'production',
cache: new InMemoryCache({
freezeResults: true,
typePolicies: {
Query: {
fields: {
sub: {
keyArgs: ['name'],
merge (existing, incoming) {
return incoming
}
},
topUsers: {
keyArgs: ['when', 'by', 'from', 'to', 'limit'],
merge (existing, incoming) {

View File

@ -5,12 +5,12 @@ import Button from 'react-bootstrap/Button'
import styles from '../../styles/user.module.css'
import { useState } from 'react'
import ItemFull from '../../components/item-full'
import { Form, MarkdownInput, SubmitButton } from '../../components/form'
import { Form, MarkdownInput } from '../../components/form'
import { useMe } from '../../components/me'
import { USER_FULL } from '../../fragments/users'
import { ITEM_FIELDS } from '../../fragments/items'
import { getGetServerSideProps } from '../../api/ssrApollo'
import FeeButton, { EditFeeButton } from '../../components/fee-button'
import FeeButton, { FeeButtonProvider } from '../../components/fee-button'
import { bioSchema } from '../../lib/validate'
import CancelButton from '../../components/cancel-button'
import { useRouter } from 'next/router'
@ -69,15 +69,9 @@ export function BioForm ({ handleDone, bio }) {
/>
<div className='d-flex mt-3 justify-content-end'>
<CancelButton onClick={handleDone} />
{bio?.text
? <EditFeeButton
paidSats={bio?.meSats}
parentId={null} text='save' ChildButton={SubmitButton} variant='secondary'
/>
: <FeeButton
baseFee={1} parentId={null} text='create'
ChildButton={SubmitButton} variant='secondary'
/>}
<FeeButtonProvider>
<FeeButton text='save' variant='secondary' />
</FeeButtonProvider>
</div>
</Form>
</div>

View File

@ -11,6 +11,7 @@ import { useState } from 'react'
import { useQuery } from '@apollo/client'
import { useRouter } from 'next/router'
import PageLoading from '../../../components/page-loading'
import { FeeButtonProvider } from '../../../components/fee-button'
export const getServerSideProps = getGetServerSideProps({
query: ITEM,
@ -39,11 +40,23 @@ export default function PostEdit ({ ssrData }) {
FormType = BountyForm
}
const existingBoostLineItem = item.boost
? {
existingBoost: {
label: 'old boost',
term: `- ${item.boost}`,
modifier: cost => cost - item.boost
}
}
: undefined
return (
<CenterLayout sub={sub}>
<FormType item={item} editThreshold={editThreshold}>
{!item.isJob && <SubSelect label='sub' item={item} setSub={setSub} sub={sub} />}
</FormType>
<FeeButtonProvider baseLineItems={existingBoostLineItem}>
<FormType item={item} editThreshold={editThreshold}>
{!item.isJob && <SubSelect label='sub' item={item} setSub={setSub} sub={sub} />}
</FormType>
</FeeButtonProvider>
</CenterLayout>
)
}

View File

@ -103,7 +103,7 @@ export function DonateButton () {
<Button onClick={() => showModal(onClose => (
<Form
initial={{
amount: 1000
amount: 10000
}}
schema={amountSchema}
invoiceable

View File

@ -0,0 +1,128 @@
-- add base cost
CREATE OR REPLACE FUNCTION create_item(
jitem JSONB, forward JSONB, poll_options JSONB, spam_within INTERVAL, upload_ids INTEGER[])
RETURNS "Item"
LANGUAGE plpgsql
AS $$
DECLARE
user_msats BIGINT;
cost_msats BIGINT := 1000;
base_cost_msats BIGINT;
freebie BOOLEAN;
item "Item";
med_votes FLOAT;
select_clause TEXT;
BEGIN
PERFORM ASSERT_SERIALIZED();
-- access fields with appropriate types
item := jsonb_populate_record(NULL::"Item", jitem);
SELECT msats INTO user_msats FROM users WHERE id = item."userId";
-- if this is a post, get the base cost of the sub
IF item."parentId" IS NULL THEN
SELECT "baseCost" * 1000, "baseCost" * 1000
INTO base_cost_msats, cost_msats
FROM "Sub"
WHERE name = item."subName";
END IF;
IF item."maxBid" IS NULL THEN
-- spam multiplier
cost_msats := cost_msats * POWER(10, item_spam(item."parentId", item."userId", spam_within));
END IF;
-- add image fees
IF upload_ids IS NOT NULL THEN
cost_msats := cost_msats + (SELECT "nUnpaid" * "imageFeeMsats" FROM image_fees_info(item."userId", upload_ids));
UPDATE "Upload" SET paid = 't' WHERE id = ANY(upload_ids);
END IF;
-- it's only a freebie if it's no greater than the base cost, they have less than the cost, and boost = 0
freebie := (cost_msats <= base_cost_msats) AND (user_msats < cost_msats) AND (item.boost IS NULL OR item.boost = 0);
IF NOT freebie AND cost_msats > user_msats THEN
RAISE EXCEPTION 'SN_INSUFFICIENT_FUNDS';
END IF;
-- get this user's median item score
SELECT COALESCE(
percentile_cont(0.5) WITHIN GROUP(
ORDER BY "weightedVotes" - "weightedDownVotes"), 0)
INTO med_votes FROM "Item" WHERE "userId" = item."userId";
-- if their median votes are positive, start at 0
-- if the median votes are negative, start their post with that many down votes
-- basically: if their median post is bad, presume this post is too
-- addendum: if they're an anon poster, always start at 0
IF med_votes >= 0 OR item."userId" = 27 THEN
med_votes := 0;
ELSE
med_votes := ABS(med_votes);
END IF;
-- there's no great way to set default column values when using json_populate_record
-- so we need to only select fields with non-null values that way when func input
-- does not include a value, the default value is used instead of null
SELECT string_agg(quote_ident(key), ',') INTO select_clause
FROM jsonb_object_keys(jsonb_strip_nulls(jitem)) k(key);
-- insert the item
EXECUTE format($fmt$
INSERT INTO "Item" (%s, "weightedDownVotes", freebie)
SELECT %1$s, %L, %L
FROM jsonb_populate_record(NULL::"Item", %L) RETURNING *
$fmt$, select_clause, med_votes, freebie, jitem) INTO item;
INSERT INTO "ItemForward" ("itemId", "userId", "pct")
SELECT item.id, "userId", "pct" FROM jsonb_populate_recordset(NULL::"ItemForward", forward);
-- Automatically subscribe to one's own posts
INSERT INTO "ThreadSubscription" ("itemId", "userId")
VALUES (item.id, item."userId");
-- Automatically subscribe forward recipients to the new post
INSERT INTO "ThreadSubscription" ("itemId", "userId")
SELECT item.id, "userId" FROM jsonb_populate_recordset(NULL::"ItemForward", forward);
INSERT INTO "PollOption" ("itemId", "option")
SELECT item.id, "option" FROM jsonb_array_elements_text(poll_options) o("option");
IF NOT freebie THEN
UPDATE users SET msats = msats - cost_msats WHERE id = item."userId";
INSERT INTO "ItemAct" (msats, "itemId", "userId", act)
VALUES (cost_msats, item.id, item."userId", 'FEE');
END IF;
-- if this item has boost
IF item.boost > 0 THEN
PERFORM item_act(item.id, item."userId", 'BOOST', item.boost);
END IF;
-- if this is a job
IF item."maxBid" IS NOT NULL THEN
PERFORM run_auction(item.id);
END IF;
-- if this is a bio
IF item.bio THEN
UPDATE users SET "bioId" = item.id WHERE id = item."userId";
END IF;
-- record attachments
IF upload_ids IS NOT NULL THEN
INSERT INTO "ItemUpload" ("itemId", "uploadId")
SELECT item.id, * FROM UNNEST(upload_ids);
END IF;
-- schedule imgproxy job
INSERT INTO pgboss.job (name, data, retrylimit, retrybackoff, startafter)
VALUES ('imgproxy', jsonb_build_object('id', item.id), 21, true, now() + interval '5 seconds');
RETURN item;
END;
$$;
UPDATE "Sub" SET "baseCost" = 10 WHERE name <> 'jobs';
UPDATE "Sub" SET "baseCost" = 10000 WHERE name = 'jobs';