diff --git a/components/adv-post-form.js b/components/adv-post-form.js index 615455bd..849dc1ac 100644 --- a/components/adv-post-form.js +++ b/components/adv-post-form.js @@ -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 ( } name='boost' + onChange={(_, e) => merge({ + boost: { + term: `+ ${e.target.value}`, + label: 'boost', + modifier: cost => cost + Number(e.target.value) + } + })} hint={ranks posts higher temporarily based on the amount} append={sats} /> diff --git a/components/bounty-form.js b/components/bounty-form.js index f1b14bf4..3a3d8de2 100644 --- a/components/bounty-form.js +++ b/components/bounty-form.js @@ -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 ({ } /> -
- {item - ? ( -
- - -
- ) - : ( - - )} +
+ +
) diff --git a/components/cancel-button.js b/components/cancel-button.js index b823d50f..1f4a4cc3 100644 --- a/components/cancel-button.js +++ b/components/cancel-button.js @@ -4,6 +4,6 @@ import Button from 'react-bootstrap/Button' export default function CancelButton ({ onClick }) { const router = useRouter() return ( - + ) } diff --git a/components/comment-edit.js b/components/comment-edit.js index 0f6e1077..63250d8e 100644 --- a/components/comment-edit.js +++ b/components/comment-edit.js @@ -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 (
-
{ - const { error } = await upsertComment({ variables: { ...values, id: comment.id } }) - if (error) { - throw new Error({ message: error.toString() }) - } - if (onSuccess) { - onSuccess() - } - }} - > - -
- - - - + { + const { error } = await upsertComment({ variables: { ...values, id: comment.id } }) + if (error) { + throw new Error({ message: error.toString() }) + } + if (onSuccess) { + onSuccess() + } + }} + > + -
- +
+ + + +
+ +
+
+ +
) } diff --git a/components/discussion-form.js b/components/discussion-form.js index 9166a1d4..f94c3985 100644 --- a/components/discussion-form.js +++ b/components/discussion-form.js @@ -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 ({ />
- {item - ? ( -
- router.push(`/items/${item.id}`)}> - - -
- - -
-
) - : } +
+ {item && + router.push(`/items/${item.id}`)}> + + } +
+ + +
+
{!item &&
0 ? '' : 'invisible'}`}> diff --git a/components/fee-button.js b/components/fee-button.js index 580aa2ee..5c819f0b 100644 --- a/components/fee-button.js +++ b/components/fee-button.js @@ -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{repetition}, + 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 ( + + {children} + + ) +} + +export function useFeeButton () { + return useContext(FeeButtonContext) +} + +export default function FeeButton ({ ChildButton = SubmitButton, variant, text, disabled }) { + const me = useMe() + const { lines, total } = useFeeButton() + + return ( +
+ + {text}{total > 1 && {numWithUnits(total, { abbreviate: false, format: true })}} + + {!me && } + {total > 1 && + + + } +
+ ) +} + +function Receipt ({ lines, total }) { return ( - - - - - {repetition > 0 && - - - - } - {imageFeesInfo.totalFees > 0 && - - - - } - {boost > 0 && - - - - } + {Object.entries(lines).map(([key, { term, label, omit }]) => ( + !omit && + + + + ))} - + @@ -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 ( -
- - {text}{totalCost > 1 && show && {numWithUnits(totalCost, { abbreviate: false })}} - - {!me && } - {totalCost > baseFee && show && - - - } -
- ) -} - -function EditReceipt ({ cost, paidSats, imageFeesInfo, boost, parentId }) { - return ( -
{numWithUnits(baseFee, { abbreviate: false })}{parentId ? 'reply' : 'post'} fee
x 10{repetition}{repetition} {parentId ? 'repeat or self replies' : 'posts'} in 10m
+ {imageFeesInfo.nUnpaid} x {numWithUnits(imageFeesInfo.imageFee, { abbreviate: false })}image fee
+ {numWithUnits(boost, { abbreviate: false })}boost
{term}{label}
{numWithUnits(cost, { abbreviate: false })}{numWithUnits(total, { abbreviate: false, format: true })} total fee
- - - - - - {imageFeesInfo.totalFees > 0 && - - - - } - {boost > 0 && - - - - } - - - - - - - -
{numWithUnits(0, { abbreviate: false })}edit fee
+ {imageFeesInfo.nUnpaid} x {numWithUnits(imageFeesInfo.imageFee, { abbreviate: false })}image fee
+ {numWithUnits(boost, { abbreviate: false })}boost
{numWithUnits(cost)}total fee
- ) -} - -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 ( -
- = 0 ? totalCost : 0, { abbreviate: false })}> - {text}{totalCost > 0 && show && {numWithUnits(totalCost, { abbreviate: false })}} - - {totalCost > 0 && show && - - - } -
- ) -} diff --git a/components/form.js b/components/form.js index 6e3564e3..c26b3125 100644 --- a/components/form.js +++ b/components/form.js @@ -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 ( } {...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 } diff --git a/components/job-form.js b/components/job-form.js index 20f3d12d..8da076e1 100644 --- a/components/job-form.js +++ b/components/job-form.js @@ -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 }) { {item && }
- {item - ? ( -
- - save -
- ) - : ( - - post 1000 sats - - )} + +
diff --git a/components/link-form.js b/components/link-form.js index f8b69ce4..3262d3f2 100644 --- a/components/link-form.js +++ b/components/link-form.js @@ -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 }) { />
- {item - ? ( -
+
+ {item + ? ( router.push(`/items/${item.id}`)}> - -
- - -
-
) - : ( -
- - {dupesLoading && -
- -
searching for dupes
-
} -
- )} + ) + : dupesLoading && +
+ +
searching for dupes
+
} +
+ + +
+
{!item && <> diff --git a/components/poll-form.js b/components/poll-form.js index 6e678878..cda03afe 100644 --- a/components/poll-form.js +++ b/components/poll-form.js @@ -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 }) { />
- {item - ? ( -
+
+
+ {item && router.push(`/items/${item.id}`)}> - -
- - -
-
) - : } + } +
+ + +
+
+
) diff --git a/components/post.js b/components/post.js index 73877bbd..569ea8a6 100644 --- a/components/post.js +++ b/components/post.js @@ -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 {children} + return ( + + {children} + + ) } export default function Post ({ sub }) { diff --git a/components/reply.js b/components/reply.js index e863a199..2c13f0b9 100644 --- a/components/reply.js +++ b/components/reply.js @@ -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}
)} -
-
- } - innerRef={replyInput} - /> - {reply && -
- + + + } + innerRef={replyInput} /> -
} - -
+
+
+ +
+
+ + +
} ) }) diff --git a/lib/apollo.js b/lib/apollo.js index 1cdeec4c..5d50ce75 100644 --- a/lib/apollo.js +++ b/lib/apollo.js @@ -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) { diff --git a/pages/[name]/index.js b/pages/[name]/index.js index a86ae4cd..780c797d 100644 --- a/pages/[name]/index.js +++ b/pages/[name]/index.js @@ -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 }) { />
- {bio?.text - ? - : } + + +
diff --git a/pages/items/[id]/edit.js b/pages/items/[id]/edit.js index 4e5b9050..075f43f7 100644 --- a/pages/items/[id]/edit.js +++ b/pages/items/[id]/edit.js @@ -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 ( - - {!item.isJob && } - + + + {!item.isJob && } + + ) } diff --git a/pages/rewards/index.js b/pages/rewards/index.js index 6d833130..eb72a82f 100644 --- a/pages/rewards/index.js +++ b/pages/rewards/index.js @@ -103,7 +103,7 @@ export function DonateButton () {