From 221dd5bb1dcddef5dea015e404ded135f5fbad9e Mon Sep 17 00:00:00 2001 From: ekzyis Date: Wed, 25 Oct 2023 18:00:46 +0200 Subject: [PATCH] Show image fees in frontend --- api/resolvers/image.js | 85 ++++++++++++++++++++++++++++++++++++++++ api/resolvers/index.js | 3 +- api/resolvers/item.js | 76 ++--------------------------------- api/typeDefs/image.js | 7 ++++ api/typeDefs/index.js | 3 +- components/fee-button.js | 61 +++++++++++++--------------- components/form.js | 29 ++++++++++---- components/invoice.js | 3 +- lib/constants.js | 1 + 9 files changed, 151 insertions(+), 117 deletions(-) create mode 100644 api/resolvers/image.js create mode 100644 api/typeDefs/image.js diff --git a/api/resolvers/image.js b/api/resolvers/image.js new file mode 100644 index 00000000..db16de97 --- /dev/null +++ b/api/resolvers/image.js @@ -0,0 +1,85 @@ +import { ANON_USER_ID, AWS_S3_URL_REGEXP } from '../../lib/constants' +import { datePivot } from '../../lib/time' + +export default { + Query: { + imageFees: async (parent, { s3Keys }, { models, me }) => { + const [, fees] = await imageFees(s3Keys, { models, me }) + return fees + } + } +} + +export async function imageFeesFromText (text, { models, me }) { + // no text means no image fees + if (!text) return [itemId => [], 0] + + // parse all s3 keys (= image ids) from text + const textS3Keys = [...text.matchAll(AWS_S3_URL_REGEXP)].map(m => Number(m[1])) + if (!textS3Keys.length) return [itemId => [], 0] + + return imageFees(textS3Keys, { models, me }) +} + +export async function imageFees (s3Keys, { models, me }) { + // To apply image fees, we return queries which need to be run, preferably in the same transaction as creating or updating an item. + function queries (userId, imgIds, imgFees) { + return itemId => { + return [ + // pay fees + models.$queryRawUnsafe('SELECT * FROM user_fee($1::INTEGER, $2::INTEGER, $3::BIGINT)', userId, itemId, imgFees), + // mark images as paid + models.upload.updateMany({ where: { id: { in: imgIds } }, data: { paid: true } }) + ] + } + } + + // we want to ignore image ids for which someone already paid during fee calculation + // to make sure that every image is only paid once + const unpaidS3Keys = (await models.upload.findMany({ select: { id: true }, where: { id: { in: s3Keys }, paid: false } })).map(({ id }) => id) + const unpaid = unpaidS3Keys.length + + if (!unpaid) return [itemId => [], 0] + + if (!me) { + // anons pay for every new image 100 sats + const fees = unpaid * 100 + return [queries(ANON_USER_ID, unpaidS3Keys, fees), fees] + } + + // check how much stacker uploaded in last 24 hours + const { _sum: { size: size24h } } = await models.upload.aggregate({ + _sum: { size: true }, + where: { + userId: me.id, + createdAt: { gt: datePivot(new Date(), { days: -1 }) }, + paid: true + } + }) + + // check how much stacker uploaded now in size + const { _sum: { size: sizeNow } } = await models.upload.aggregate({ + _count: { id: true }, + _sum: { size: true }, + where: { id: { in: unpaidS3Keys } } + }) + + // total size that we consider to calculate fees includes size of images within last 24 hours and size of incoming images + const size = size24h + sizeNow + const MB = 1024 * 1024 // factor for bytes -> megabytes + + // 10 MB per 24 hours are free. fee is also 0 if there are no incoming images (obviously) + let fees + if (!sizeNow || size <= 1 * MB) { + fees = 0 + } else if (size <= 25 * MB) { + fees = 10 * unpaid + } else if (size <= 50 * MB) { + fees = 100 * unpaid + } else if (size <= 100 * MB) { + fees = 1000 * unpaid + } else { + fees = 10000 * unpaid + } + return [queries(me.id, unpaidS3Keys, fees), fees] +} diff --git a/api/resolvers/index.js b/api/resolvers/index.js index f0b311ac..6e41b23f 100644 --- a/api/resolvers/index.js +++ b/api/resolvers/index.js @@ -15,6 +15,7 @@ import price from './price' import { GraphQLJSONObject as JSONObject } from 'graphql-type-json' import admin from './admin' import blockHeight from './blockHeight' +import image from './image' import { GraphQLScalarType, Kind } from 'graphql' const date = new GraphQLScalarType({ @@ -45,4 +46,4 @@ const date = new GraphQLScalarType({ }) export default [user, item, message, wallet, lnurl, notifications, invite, sub, - upload, search, growth, rewards, referrals, price, admin, blockHeight, { JSONObject }, { Date: date }] + upload, search, growth, rewards, referrals, price, admin, blockHeight, image, { JSONObject }, { Date: date }] diff --git a/api/resolvers/item.js b/api/resolvers/item.js index e48e89e8..74f1cf91 100644 --- a/api/resolvers/item.js +++ b/api/resolvers/item.js @@ -19,6 +19,7 @@ import { sendUserNotification } from '../webPush' import { defaultCommentSort, isJob, deleteItemByAuthor, getDeleteCommand, hasDeleteCommand } from '../../lib/item' import { notifyItemParents, notifyUserSubscribers, notifyZapped } from '../../lib/push-notifications' import { datePivot } from '../../lib/time' +import { imageFeesFromText } from './image' export async function commentFilterClause (me, models) { let clause = ` AND ("Item"."weightedVotes" - "Item"."weightedDownVotes" > -${ITEM_FILTER_THRESHOLD}` @@ -1090,7 +1091,7 @@ export const updateItem = async (parent, { sub: subName, forward, options, ...it item = { subName, userId: me.id, ...item } const fwdUsers = await getForwardUsers(models, forward) - const [imgQueriesFn, imgFees] = await imageFees(item.text, { models, me }) + const [imgQueriesFn, imgFees] = await imageFeesFromText(item.text, { models, me }) item = await serializeInvoicable( [ models.$queryRawUnsafe(`${SELECT} FROM update_item($1::JSONB, $2::JSONB, $3::JSONB) AS "Item"`, @@ -1127,7 +1128,7 @@ export const createItem = async (parent, { forward, options, ...item }, { me, mo item.url = removeTracking(item.url) } - const [imgQueriesFn, imgFees] = await imageFees(item.text, { models, me }) + const [imgQueriesFn, imgFees] = await imageFeesFromText(item.text, { models, me }) const enforceFee = (me ? undefined : (item.parentId ? ANON_COMMENT_FEE : (ANON_POST_FEE + (item.boost || 0)))) + imgFees item = await serializeInvoicable( models.$queryRawUnsafe( @@ -1150,77 +1151,6 @@ export const createItem = async (parent, { forward, options, ...item }, { me, mo return item } -const AWS_S3_URL_REGEXP = new RegExp(`https://${process.env.NEXT_PUBLIC_AWS_UPLOAD_BUCKET}.s3.amazonaws.com/([0-9]+)`, 'g') -async function imageFees (text, { models, me }) { - // To apply image fees, we return queries which need to be run, preferably in the same transaction as creating or updating an item. - function queries (userId, imgIds, imgFees) { - return itemId => { - return [ - // pay fees - models.$queryRawUnsafe('SELECT * FROM user_fee($1::INTEGER, $2::INTEGER, $3::BIGINT)', userId, itemId, imgFees), - // mark images as paid - models.upload.updateMany({ where: { id: { in: imgIds } }, data: { paid: true } }) - ] - } - } - - // no text means no image fees - if (!text) return [itemId => [], 0] - - // parse all s3 keys (= image ids) from text - const textS3Keys = [...text.matchAll(AWS_S3_URL_REGEXP)].map(m => Number(m[1])) - if (!textS3Keys.length) return [itemId => [], 0] - - // we want to ignore image ids in text for which someone already paid during fee calculation - // to make sure that every image is only paid once - const unpaidS3Keys = (await models.upload.findMany({ select: { id: true }, where: { id: { in: textS3Keys }, paid: false } })).map(({ id }) => id) - const unpaid = unpaidS3Keys.length - - if (!unpaid) return [itemId => [], 0] - - if (!me) { - // anons pay for every new image 100 sats - const fees = unpaid * 100 - return [queries(ANON_USER_ID, unpaidS3Keys, fees), fees] - } - - // check how much stacker uploaded in last 24 hours - const { _sum: { size: size24h } } = await models.upload.aggregate({ - _sum: { size: true }, - where: { - userId: me.id, - createdAt: { gt: datePivot(new Date(), { days: -1 }) }, - paid: true - } - }) - - // check how much stacker uploaded now in size - const { _sum: { size: sizeNow } } = await models.upload.aggregate({ - _count: { id: true }, - _sum: { size: true }, - where: { id: { in: unpaidS3Keys } } - }) - - // total size that we consider to calculate fees includes size of images within last 24 hours and size of incoming images - const size = size24h + sizeNow - const MB = 1024 * 1024 // factor for bytes -> megabytes - - // 10 MB per 24 hours are free. fee is also 0 if there are no incoming images (obviously) - let fees - if (!sizeNow || size <= 10 * MB) { - fees = 0 - } else if (size <= 25 * MB) { - fees = 10 * unpaid - } else if (size <= 50 * MB) { - fees = 100 * unpaid - } else if (size <= 100 * MB) { - fees = 1000 * unpaid - } else { - fees = 10000 * unpaid - } - return [queries(me.id, unpaidS3Keys, fees), fees] -} - const clearDeletionJobs = async (item, models) => { await models.$queryRawUnsafe(`DELETE FROM pgboss.job WHERE name = 'deleteItem' AND data->>'id' = '${item.id}';`) } diff --git a/api/typeDefs/image.js b/api/typeDefs/image.js new file mode 100644 index 00000000..cdecc787 --- /dev/null +++ b/api/typeDefs/image.js @@ -0,0 +1,7 @@ +import { gql } from 'graphql-tag' + +export default gql` + extend type Query { + imageFees(s3Keys: [Int]!): Int! + } +` diff --git a/api/typeDefs/index.js b/api/typeDefs/index.js index fb7b39d2..665670b7 100644 --- a/api/typeDefs/index.js +++ b/api/typeDefs/index.js @@ -16,6 +16,7 @@ import referrals from './referrals' import price from './price' import admin from './admin' import blockHeight from './blockHeight' +import image from './image' const common = gql` type Query { @@ -35,4 +36,4 @@ const common = gql` ` export default [common, user, item, itemForward, message, wallet, lnurl, notifications, invite, - sub, upload, growth, rewards, referrals, price, admin, blockHeight] + sub, upload, growth, rewards, referrals, price, admin, blockHeight, image] diff --git a/components/fee-button.js b/components/fee-button.js index 9bc7dc72..f09c65ab 100644 --- a/components/fee-button.js +++ b/components/fee-button.js @@ -12,7 +12,7 @@ import AnonIcon from '../svgs/spy-fill.svg' import { useShowModal } from './modal' import Link from 'next/link' -function Receipt ({ cost, repetition, hasImgLink, baseFee, parentId, boost }) { +function Receipt ({ cost, repetition, imageFees, baseFee, parentId, boost }) { return ( @@ -20,10 +20,10 @@ function Receipt ({ cost, repetition, hasImgLink, baseFee, parentId, boost }) { - {hasImgLink && + {imageFees && - - + + } {repetition > 0 && @@ -69,7 +69,7 @@ function AnonInfo () { ) } -export default function FeeButton ({ parentId, hasImgLink, baseFee, ChildButton, variant, text, alwaysShow, disabled }) { +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 @@ -79,46 +79,39 @@ export default function FeeButton ({ parentId, hasImgLink, baseFee, ChildButton, const repetition = me ? data?.itemRepetition || 0 : 0 const formik = useFormikContext() const boost = Number(formik?.values?.boost) || 0 - const cost = baseFee * (hasImgLink ? 10 : 1) * Math.pow(10, repetition) + Number(boost) + const cost = baseFee * Math.pow(10, repetition) + Number(boost) useEffect(() => { formik?.setFieldValue('cost', cost) }, [formik?.getFieldProps('cost').value, cost]) + const imageFees = formik?.getFieldProps('imageFees').value || 0 + const totalCost = cost + imageFees + const show = alwaysShow || !formik?.isSubmitting return (
- - {text}{cost > 1 && show && {numWithUnits(cost, { abbreviate: false })}} + + {text}{totalCost > 1 && show && {numWithUnits(totalCost, { abbreviate: false })}} {!me && } - {cost > baseFee && show && + {totalCost > baseFee && show && - + }
) } -function EditReceipt ({ cost, paidSats, addImgLink, boost, parentId }) { +function EditReceipt ({ cost, paidSats, imageFees, boost, parentId }) { return (
{numWithUnits(baseFee, { abbreviate: false })} {parentId ? 'reply' : 'post'} fee
x 10image/link fee+ {numWithUnits(imageFees, { abbreviate: false })}image fees
- {addImgLink && - <> - - - - - - - - - - - - - } + {imageFees && + + + + } {boost > 0 && @@ -135,25 +128,27 @@ function EditReceipt ({ cost, paidSats, addImgLink, boost, parentId }) { ) } -export function EditFeeButton ({ paidSats, hadImgLink, hasImgLink, ChildButton, variant, text, alwaysShow, parentId }) { +export function EditFeeButton ({ paidSats, ChildButton, variant, text, alwaysShow, parentId }) { const formik = useFormikContext() const boost = (formik?.values?.boost || 0) - (formik?.initialValues?.boost || 0) - const addImgLink = hasImgLink && !hadImgLink - const cost = (addImgLink ? paidSats * 9 : 0) + Number(boost) + const cost = Number(boost) useEffect(() => { formik?.setFieldValue('cost', cost) }, [formik?.getFieldProps('cost').value, cost]) + const imageFees = formik?.getFieldProps('imageFees').value || 0 + const totalCost = cost + imageFees + const show = alwaysShow || !formik?.isSubmitting return (
- = 0 ? cost : 0, { abbreviate: false })}> - {text}{cost > 0 && show && {numWithUnits(cost, { abbreviate: false })}} + = 0 ? totalCost : 0, { abbreviate: false })}> + {text}{totalCost > 0 && show && {numWithUnits(totalCost, { abbreviate: false })}} - {cost > 0 && show && + {totalCost > 0 && show && - + }
) diff --git a/components/form.js b/components/form.js index 43b5bf90..13ae9c52 100644 --- a/components/form.js +++ b/components/form.js @@ -13,9 +13,8 @@ import AddImageIcon from '../svgs/image-add-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 { gql, useLazyQuery } from '@apollo/client' import { TOP_USERS, USER_SEARCH } from '../fragments/users' import TextareaAutosize from 'react-textarea-autosize' import { useToast } from './toast' @@ -26,6 +25,7 @@ import ReactDatePicker from 'react-datepicker' import 'react-datepicker/dist/react-datepicker.css' import { debounce } from './use-debounce-callback' import { ImageUpload } from './image' +import { AWS_S3_URL_REGEXP } from '../lib/constants' export function SubmitButton ({ children, variant, value, onClick, disabled, cost, ...props @@ -95,12 +95,26 @@ export function InputSkeleton ({ label, hint }) { } const DEFAULT_MENTION_INDICES = { start: -1, end: -1 } -export function MarkdownInput ({ label, topLevel, groupClassName, onChange, setHasImgLink, onKeyDown, innerRef, ...props }) { +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 previousTab = useRef(tab) + const formik = useFormikContext() + const toaster = useToast() + const [updateImageFees] = useLazyQuery(gql` + query imageFees($s3Keys: [Int]!) { + imageFees(s3Keys: $s3Keys) + }`, { + onError: (err) => { + console.log(err) + toaster.danger(err.message || err.toString?.()) + }, + onCompleted: ({ imageFees }) => { + formik?.setFieldValue('imageFees', imageFees) + } + }) props.as ||= TextareaAutosize props.rows ||= props.minRows || 6 @@ -141,9 +155,6 @@ export function MarkdownInput ({ label, topLevel, groupClassName, onChange, setH 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 @@ -176,7 +187,7 @@ export function MarkdownInput ({ label, topLevel, groupClassName, onChange, setH setMentionQuery(undefined) setMentionIndices(DEFAULT_MENTION_INDICES) } - }, [onChange, setHasImgLink, setMentionQuery, setMentionIndices, setUserSuggestDropdownStyle]) + }, [onChange, setMentionQuery, setMentionIndices, setUserSuggestDropdownStyle]) const onKeyDownInner = useCallback((userSuggestOnKeyDown) => { return (e) => { @@ -230,10 +241,12 @@ export function MarkdownInput ({ label, topLevel, groupClassName, onChange, setH text += `![Uploading ${file.name}…]()` helpers.setValue(text) }} - onSuccess={({ url, name }) => { + onSuccess={async ({ url, name }) => { let text = innerRef.current.value text = text.replace(`![Uploading ${name}…]()`, `![${name}](${url})`) helpers.setValue(text) + const s3Keys = [...text.matchAll(AWS_S3_URL_REGEXP)].map(m => Number(m[1])) + updateImageFees({ variables: { s3Keys } }) }} > diff --git a/components/invoice.js b/components/invoice.js index 4c6f4bbc..efd7c481 100644 --- a/components/invoice.js +++ b/components/invoice.js @@ -232,8 +232,9 @@ export const useInvoiceable = (onSubmit, options = defaultOptions) => { // this function will be called before the Form's onSubmit handler is called // and the form must include `cost` or `amount` as a value const onSubmitWrapper = useCallback(async (formValues, ...submitArgs) => { - let { cost, amount } = formValues + let { cost, imageFees, amount } = formValues cost ??= amount + if (imageFees) cost += imageFees // action only allowed if logged in if (!me && options.requireSession) { diff --git a/lib/constants.js b/lib/constants.js index 79067559..ee33ccc4 100644 --- a/lib/constants.js +++ b/lib/constants.js @@ -8,6 +8,7 @@ export const BOOST_MULT = 5000 export const BOOST_MIN = BOOST_MULT * 5 export const UPLOAD_SIZE_MAX = 2 * 1024 * 1024 export const IMAGE_PIXELS_MAX = 35000000 +export const AWS_S3_URL_REGEXP = new RegExp(`https://${process.env.NEXT_PUBLIC_AWS_UPLOAD_BUCKET}.s3.amazonaws.com/([0-9]+)`, 'g') export const UPLOAD_TYPES_ALLOW = [ 'image/gif', 'image/heic',
{numWithUnits(paidSats, { abbreviate: false })}{parentId ? 'reply' : 'post'} fee
x 10image/link fee
- {numWithUnits(paidSats, { abbreviate: false })}already paid
+ {numWithUnits(imageFees, { abbreviate: false })}image fees
+ {numWithUnits(boost, { abbreviate: false })}