stacker.news/components/fee-button.js
ekzyis 8f590425dc
Image uploads (#576)
* Add icon to add images

* Open file explorer to select image

* Upload images to S3 on selection

* Show uploaded images below text input

* Link and remove image

* Fetch unsubmitted images from database

* Mark S3 images as submitted in imgproxy job

* Add margin-top

* Mark images as submitted on client after successful mutation

* Also delete objects in S3

* Allow items to have multiple uploads linked

* Overwrite old avatar

* Add fees for presigned URLs

* Use Github style upload

* removed upfront fees
* removed images provider since we no longer need to keep track of unsubmitted images on the client
* removed User.images resolver
* removed deleteImage mutation
* use Github style upload where it shows ![Uploading <filename>...]() first and then replaces that with ![<filename>](<url>) after successful upload

* Add Upload.paid boolean column

One item can have multiple images linked to it, but an image can also be used in multiple items (many-to-many relation).

Since we don't really care to which item an image is linked and vice versa, we just use a boolean column to mark if an image was already paid for.

This makes fee calculation easier since no JOINs are required.

* Add image fees during item creation/update

* we calculate image fees during item creation and update now
* function imageFees returns queries which deduct fees from user and mark images as paid + fees
* queries need to be run inside same transaction as item creation/update

* Allow anons to get presigned URLs

* Add comments regarding avatar upload

* Use megabytes in error message

* Remove unnecessary avatar check during image fees calculation

* Show image fees in frontend

* Also update image fees on blur

This makes sure that the images fees reflect the current state. For example, if an image was removed.

We could also add debounced requests.

* Show amount of unpaid images in receipt

* Fix fees in sats deducted from msats

* Fix algebraic order of fees

Spam fees must come immediately after the base fee since it multiplies the base fee.

* Fix image fees in edit receipt

* Fix stale fees shown

If we pay for an image and then want to edit the comment, the cache might return stale date; suggesting we didn't pay for the existing image yet.

* Add 0 base fee in edit receipt

* Remove 's' from 'image fees' in receipts

* Remove unnecessary async

* Remove 'Uploading <name>...' from text input on error

* Support upload of multiple files at once

* Add schedule to delete unused images

* Fix image fee display in receipts

* Use Drag and Drop API for image upload

* Remove dragOver style on drop

* Increase max upload size to 10MB to allow HQ camera pictures

* Fix free upload quota

* Fix stale image fees served

* Fix bad image fee return statements

* Fix multiplication with feesPerImage

* Fix NULL returned for size24h, sizeNow

* Remove unnecessary text field in query

* refactor: Unify <ImageUpload> and <Upload> component

* Add avatar cache busting using random query param

* Calculate image fee info in postgres function

* we now calculate image fee info in a postgres function which is much cleaner
* we use this function inside `create_item` and `update_item`: image fees are now deducted in the same transaction as creating/updating the item!
* reversed changes in `serializeInvoiceable`

* Fix line break in receipt

* Update upload limits

* Add comment about `e.target.value = null`

* Use debounce instead of onBlur to update image fees info

* Fix invoice amount

* Refactor avatar upload control flow

* Update image fees in onChange

* Fix rescheduling of other jobs

* also update schedule from every minute to every hour

* Add image fees in calling context

* keep item ids on uploads

* Fix incompatible onSubmit signature

* Revert "keep item ids on uploads"

This reverts commit 4688962abcd54fdc5850109372a7ad054cf9b2e4.

* many2many item uploads

* pretty subdomain for images

* handle upload conditions for profile images and job logos

---------

Co-authored-by: ekzyis <ek@ekzyis.com>
Co-authored-by: ekzyis <ek@stacker.news>
2023-11-06 14:53:33 -06:00

160 lines
6.2 KiB
JavaScript

import { useEffect } 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 { numWithUnits } from '../lib/format'
import { useMe } from './me'
import AnonIcon from '../svgs/spy-fill.svg'
import { useShowModal } from './modal'
import Link from 'next/link'
function Receipt ({ cost, repetition, imageFeesInfo, baseFee, parentId, boost }) {
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>}
</tbody>
<tfoot>
<tr>
<td className='fw-bold'>{numWithUnits(cost, { abbreviate: false })}</td>
<td align='right' className='font-weight-light'>total fee</td>
</tr>
</tfoot>
</Table>
)
}
function AnonInfo () {
const showModal = useShowModal()
return (
<AnonIcon
className='fill-muted ms-2 theme' height={22} width={22}
onClick={
(e) =>
showModal(onClose =>
<div><div className='fw-bold text-center'>You are posting without an account</div>
<ol className='my-3'>
<li>You'll pay by invoice</li>
<li>Your content will be content-joined (get it?!) under the <Link href='/anon' target='_blank'>@anon</Link> account</li>
<li>Any sats your content earns will go toward <Link href='/rewards' target='_blank'>rewards</Link></li>
<li>We won't be able to notify you when you receive replies</li>
</ol>
<small className='text-center fst-italic text-muted'>btw if you don't need to be anonymous, posting is cheaper with an account</small>
</div>)
}
/>
)
}
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>
)
}