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 }) {
{numWithUnits(baseFee, { abbreviate: false })} |
{parentId ? 'reply' : 'post'} fee |
- {hasImgLink &&
+ {imageFees &&
- x 10 |
- image/link fee |
+ + {numWithUnits(imageFees, { abbreviate: false })} |
+ image fees |
}
{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 (
- {addImgLink &&
- <>
-
- {numWithUnits(paidSats, { abbreviate: false })} |
- {parentId ? 'reply' : 'post'} fee |
-
-
- x 10 |
- image/link fee |
-
-
- - {numWithUnits(paidSats, { abbreviate: false })} |
- already paid |
-
- >}
+ {imageFees &&
+
+ + {numWithUnits(imageFees, { abbreviate: false })} |
+ image fees |
+
}
{boost > 0 &&
+ {numWithUnits(boost, { abbreviate: false })} |
@@ -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}…]()`, ``)
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',