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 4688962abc
.
* 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>
This commit is contained in:
parent
5fd74e9329
commit
8f590425dc
|
@ -0,0 +1,23 @@
|
|||
import { ANON_USER_ID, AWS_S3_URL_REGEXP } from '../../lib/constants'
|
||||
import { msatsToSats } from '../../lib/format'
|
||||
|
||||
export default {
|
||||
Query: {
|
||||
imageFeesInfo: async (parent, { s3Keys }, { models, me }) => {
|
||||
return imageFeesInfo(s3Keys, { models, me })
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export function uploadIdsFromText (text, { models }) {
|
||||
if (!text) return null
|
||||
return [...text.matchAll(AWS_S3_URL_REGEXP)].map(m => Number(m[1]))
|
||||
}
|
||||
|
||||
export async function imageFeesInfo (s3Keys, { models, me }) {
|
||||
const [info] = await models.$queryRawUnsafe('SELECT * FROM image_fees_info($1::INTEGER, $2::INTEGER[])', me ? me.id : ANON_USER_ID, s3Keys)
|
||||
const imageFee = msatsToSats(info.imageFeeMsats)
|
||||
const totalFeesMsats = info.nUnpaid * Number(info.imageFeeMsats)
|
||||
const totalFees = msatsToSats(totalFeesMsats)
|
||||
return { ...info, imageFee, totalFees, totalFeesMsats }
|
||||
}
|
|
@ -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 }]
|
||||
|
|
|
@ -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 { imageFeesInfo, uploadIdsFromText } from './image'
|
||||
|
||||
export async function commentFilterClause (me, models) {
|
||||
let clause = ` AND ("Item"."weightedVotes" - "Item"."weightedDownVotes" > -${ITEM_FILTER_THRESHOLD}`
|
||||
|
@ -1090,10 +1091,13 @@ export const updateItem = async (parent, { sub: subName, forward, options, ...it
|
|||
item = { subName, userId: me.id, ...item }
|
||||
const fwdUsers = await getForwardUsers(models, forward)
|
||||
|
||||
const uploadIds = uploadIdsFromText(item.text, { models })
|
||||
const { fees: imgFees } = await imageFeesInfo(uploadIds, { models, me })
|
||||
|
||||
item = await serializeInvoicable(
|
||||
models.$queryRawUnsafe(`${SELECT} FROM update_item($1::JSONB, $2::JSONB, $3::JSONB) AS "Item"`,
|
||||
JSON.stringify(item), JSON.stringify(fwdUsers), JSON.stringify(options)),
|
||||
{ models, lnd, hash, hmac, me }
|
||||
models.$queryRawUnsafe(`${SELECT} FROM update_item($1::JSONB, $2::JSONB, $3::JSONB, $4::INTEGER[]) AS "Item"`,
|
||||
JSON.stringify(item), JSON.stringify(fwdUsers), JSON.stringify(options), uploadIds),
|
||||
{ models, lnd, hash, hmac, me, enforceFee: imgFees }
|
||||
)
|
||||
|
||||
await createMentions(item, models)
|
||||
|
@ -1123,11 +1127,14 @@ export const createItem = async (parent, { forward, options, ...item }, { me, mo
|
|||
item.url = removeTracking(item.url)
|
||||
}
|
||||
|
||||
const enforceFee = me ? undefined : (item.parentId ? ANON_COMMENT_FEE : (ANON_POST_FEE + (item.boost || 0)))
|
||||
const uploadIds = uploadIdsFromText(item.text, { models })
|
||||
const { fees: imgFees } = await imageFeesInfo(uploadIds, { models, me })
|
||||
|
||||
const enforceFee = (me ? undefined : (item.parentId ? ANON_COMMENT_FEE : (ANON_POST_FEE + (item.boost || 0)))) + imgFees
|
||||
item = await serializeInvoicable(
|
||||
models.$queryRawUnsafe(
|
||||
`${SELECT} FROM create_item($1::JSONB, $2::JSONB, $3::JSONB, '${spamInterval}'::INTERVAL) AS "Item"`,
|
||||
JSON.stringify(item), JSON.stringify(fwdUsers), JSON.stringify(options)),
|
||||
`${SELECT} FROM create_item($1::JSONB, $2::JSONB, $3::JSONB, '${spamInterval}'::INTERVAL, $4::INTEGER[]) AS "Item"`,
|
||||
JSON.stringify(item), JSON.stringify(fwdUsers), JSON.stringify(options), uploadIds),
|
||||
{ models, lnd, hash, hmac, me, enforceFee }
|
||||
)
|
||||
|
||||
|
|
|
@ -1,62 +1,42 @@
|
|||
import { GraphQLError } from 'graphql'
|
||||
import AWS from 'aws-sdk'
|
||||
import { IMAGE_PIXELS_MAX, UPLOAD_SIZE_MAX, UPLOAD_TYPES_ALLOW } from '../../lib/constants'
|
||||
|
||||
const bucketRegion = 'us-east-1'
|
||||
|
||||
AWS.config.update({
|
||||
region: bucketRegion
|
||||
})
|
||||
import { ANON_USER_ID, IMAGE_PIXELS_MAX, UPLOAD_SIZE_MAX, UPLOAD_SIZE_MAX_AVATAR, UPLOAD_TYPES_ALLOW } from '../../lib/constants'
|
||||
import { createPresignedPost } from '../s3'
|
||||
|
||||
export default {
|
||||
Mutation: {
|
||||
getSignedPOST: async (parent, { type, size, width, height }, { models, me }) => {
|
||||
if (!me) {
|
||||
throw new GraphQLError('you must be logged in to get a signed url', { extensions: { code: 'FORBIDDEN' } })
|
||||
}
|
||||
|
||||
getSignedPOST: async (parent, { type, size, width, height, avatar }, { models, me }) => {
|
||||
if (UPLOAD_TYPES_ALLOW.indexOf(type) === -1) {
|
||||
throw new GraphQLError(`image must be ${UPLOAD_TYPES_ALLOW.map(t => t.replace('image/', '')).join(', ')}`, { extensions: { code: 'BAD_INPUT' } })
|
||||
}
|
||||
|
||||
if (size > UPLOAD_SIZE_MAX) {
|
||||
throw new GraphQLError(`image must be less than ${UPLOAD_SIZE_MAX} bytes`, { extensions: { code: 'BAD_INPUT' } })
|
||||
throw new GraphQLError(`image must be less than ${UPLOAD_SIZE_MAX / (1024 ** 2)} megabytes`, { extensions: { code: 'BAD_INPUT' } })
|
||||
}
|
||||
|
||||
if (avatar && size > UPLOAD_SIZE_MAX_AVATAR) {
|
||||
throw new GraphQLError(`image must be less than ${UPLOAD_SIZE_MAX_AVATAR / (1024 ** 2)} megabytes`, { extensions: { code: 'BAD_INPUT' } })
|
||||
}
|
||||
|
||||
if (width * height > IMAGE_PIXELS_MAX) {
|
||||
throw new GraphQLError(`image must be less than ${IMAGE_PIXELS_MAX} pixels`, { extensions: { code: 'BAD_INPUT' } })
|
||||
}
|
||||
|
||||
// create upload record
|
||||
const upload = await models.upload.create({
|
||||
data: {
|
||||
const imgParams = {
|
||||
type,
|
||||
size,
|
||||
width,
|
||||
height,
|
||||
userId: me.id
|
||||
userId: me?.id || ANON_USER_ID,
|
||||
paid: false
|
||||
}
|
||||
})
|
||||
|
||||
// get presigned POST ur
|
||||
const s3 = new AWS.S3({ apiVersion: '2006-03-01' })
|
||||
const res = await new Promise((resolve, reject) => {
|
||||
s3.createPresignedPost({
|
||||
Bucket: process.env.NEXT_PUBLIC_AWS_UPLOAD_BUCKET,
|
||||
Fields: {
|
||||
key: String(upload.id)
|
||||
},
|
||||
Expires: 300,
|
||||
Conditions: [
|
||||
{ 'Content-Type': type },
|
||||
{ 'Cache-Control': 'max-age=31536000' },
|
||||
{ acl: 'public-read' },
|
||||
['content-length-range', size, size]
|
||||
]
|
||||
}, (err, preSigned) => { if (err) { reject(err) } else { resolve(preSigned) } })
|
||||
})
|
||||
if (avatar) {
|
||||
if (!me) throw new GraphQLError('you must be logged in', { extensions: { code: 'FORBIDDEN' } })
|
||||
imgParams.paid = undefined
|
||||
}
|
||||
|
||||
return res
|
||||
const upload = await models.upload.create({ data: { ...imgParams } })
|
||||
return createPresignedPost({ key: String(upload.id), type, size })
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -0,0 +1,37 @@
|
|||
import AWS from 'aws-sdk'
|
||||
|
||||
const bucketRegion = 'us-east-1'
|
||||
const Bucket = process.env.NEXT_PUBLIC_AWS_UPLOAD_BUCKET
|
||||
|
||||
AWS.config.update({
|
||||
region: bucketRegion
|
||||
})
|
||||
|
||||
export function createPresignedPost ({ key, type, size }) {
|
||||
const s3 = new AWS.S3({ apiVersion: '2006-03-01' })
|
||||
return new Promise((resolve, reject) => {
|
||||
s3.createPresignedPost({
|
||||
Bucket,
|
||||
Fields: { key },
|
||||
Expires: 300,
|
||||
Conditions: [
|
||||
{ 'Content-Type': type },
|
||||
{ 'Cache-Control': 'max-age=31536000' },
|
||||
{ acl: 'public-read' },
|
||||
['content-length-range', size, size]
|
||||
]
|
||||
}, (err, preSigned) => { err ? reject(err) : resolve(preSigned) })
|
||||
})
|
||||
}
|
||||
|
||||
export function deleteObjects (keys) {
|
||||
const s3 = new AWS.S3({ apiVersion: '2006-03-01' })
|
||||
return new Promise((resolve, reject) => {
|
||||
s3.deleteObjects({
|
||||
Bucket,
|
||||
Delete: {
|
||||
Objects: keys.map(key => ({ Key: String(key) }))
|
||||
}
|
||||
}, (err, data) => { err ? reject(err) : resolve(keys) })
|
||||
})
|
||||
}
|
|
@ -0,0 +1,16 @@
|
|||
import { gql } from 'graphql-tag'
|
||||
|
||||
export default gql`
|
||||
type ImageFeesInfo {
|
||||
totalFees: Int!
|
||||
totalFeesMsats: Int!
|
||||
imageFee: Int!
|
||||
imageFeeMsats: Int!
|
||||
nUnpaid: Int!
|
||||
bytesUnpaid: Int!
|
||||
bytes24h: Int!
|
||||
}
|
||||
extend type Query {
|
||||
imageFeesInfo(s3Keys: [Int]!): ImageFeesInfo!
|
||||
}
|
||||
`
|
|
@ -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]
|
||||
|
|
|
@ -2,7 +2,7 @@ import { gql } from 'graphql-tag'
|
|||
|
||||
export default gql`
|
||||
extend type Mutation {
|
||||
getSignedPOST(type: String!, size: Int!, width: Int!, height: Int!): SignedPost!
|
||||
getSignedPOST(type: String!, size: Int!, width: Int!, height: Int!, avatar: Boolean): SignedPost!
|
||||
}
|
||||
|
||||
type SignedPost {
|
||||
|
|
|
@ -45,6 +45,18 @@ export default gql`
|
|||
email: String
|
||||
}
|
||||
|
||||
type Image {
|
||||
id: ID!
|
||||
createdAt: Date!
|
||||
updatedAt: Date!
|
||||
type: String!
|
||||
size: Int!
|
||||
width: Int
|
||||
height: Int
|
||||
itemId: Int
|
||||
userId: Int!
|
||||
}
|
||||
|
||||
type User {
|
||||
id: ID!
|
||||
createdAt: Date!
|
||||
|
|
|
@ -2,10 +2,10 @@ import { useRef, useState } from 'react'
|
|||
import AvatarEditor from 'react-avatar-editor'
|
||||
import Button from 'react-bootstrap/Button'
|
||||
import BootstrapForm from 'react-bootstrap/Form'
|
||||
import Upload from './upload'
|
||||
import EditImage from '../svgs/image-edit-fill.svg'
|
||||
import Moon from '../svgs/moon-fill.svg'
|
||||
import { useShowModal } from './modal'
|
||||
import { ImageUpload } from './image'
|
||||
|
||||
export default function Avatar ({ onSuccess }) {
|
||||
const [uploading, setUploading] = useState()
|
||||
|
@ -49,27 +49,41 @@ export default function Avatar ({ onSuccess }) {
|
|||
}
|
||||
|
||||
return (
|
||||
<Upload
|
||||
as={({ onClick }) =>
|
||||
<div className='position-absolute p-1 bg-dark pointer' onClick={onClick} style={{ bottom: '0', right: '0' }}>
|
||||
{uploading
|
||||
? <Moon className='fill-white spin' />
|
||||
: <EditImage className='fill-white' />}
|
||||
</div>}
|
||||
<ImageUpload
|
||||
avatar
|
||||
onError={e => {
|
||||
console.log(e)
|
||||
setUploading(false)
|
||||
}}
|
||||
onSelect={(file, upload) => {
|
||||
showModal(onClose => <Body onClose={onClose} file={file} upload={upload} />)
|
||||
return new Promise((resolve, reject) =>
|
||||
showModal(onClose => (
|
||||
<Body
|
||||
onClose={() => {
|
||||
onClose()
|
||||
resolve()
|
||||
}}
|
||||
onSuccess={async key => {
|
||||
onSuccess && onSuccess(key)
|
||||
setUploading(false)
|
||||
}}
|
||||
onStarted={() => {
|
||||
setUploading(true)
|
||||
file={file}
|
||||
upload={async (blob) => {
|
||||
await upload(blob)
|
||||
resolve(blob)
|
||||
}}
|
||||
/>
|
||||
)))
|
||||
}}
|
||||
onSuccess={({ id }) => {
|
||||
onSuccess?.(id)
|
||||
setUploading(false)
|
||||
}}
|
||||
onUpload={() => {
|
||||
setUploading(true)
|
||||
}}
|
||||
>
|
||||
<div className='position-absolute p-1 bg-dark pointer' style={{ bottom: '0', right: '0' }}>
|
||||
{uploading
|
||||
? <Moon className='fill-white spin' />
|
||||
: <EditImage className='fill-white' />}
|
||||
</div>
|
||||
</ImageUpload>
|
||||
)
|
||||
}
|
||||
|
|
|
@ -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, imageFeesInfo, baseFee, parentId, boost }) {
|
||||
return (
|
||||
<Table className={styles.receipt} borderless size='sm'>
|
||||
<tbody>
|
||||
|
@ -20,16 +20,16 @@ function Receipt ({ cost, repetition, hasImgLink, baseFee, parentId, boost }) {
|
|||
<td>{numWithUnits(baseFee, { abbreviate: false })}</td>
|
||||
<td align='right' className='font-weight-light'>{parentId ? 'reply' : 'post'} fee</td>
|
||||
</tr>
|
||||
{hasImgLink &&
|
||||
<tr>
|
||||
<td>x 10</td>
|
||||
<td align='right' className='font-weight-light'>image/link 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>
|
||||
|
@ -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,43 @@ 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 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(cost, { abbreviate: false })}>
|
||||
<ChildButton variant={variant} disabled={disabled}>{text}{cost > 1 && show && <small> {numWithUnits(cost, { abbreviate: false })}</small>}</ChildButton>
|
||||
<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 />}
|
||||
{cost > baseFee && show &&
|
||||
{totalCost > baseFee && show &&
|
||||
<Info>
|
||||
<Receipt baseFee={baseFee} hasImgLink={hasImgLink} repetition={repetition} cost={cost} parentId={parentId} boost={boost} />
|
||||
<Receipt baseFee={baseFee} imageFeesInfo={imageFeesInfo} repetition={repetition} cost={totalCost} parentId={parentId} boost={boost} />
|
||||
</Info>}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function EditReceipt ({ cost, paidSats, addImgLink, boost, parentId }) {
|
||||
function EditReceipt ({ cost, paidSats, imageFeesInfo, boost, parentId }) {
|
||||
return (
|
||||
<Table className={styles.receipt} borderless size='sm'>
|
||||
<tbody>
|
||||
{addImgLink &&
|
||||
<>
|
||||
<tr>
|
||||
<td>{numWithUnits(paidSats, { abbreviate: false })}</td>
|
||||
<td align='right' className='font-weight-light'>{parentId ? 'reply' : 'post'} fee</td>
|
||||
<td>{numWithUnits(0, { abbreviate: false })}</td>
|
||||
<td align='right' className='font-weight-light'>edit fee</td>
|
||||
</tr>
|
||||
{imageFeesInfo.totalFees > 0 &&
|
||||
<tr>
|
||||
<td>x 10</td>
|
||||
<td align='right' className='font-weight-light'>image/link fee</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>- {numWithUnits(paidSats, { abbreviate: false })}</td>
|
||||
<td align='right' className='font-weight-light'>already paid</td>
|
||||
</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>
|
||||
|
@ -135,25 +132,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 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(cost >= 0 ? cost : 0, { abbreviate: false })}>
|
||||
<ChildButton variant={variant}>{text}{cost > 0 && show && <small> {numWithUnits(cost, { abbreviate: false })}</small>}</ChildButton>
|
||||
<ActionTooltip overlayText={numWithUnits(totalCost >= 0 ? totalCost : 0, { abbreviate: false })}>
|
||||
<ChildButton variant={variant}>{text}{totalCost > 0 && show && <small> {numWithUnits(totalCost, { abbreviate: false })}</small>}</ChildButton>
|
||||
</ActionTooltip>
|
||||
{cost > 0 && show &&
|
||||
{totalCost > 0 && show &&
|
||||
<Info>
|
||||
<EditReceipt paidSats={paidSats} addImgLink={addImgLink} cost={cost} parentId={parentId} boost={boost} />
|
||||
<EditReceipt paidSats={paidSats} imageFeesInfo={imageFeesInfo} cost={totalCost} parentId={parentId} boost={boost} />
|
||||
</Info>}
|
||||
</div>
|
||||
)
|
||||
|
|
|
@ -1,6 +1,6 @@
|
|||
.receipt {
|
||||
background-color: var(--theme-inputBg);
|
||||
max-width: 250px;
|
||||
max-width: 300px;
|
||||
margin: auto;
|
||||
table-layout: auto;
|
||||
width: 100%;
|
||||
|
|
|
@ -9,12 +9,12 @@ import Dropdown from 'react-bootstrap/Dropdown'
|
|||
import Nav from 'react-bootstrap/Nav'
|
||||
import Row from 'react-bootstrap/Row'
|
||||
import Markdown from '../svgs/markdown-line.svg'
|
||||
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'
|
||||
|
@ -24,6 +24,8 @@ import textAreaCaret from 'textarea-caret'
|
|||
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
|
||||
|
@ -93,12 +95,34 @@ 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 imageUploadRef = useRef(null)
|
||||
const previousTab = useRef(tab)
|
||||
const formik = useFormikContext()
|
||||
const toaster = useToast()
|
||||
const [updateImageFeesInfo] = useLazyQuery(gql`
|
||||
query imageFeesInfo($s3Keys: [Int]!) {
|
||||
imageFeesInfo(s3Keys: $s3Keys) {
|
||||
totalFees
|
||||
nUnpaid
|
||||
imageFee
|
||||
bytes24h
|
||||
}
|
||||
}`, {
|
||||
fetchPolicy: 'no-cache',
|
||||
nextFetchPolicy: 'no-cache',
|
||||
onError: (err) => {
|
||||
console.log(err)
|
||||
toaster.danger(err.message || err.toString?.())
|
||||
},
|
||||
onCompleted: ({ imageFeesInfo }) => {
|
||||
formik?.setFieldValue('imageFeesInfo', imageFeesInfo)
|
||||
}
|
||||
})
|
||||
|
||||
props.as ||= TextareaAutosize
|
||||
props.rows ||= props.minRows || 6
|
||||
|
@ -137,11 +161,14 @@ export function MarkdownInput ({ label, topLevel, groupClassName, onChange, setH
|
|||
innerRef.current.focus()
|
||||
}, [mentionIndices, innerRef, helpers?.setValue])
|
||||
|
||||
const imageFeesUpdate = useCallback(debounce(
|
||||
(text) => {
|
||||
const s3Keys = text ? [...text.matchAll(AWS_S3_URL_REGEXP)].map(m => Number(m[1])) : []
|
||||
updateImageFeesInfo({ variables: { s3Keys } })
|
||||
}, 1000), [debounce, updateImageFeesInfo])
|
||||
|
||||
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
|
||||
|
@ -174,7 +201,9 @@ export function MarkdownInput ({ label, topLevel, groupClassName, onChange, setH
|
|||
setMentionQuery(undefined)
|
||||
setMentionIndices(DEFAULT_MENTION_INDICES)
|
||||
}
|
||||
}, [onChange, setHasImgLink, setMentionQuery, setMentionIndices, setUserSuggestDropdownStyle])
|
||||
|
||||
imageFeesUpdate(value)
|
||||
}, [onChange, setMentionQuery, setMentionIndices, setUserSuggestDropdownStyle, imageFeesUpdate])
|
||||
|
||||
const onKeyDownInner = useCallback((userSuggestOnKeyDown) => {
|
||||
return (e) => {
|
||||
|
@ -209,6 +238,22 @@ export function MarkdownInput ({ label, topLevel, groupClassName, onChange, setH
|
|||
}
|
||||
}, [innerRef, helpers?.setValue, setSelectionRange, onKeyDown])
|
||||
|
||||
const onDrop = useCallback((event) => {
|
||||
event.preventDefault()
|
||||
setDragStyle(null)
|
||||
const changeEvent = new Event('change', { bubbles: true })
|
||||
imageUploadRef.current.files = event.dataTransfer.files
|
||||
imageUploadRef.current.dispatchEvent(changeEvent)
|
||||
}, [imageUploadRef])
|
||||
|
||||
const [dragStyle, setDragStyle] = useState(null)
|
||||
const onDragEnter = useCallback((e) => {
|
||||
setDragStyle('over')
|
||||
}, [setDragStyle])
|
||||
const onDragLeave = useCallback((e) => {
|
||||
setDragStyle(null)
|
||||
}, [setDragStyle])
|
||||
|
||||
return (
|
||||
<FormGroup label={label} className={groupClassName}>
|
||||
<div className={`${styles.markdownInput} ${tab === 'write' ? styles.noTopLeftRadius : ''}`}>
|
||||
|
@ -219,12 +264,39 @@ export function MarkdownInput ({ label, topLevel, groupClassName, onChange, setH
|
|||
<Nav.Item>
|
||||
<Nav.Link className={styles.previewTab} eventKey='preview' disabled={!meta.value}>preview</Nav.Link>
|
||||
</Nav.Item>
|
||||
<span className='ms-auto text-muted d-flex align-items-center'>
|
||||
<ImageUpload
|
||||
multiple
|
||||
ref={imageUploadRef}
|
||||
className='d-flex align-items-center me-1'
|
||||
onUpload={file => {
|
||||
let text = innerRef.current.value
|
||||
if (text) text += '\n\n'
|
||||
text += `![Uploading ${file.name}…]()`
|
||||
helpers.setValue(text)
|
||||
}}
|
||||
onSuccess={({ 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]))
|
||||
updateImageFeesInfo({ variables: { s3Keys } })
|
||||
}}
|
||||
onError={({ name }) => {
|
||||
let text = innerRef.current.value
|
||||
text = text.replace(`![Uploading ${name}…]()`, '')
|
||||
helpers.setValue(text)
|
||||
}}
|
||||
>
|
||||
<AddImageIcon width={18} height={18} />
|
||||
</ImageUpload>
|
||||
<a
|
||||
className='ms-auto text-muted d-flex align-items-center'
|
||||
className='d-flex align-items-center'
|
||||
href='https://guides.github.com/features/mastering-markdown/' target='_blank' rel='noreferrer'
|
||||
>
|
||||
<Markdown width={18} height={18} />
|
||||
</a>
|
||||
</span>
|
||||
</Nav>
|
||||
<div className={`position-relative ${tab === 'write' ? '' : 'd-none'}`}>
|
||||
<UserSuggest
|
||||
|
@ -238,6 +310,10 @@ export function MarkdownInput ({ label, topLevel, groupClassName, onChange, setH
|
|||
onChange={onChangeInner}
|
||||
onKeyDown={onKeyDownInner(userSuggestOnKeyDown)}
|
||||
onBlur={() => setTimeout(resetSuggestions, 100)}
|
||||
onDragEnter={onDragEnter}
|
||||
onDragLeave={onDragLeave}
|
||||
onDrop={onDrop}
|
||||
className={dragStyle === 'over' ? styles.dragOver : ''}
|
||||
/>)}
|
||||
</UserSuggest>
|
||||
</div>
|
||||
|
@ -675,6 +751,14 @@ export function Form ({
|
|||
const onSubmitInner = useCallback(async (values, ...args) => {
|
||||
try {
|
||||
if (onSubmit) {
|
||||
// extract cost from formik fields
|
||||
// (cost may also be set in a formik field named 'amount')
|
||||
let cost = values?.cost || values?.amount
|
||||
// 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
|
||||
|
||||
const options = await onSubmit(values, ...args)
|
||||
if (!storageKeyPrefix || options?.keepLocalStorage) return
|
||||
clearLocalStorage(values)
|
||||
|
|
|
@ -19,6 +19,10 @@
|
|||
height: auto;
|
||||
}
|
||||
|
||||
.dragOver {
|
||||
box-shadow: 0 0 10px var(--bs-info);
|
||||
}
|
||||
|
||||
.appendButton {
|
||||
border-left: 0 !important;
|
||||
border-top-left-radius: 0;
|
||||
|
|
|
@ -1,9 +1,13 @@
|
|||
import styles from './text.module.css'
|
||||
import { useState, useEffect, useMemo, useCallback } from 'react'
|
||||
import { Fragment, useState, useEffect, useMemo, useCallback, forwardRef, useRef } from 'react'
|
||||
import { IMGPROXY_URL_REGEXP } from '../lib/url'
|
||||
import { useShowModal } from './modal'
|
||||
import { useMe } from './me'
|
||||
import { Dropdown } from 'react-bootstrap'
|
||||
import { UPLOAD_TYPES_ALLOW } from '../lib/constants'
|
||||
import { useToast } from './toast'
|
||||
import gql from 'graphql-tag'
|
||||
import { useMutation } from '@apollo/client'
|
||||
|
||||
export function decodeOriginalUrl (imgproxyUrl) {
|
||||
const parts = imgproxyUrl.split('/')
|
||||
|
@ -132,3 +136,99 @@ export default function ZoomableImage ({ src, srcSet, ...props }) {
|
|||
|
||||
return <ImageOriginal src={originalUrl} onClick={handleClick} {...props} />
|
||||
}
|
||||
|
||||
export const ImageUpload = forwardRef(({ children, className, onSelect, onUpload, onSuccess, onError, multiple, avatar }, ref) => {
|
||||
const toaster = useToast()
|
||||
ref ??= useRef(null)
|
||||
|
||||
const [getSignedPOST] = useMutation(
|
||||
gql`
|
||||
mutation getSignedPOST($type: String!, $size: Int!, $width: Int!, $height: Int!, $avatar: Boolean) {
|
||||
getSignedPOST(type: $type, size: $size, width: $width, height: $height, avatar: $avatar) {
|
||||
url
|
||||
fields
|
||||
}
|
||||
}`)
|
||||
|
||||
const s3Upload = useCallback(file => {
|
||||
const img = new window.Image()
|
||||
img.src = window.URL.createObjectURL(file)
|
||||
return new Promise((resolve, reject) => {
|
||||
img.onload = async () => {
|
||||
onUpload?.(file)
|
||||
let data
|
||||
const variables = {
|
||||
avatar,
|
||||
type: file.type,
|
||||
size: file.size,
|
||||
width: img.width,
|
||||
height: img.height
|
||||
}
|
||||
try {
|
||||
({ data } = await getSignedPOST({ variables }))
|
||||
} catch (e) {
|
||||
toaster.danger(e.message || e.toString?.())
|
||||
onError?.({ ...variables, name: file.name, file })
|
||||
reject(e)
|
||||
return
|
||||
}
|
||||
|
||||
const form = new FormData()
|
||||
Object.keys(data.getSignedPOST.fields).forEach(key => form.append(key, data.getSignedPOST.fields[key]))
|
||||
form.append('Content-Type', file.type)
|
||||
form.append('Cache-Control', 'max-age=31536000')
|
||||
form.append('acl', 'public-read')
|
||||
form.append('file', file)
|
||||
|
||||
const res = await fetch(data.getSignedPOST.url, {
|
||||
method: 'POST',
|
||||
body: form
|
||||
})
|
||||
|
||||
if (!res.ok) {
|
||||
// TODO make sure this is actually a helpful error message and does not expose anything to the user we don't want
|
||||
const err = res.statusText
|
||||
toaster.danger(err)
|
||||
onError?.({ ...variables, name: file.name, file })
|
||||
reject(err)
|
||||
return
|
||||
}
|
||||
|
||||
const url = `https://${process.env.NEXT_PUBLIC_MEDIA_DOMAIN}/${data.getSignedPOST.fields.key}`
|
||||
// key is upload id in database
|
||||
const id = data.getSignedPOST.fields.key
|
||||
onSuccess?.({ ...variables, id, name: file.name, url, file })
|
||||
resolve(id)
|
||||
}
|
||||
})
|
||||
}, [toaster, getSignedPOST])
|
||||
|
||||
return (
|
||||
<>
|
||||
<input
|
||||
ref={ref}
|
||||
type='file'
|
||||
multiple={multiple}
|
||||
className='d-none'
|
||||
accept={UPLOAD_TYPES_ALLOW.join(', ')}
|
||||
onChange={async (e) => {
|
||||
const fileList = e.target.files
|
||||
for (const file of Array.from(fileList)) {
|
||||
if (UPLOAD_TYPES_ALLOW.indexOf(file.type) === -1) {
|
||||
toaster.danger(`image must be ${UPLOAD_TYPES_ALLOW.map(t => t.replace('image/', '')).join(', ')}`)
|
||||
continue
|
||||
}
|
||||
if (onSelect) await onSelect?.(file, s3Upload)
|
||||
else await s3Upload(file)
|
||||
// reset file input
|
||||
// see https://bobbyhadz.com/blog/react-reset-file-input#reset-a-file-input-in-react
|
||||
e.target.value = null
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<div className={className} onClick={() => ref.current?.click()} style={{ cursor: 'pointer' }}>
|
||||
{children}
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
})
|
||||
|
|
|
@ -231,10 +231,7 @@ 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
|
||||
cost ??= amount
|
||||
|
||||
const onSubmitWrapper = useCallback(async ({ cost, ...formValues }, ...submitArgs) => {
|
||||
// action only allowed if logged in
|
||||
if (!me && options.requireSession) {
|
||||
throw new Error('you must be logged in')
|
||||
|
|
|
@ -25,7 +25,7 @@ export default function ItemJob ({ item, toc, rank, children }) {
|
|||
<div className={styles.item}>
|
||||
<Link href={`/items/${item.id}`}>
|
||||
<Image
|
||||
src={item.uploadId ? `https://${process.env.NEXT_PUBLIC_AWS_UPLOAD_BUCKET}.s3.amazonaws.com/${item.uploadId}` : '/jobs-default.png'} width='42' height='42' className={styles.companyImage}
|
||||
src={item.uploadId ? `https://${process.env.NEXT_PUBLIC_MEDIA_DOMAIN}/${item.uploadId}` : '/jobs-default.png'} width='42' height='42' className={styles.companyImage}
|
||||
/>
|
||||
</Link>
|
||||
<div className={`${styles.hunk} align-self-center mb-0`}>
|
||||
|
|
|
@ -106,7 +106,7 @@ export default function JobForm ({ item, sub }) {
|
|||
<label className='form-label'>logo</label>
|
||||
<div className='position-relative' style={{ width: 'fit-content' }}>
|
||||
<Image
|
||||
src={logoId ? `https://${process.env.NEXT_PUBLIC_AWS_UPLOAD_BUCKET}.s3.amazonaws.com/${logoId}` : '/jobs-default.png'} width='135' height='135' roundedCircle
|
||||
src={logoId ? `https://${process.env.NEXT_PUBLIC_MEDIA_DOMAIN}/${logoId}` : '/jobs-default.png'} width='135' height='135' roundedCircle
|
||||
/>
|
||||
<Avatar onSuccess={setLogoId} />
|
||||
</div>
|
||||
|
|
|
@ -1,90 +0,0 @@
|
|||
import { useRef } from 'react'
|
||||
import { gql, useMutation } from '@apollo/client'
|
||||
import { UPLOAD_TYPES_ALLOW } from '../lib/constants'
|
||||
|
||||
export default function Upload ({ as: Component, onSelect, onStarted, onError, onSuccess }) {
|
||||
const [getSignedPOST] = useMutation(
|
||||
gql`
|
||||
mutation getSignedPOST($type: String!, $size: Int!, $width: Int!, $height: Int!) {
|
||||
getSignedPOST(type: $type, size: $size, width: $width, height: $height) {
|
||||
url
|
||||
fields
|
||||
}
|
||||
}`)
|
||||
const ref = useRef()
|
||||
|
||||
const upload = file => {
|
||||
onStarted && onStarted()
|
||||
|
||||
const img = new window.Image()
|
||||
img.src = window.URL.createObjectURL(file)
|
||||
img.onload = async () => {
|
||||
let data
|
||||
try {
|
||||
({ data } = await getSignedPOST({
|
||||
variables: {
|
||||
type: file.type,
|
||||
size: file.size,
|
||||
width: img.width,
|
||||
height: img.height
|
||||
}
|
||||
}))
|
||||
} catch (e) {
|
||||
onError && onError(e.toString())
|
||||
return
|
||||
}
|
||||
|
||||
const form = new FormData()
|
||||
Object.keys(data.getSignedPOST.fields).forEach(key =>
|
||||
form.append(key, data.getSignedPOST.fields[key]))
|
||||
form.append('Content-Type', file.type)
|
||||
form.append('Cache-Control', 'max-age=31536000')
|
||||
form.append('acl', 'public-read')
|
||||
form.append('file', file)
|
||||
|
||||
const res = await fetch(data.getSignedPOST.url, {
|
||||
method: 'POST',
|
||||
body: form
|
||||
})
|
||||
|
||||
if (!res.ok) {
|
||||
onError && onError(res.statusText)
|
||||
return
|
||||
}
|
||||
|
||||
onSuccess && onSuccess(data.getSignedPOST.fields.key)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<input
|
||||
ref={ref}
|
||||
type='file'
|
||||
className='d-none'
|
||||
accept={UPLOAD_TYPES_ALLOW.join(', ')}
|
||||
onChange={(e) => {
|
||||
if (e.target.files.length === 0) {
|
||||
return
|
||||
}
|
||||
|
||||
const file = e.target.files[0]
|
||||
|
||||
if (UPLOAD_TYPES_ALLOW.indexOf(file.type) === -1) {
|
||||
onError && onError(`image must be ${UPLOAD_TYPES_ALLOW.map(t => t.replace('image/', '')).join(', ')}`)
|
||||
return
|
||||
}
|
||||
|
||||
if (onSelect) {
|
||||
onSelect(file, upload)
|
||||
} else {
|
||||
upload(file)
|
||||
}
|
||||
|
||||
e.target.value = null
|
||||
}}
|
||||
/>
|
||||
<Component onClick={() => ref.current?.click()} />
|
||||
</>
|
||||
)
|
||||
}
|
|
@ -71,14 +71,20 @@ function HeaderPhoto ({ user, isMe }) {
|
|||
}
|
||||
}
|
||||
})
|
||||
},
|
||||
onCompleted ({ setPhoto: photoId }) {
|
||||
const src = `https://${process.env.NEXT_PUBLIC_MEDIA_DOMAIN}/${user.photoId}`
|
||||
setSrc(src)
|
||||
}
|
||||
}
|
||||
)
|
||||
const initialSrc = user.photoId ? `https://${process.env.NEXT_PUBLIC_MEDIA_DOMAIN}/${user.photoId}` : '/dorian400.jpg'
|
||||
const [src, setSrc] = useState(initialSrc)
|
||||
|
||||
return (
|
||||
<div className='position-relative align-self-start' style={{ width: 'fit-content' }}>
|
||||
<Image
|
||||
src={user.photoId ? `https://${process.env.NEXT_PUBLIC_AWS_UPLOAD_BUCKET}.s3.amazonaws.com/${user.photoId}` : '/dorian400.jpg'} width='135' height='135'
|
||||
src={src} width='135' height='135'
|
||||
className={styles.userimg}
|
||||
/>
|
||||
{isMe &&
|
||||
|
|
|
@ -66,7 +66,7 @@ export default function UserList ({ ssrData, query, variables, destructureData }
|
|||
<div className={`${styles.item} mb-2`} key={user.name}>
|
||||
<Link href={`/${user.name}`}>
|
||||
<Image
|
||||
src={user.photoId ? `https://${process.env.NEXT_PUBLIC_AWS_UPLOAD_BUCKET}.s3.amazonaws.com/${user.photoId}` : '/dorian400.jpg'} width='32' height='32'
|
||||
src={user.photoId ? `https://${process.env.NEXT_PUBLIC_MEDIA_DOMAIN}/${user.photoId}` : '/dorian400.jpg'} width='32' height='32'
|
||||
className={`${userStyles.userimg} me-2`}
|
||||
/>
|
||||
</Link>
|
||||
|
|
|
@ -6,8 +6,10 @@ export const SUBS_NO_JOBS = SUBS.filter(s => s !== 'jobs')
|
|||
export const NOFOLLOW_LIMIT = 1000
|
||||
export const BOOST_MULT = 5000
|
||||
export const BOOST_MIN = BOOST_MULT * 5
|
||||
export const UPLOAD_SIZE_MAX = 2 * 1024 * 1024
|
||||
export const UPLOAD_SIZE_MAX = 25 * 1024 * 1024
|
||||
export const UPLOAD_SIZE_MAX_AVATAR = 5 * 1024 * 1024
|
||||
export const IMAGE_PIXELS_MAX = 35000000
|
||||
export const AWS_S3_URL_REGEXP = new RegExp(`https://${process.env.NEXT_PUBLIC_MEDIA_DOMAIN}/([0-9]+)`, 'g')
|
||||
export const UPLOAD_TYPES_ALLOW = [
|
||||
'image/gif',
|
||||
'image/heic',
|
||||
|
|
|
@ -0,0 +1,18 @@
|
|||
/*
|
||||
Warnings:
|
||||
|
||||
- You are about to drop the column `itemId` on the `Upload` table. All the data in the column will be lost.
|
||||
|
||||
*/
|
||||
-- DropForeignKey
|
||||
ALTER TABLE "Upload" DROP CONSTRAINT "Upload_itemId_fkey";
|
||||
|
||||
-- DropIndex
|
||||
DROP INDEX "Upload.itemId_index";
|
||||
|
||||
-- DropIndex
|
||||
DROP INDEX "Upload.itemId_unique";
|
||||
|
||||
-- AlterTable
|
||||
ALTER TABLE "Upload" DROP COLUMN "itemId",
|
||||
ADD COLUMN "paid" BOOLEAN;
|
|
@ -0,0 +1,17 @@
|
|||
-- add 'deleteUnusedImages' job
|
||||
CREATE OR REPLACE FUNCTION create_delete_unused_images_job()
|
||||
RETURNS INTEGER
|
||||
LANGUAGE plpgsql
|
||||
AS $$
|
||||
DECLARE
|
||||
BEGIN
|
||||
INSERT INTO pgboss.schedule (name, cron, timezone) VALUES ('deleteUnusedImages', '0 * * * *', 'America/Chicago') ON CONFLICT DO NOTHING;
|
||||
return 0;
|
||||
EXCEPTION WHEN OTHERS THEN
|
||||
return 0;
|
||||
END;
|
||||
$$;
|
||||
|
||||
SELECT create_delete_unused_images_job();
|
||||
|
||||
DROP FUNCTION create_delete_unused_images_job;
|
|
@ -0,0 +1,241 @@
|
|||
-- function to calculate image fees info for given user and upload ids
|
||||
CREATE OR REPLACE FUNCTION image_fees_info(user_id INTEGER, upload_ids INTEGER[])
|
||||
RETURNS TABLE (
|
||||
"bytes24h" INTEGER,
|
||||
"bytesUnpaid" INTEGER,
|
||||
"nUnpaid" INTEGER,
|
||||
"imageFeeMsats" BIGINT
|
||||
)
|
||||
LANGUAGE plpgsql
|
||||
AS $$
|
||||
BEGIN
|
||||
RETURN QUERY SELECT
|
||||
uploadinfo.*,
|
||||
CASE
|
||||
-- anons always pay 100 sats per image
|
||||
WHEN user_id = 27 THEN 100000::BIGINT
|
||||
ELSE CASE
|
||||
-- 10 MB are free per stacker and 24 hours
|
||||
WHEN uploadinfo."bytes24h" + uploadinfo."bytesUnpaid" <= 10 * 1024 * 1024 THEN 0::BIGINT
|
||||
WHEN uploadinfo."bytes24h" + uploadinfo."bytesUnpaid" <= 25 * 1024 * 1024 THEN 10000::BIGINT
|
||||
WHEN uploadinfo."bytes24h" + uploadinfo."bytesUnpaid" <= 50 * 1024 * 1024 THEN 100000::BIGINT
|
||||
WHEN uploadinfo."bytes24h" + uploadinfo."bytesUnpaid" <= 100 * 1024 * 1024 THEN 1000000::BIGINT
|
||||
ELSE 10000000::BIGINT
|
||||
END
|
||||
END AS "imageFeeMsats"
|
||||
FROM (
|
||||
SELECT
|
||||
-- how much bytes did stacker upload in last 24 hours?
|
||||
COALESCE(SUM(size) FILTER(WHERE paid = 't' AND created_at >= NOW() - interval '24 hours'), 0)::INTEGER AS "bytes24h",
|
||||
-- how much unpaid bytes do they want to upload now?
|
||||
COALESCE(SUM(size) FILTER(WHERE paid = 'f' AND id = ANY(upload_ids)), 0)::INTEGER AS "bytesUnpaid",
|
||||
-- how many unpaid images do they want to upload now?
|
||||
COALESCE(COUNT(id) FILTER(WHERE paid = 'f' AND id = ANY(upload_ids)), 0)::INTEGER AS "nUnpaid"
|
||||
FROM "Upload"
|
||||
WHERE "Upload"."userId" = user_id
|
||||
) uploadinfo;
|
||||
RETURN;
|
||||
END;
|
||||
$$;
|
||||
|
||||
-- add image fees
|
||||
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;
|
||||
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 item."maxBid" IS NOT NULL THEN
|
||||
cost_msats := 1000000;
|
||||
ELSE
|
||||
cost_msats := 1000 * 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 a 1 sat cost, they have < 1 sat, and boost = 0
|
||||
freebie := (cost_msats <= 1000) AND (user_msats < 1000) 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;
|
||||
|
||||
-- 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;
|
||||
$$;
|
||||
|
||||
-- add image fees
|
||||
CREATE OR REPLACE FUNCTION update_item(
|
||||
jitem JSONB, forward JSONB, poll_options JSONB, upload_ids INTEGER[])
|
||||
RETURNS "Item"
|
||||
LANGUAGE plpgsql
|
||||
AS $$
|
||||
DECLARE
|
||||
user_msats INTEGER;
|
||||
cost_msats BIGINT;
|
||||
item "Item";
|
||||
select_clause TEXT;
|
||||
BEGIN
|
||||
PERFORM ASSERT_SERIALIZED();
|
||||
|
||||
item := jsonb_populate_record(NULL::"Item", jitem);
|
||||
|
||||
SELECT msats INTO user_msats FROM users WHERE id = item."userId";
|
||||
cost_msats := 0;
|
||||
|
||||
-- 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;
|
||||
|
||||
IF cost_msats > 0 AND cost_msats > user_msats THEN
|
||||
RAISE EXCEPTION 'SN_INSUFFICIENT_FUNDS';
|
||||
ELSE
|
||||
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 item.boost > 0 THEN
|
||||
UPDATE "Item" SET boost = boost + item.boost WHERE id = item.id;
|
||||
PERFORM item_act(item.id, item."userId", 'BOOST', item.boost);
|
||||
END IF;
|
||||
|
||||
IF item.status IS NOT NULL THEN
|
||||
UPDATE "Item" SET "statusUpdatedAt" = now_utc()
|
||||
WHERE id = item.id AND status <> item.status;
|
||||
END IF;
|
||||
|
||||
SELECT string_agg(quote_ident(key), ',') INTO select_clause
|
||||
FROM jsonb_object_keys(jsonb_strip_nulls(jitem)) k(key)
|
||||
WHERE key <> 'boost';
|
||||
|
||||
EXECUTE format($fmt$
|
||||
UPDATE "Item" SET (%s) = (
|
||||
SELECT %1$s
|
||||
FROM jsonb_populate_record(NULL::"Item", %L)
|
||||
) WHERE id = %L RETURNING *
|
||||
$fmt$, select_clause, jitem, item.id) INTO item;
|
||||
|
||||
-- Delete any old thread subs if the user is no longer a fwd recipient
|
||||
DELETE FROM "ThreadSubscription"
|
||||
WHERE "itemId" = item.id
|
||||
-- they aren't in the new forward list
|
||||
AND NOT EXISTS (SELECT 1 FROM jsonb_populate_recordset(NULL::"ItemForward", forward) as nf WHERE "ThreadSubscription"."userId" = nf."userId")
|
||||
-- and they are in the old forward list
|
||||
AND EXISTS (SELECT 1 FROM "ItemForward" WHERE "ItemForward"."itemId" = item.id AND "ItemForward"."userId" = "ThreadSubscription"."userId" );
|
||||
|
||||
-- Automatically subscribe any new forward recipients to the post
|
||||
INSERT INTO "ThreadSubscription" ("itemId", "userId")
|
||||
SELECT item.id, "userId" FROM jsonb_populate_recordset(NULL::"ItemForward", forward)
|
||||
EXCEPT
|
||||
SELECT item.id, "userId" FROM "ItemForward" WHERE "itemId" = item.id;
|
||||
|
||||
-- Delete all old forward entries, to recreate in next command
|
||||
DELETE FROM "ItemForward" WHERE "itemId" = item.id;
|
||||
|
||||
INSERT INTO "ItemForward" ("itemId", "userId", "pct")
|
||||
SELECT item.id, "userId", "pct" 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 this is a job
|
||||
IF item."maxBid" IS NOT NULL THEN
|
||||
PERFORM run_auction(item.id);
|
||||
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;
|
||||
$$;
|
|
@ -0,0 +1,237 @@
|
|||
-- CreateTable
|
||||
CREATE TABLE "ItemUpload" (
|
||||
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updated_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"itemId" INTEGER NOT NULL,
|
||||
"uploadId" INTEGER NOT NULL,
|
||||
|
||||
CONSTRAINT "ItemUpload_pkey" PRIMARY KEY ("itemId","uploadId")
|
||||
);
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "ItemUpload_created_at_idx" ON "ItemUpload"("created_at");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "ItemUpload_itemId_idx" ON "ItemUpload"("itemId");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "ItemUpload_uploadId_idx" ON "ItemUpload"("uploadId");
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "ItemUpload" ADD CONSTRAINT "ItemUpload_itemId_fkey" FOREIGN KEY ("itemId") REFERENCES "Item"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "ItemUpload" ADD CONSTRAINT "ItemUpload_uploadId_fkey" FOREIGN KEY ("uploadId") REFERENCES "Upload"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
-- add image fees
|
||||
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;
|
||||
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 item."maxBid" IS NOT NULL THEN
|
||||
cost_msats := 1000000;
|
||||
ELSE
|
||||
cost_msats := 1000 * 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 a 1 sat cost, they have < 1 sat, and boost = 0
|
||||
freebie := (cost_msats <= 1000) AND (user_msats < 1000) 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;
|
||||
$$;
|
||||
|
||||
-- add image fees
|
||||
CREATE OR REPLACE FUNCTION update_item(
|
||||
jitem JSONB, forward JSONB, poll_options JSONB, upload_ids INTEGER[])
|
||||
RETURNS "Item"
|
||||
LANGUAGE plpgsql
|
||||
AS $$
|
||||
DECLARE
|
||||
user_msats INTEGER;
|
||||
cost_msats BIGINT;
|
||||
item "Item";
|
||||
select_clause TEXT;
|
||||
BEGIN
|
||||
PERFORM ASSERT_SERIALIZED();
|
||||
|
||||
item := jsonb_populate_record(NULL::"Item", jitem);
|
||||
|
||||
SELECT msats INTO user_msats FROM users WHERE id = item."userId";
|
||||
cost_msats := 0;
|
||||
|
||||
-- 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);
|
||||
-- delete any old uploads that are no longer attached
|
||||
DELETE FROM "ItemUpload" WHERE "itemId" = item.id AND "uploadId" <> ANY(upload_ids);
|
||||
-- insert any new uploads that are not already attached
|
||||
INSERT INTO "ItemUpload" ("itemId", "uploadId")
|
||||
SELECT item.id, * FROM UNNEST(upload_ids) ON CONFLICT DO NOTHING;
|
||||
END IF;
|
||||
|
||||
IF cost_msats > 0 AND cost_msats > user_msats THEN
|
||||
RAISE EXCEPTION 'SN_INSUFFICIENT_FUNDS';
|
||||
ELSE
|
||||
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 item.boost > 0 THEN
|
||||
UPDATE "Item" SET boost = boost + item.boost WHERE id = item.id;
|
||||
PERFORM item_act(item.id, item."userId", 'BOOST', item.boost);
|
||||
END IF;
|
||||
|
||||
IF item.status IS NOT NULL THEN
|
||||
UPDATE "Item" SET "statusUpdatedAt" = now_utc()
|
||||
WHERE id = item.id AND status <> item.status;
|
||||
END IF;
|
||||
|
||||
SELECT string_agg(quote_ident(key), ',') INTO select_clause
|
||||
FROM jsonb_object_keys(jsonb_strip_nulls(jitem)) k(key)
|
||||
WHERE key <> 'boost';
|
||||
|
||||
EXECUTE format($fmt$
|
||||
UPDATE "Item" SET (%s) = (
|
||||
SELECT %1$s
|
||||
FROM jsonb_populate_record(NULL::"Item", %L)
|
||||
) WHERE id = %L RETURNING *
|
||||
$fmt$, select_clause, jitem, item.id) INTO item;
|
||||
|
||||
-- Delete any old thread subs if the user is no longer a fwd recipient
|
||||
DELETE FROM "ThreadSubscription"
|
||||
WHERE "itemId" = item.id
|
||||
-- they aren't in the new forward list
|
||||
AND NOT EXISTS (SELECT 1 FROM jsonb_populate_recordset(NULL::"ItemForward", forward) as nf WHERE "ThreadSubscription"."userId" = nf."userId")
|
||||
-- and they are in the old forward list
|
||||
AND EXISTS (SELECT 1 FROM "ItemForward" WHERE "ItemForward"."itemId" = item.id AND "ItemForward"."userId" = "ThreadSubscription"."userId" );
|
||||
|
||||
-- Automatically subscribe any new forward recipients to the post
|
||||
INSERT INTO "ThreadSubscription" ("itemId", "userId")
|
||||
SELECT item.id, "userId" FROM jsonb_populate_recordset(NULL::"ItemForward", forward)
|
||||
EXCEPT
|
||||
SELECT item.id, "userId" FROM "ItemForward" WHERE "itemId" = item.id;
|
||||
|
||||
-- Delete all old forward entries, to recreate in next command
|
||||
DELETE FROM "ItemForward" WHERE "itemId" = item.id;
|
||||
|
||||
INSERT INTO "ItemForward" ("itemId", "userId", "pct")
|
||||
SELECT item.id, "userId", "pct" 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 this is a job
|
||||
IF item."maxBid" IS NOT NULL THEN
|
||||
PERFORM run_auction(item.id);
|
||||
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;
|
||||
$$;
|
|
@ -0,0 +1,5 @@
|
|||
-- CreateIndex
|
||||
CREATE INDEX "Item_uploadId_idx" ON "Item"("uploadId");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "users_photoId_idx" ON "users"("photoId");
|
|
@ -100,6 +100,7 @@ model User {
|
|||
ArcOut Arc[] @relation("fromUser")
|
||||
ArcIn Arc[] @relation("toUser")
|
||||
|
||||
@@index([photoId])
|
||||
@@index([createdAt], map: "users.created_at_index")
|
||||
@@index([inviteId], map: "users.inviteId_index")
|
||||
@@map("users")
|
||||
|
@ -168,6 +169,20 @@ model Donation {
|
|||
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||
}
|
||||
|
||||
model ItemUpload {
|
||||
createdAt DateTime @default(now()) @map("created_at")
|
||||
updatedAt DateTime @default(now()) @updatedAt @map("updated_at")
|
||||
itemId Int
|
||||
uploadId Int
|
||||
item Item @relation(fields: [itemId], references: [id], onDelete: Cascade)
|
||||
upload Upload @relation(fields: [uploadId], references: [id], onDelete: Cascade)
|
||||
|
||||
@@id([itemId, uploadId])
|
||||
@@index([createdAt])
|
||||
@@index([itemId])
|
||||
@@index([uploadId])
|
||||
}
|
||||
|
||||
model Upload {
|
||||
id Int @id @default(autoincrement())
|
||||
createdAt DateTime @default(now()) @map("created_at")
|
||||
|
@ -176,14 +191,13 @@ model Upload {
|
|||
size Int
|
||||
width Int?
|
||||
height Int?
|
||||
itemId Int? @unique(map: "Upload.itemId_unique")
|
||||
userId Int
|
||||
item Item? @relation(fields: [itemId], references: [id])
|
||||
paid Boolean?
|
||||
user User @relation("Uploads", fields: [userId], references: [id], onDelete: Cascade)
|
||||
User User[]
|
||||
ItemUpload ItemUpload[]
|
||||
|
||||
@@index([createdAt], map: "Upload.created_at_index")
|
||||
@@index([itemId], map: "Upload.itemId_index")
|
||||
@@index([userId], map: "Upload.userId_index")
|
||||
}
|
||||
|
||||
|
@ -267,7 +281,6 @@ model Item {
|
|||
company String?
|
||||
weightedVotes Float @default(0)
|
||||
boost Int @default(0)
|
||||
uploadId Int?
|
||||
pollCost Int?
|
||||
paidImgLink Boolean @default(false)
|
||||
commentMsats BigInt @default(0)
|
||||
|
@ -299,10 +312,12 @@ model Item {
|
|||
PollOption PollOption[]
|
||||
PollVote PollVote[]
|
||||
ThreadSubscription ThreadSubscription[]
|
||||
upload Upload?
|
||||
User User[]
|
||||
itemForwards ItemForward[]
|
||||
ItemUpload ItemUpload[]
|
||||
uploadId Int?
|
||||
|
||||
@@index([uploadId])
|
||||
@@index([bio], map: "Item.bio_index")
|
||||
@@index([createdAt], map: "Item.created_at_index")
|
||||
@@index([freebie], map: "Item.freebie_index")
|
||||
|
|
|
@ -0,0 +1,26 @@
|
|||
import { deleteObjects } from '../api/s3'
|
||||
|
||||
export function deleteUnusedImages ({ models }) {
|
||||
return async function ({ name }) {
|
||||
console.log('running', name)
|
||||
|
||||
// delete all images in database and S3 which weren't paid in the last 24 hours
|
||||
const unpaidImages = await models.$queryRaw`
|
||||
SELECT id
|
||||
FROM "Upload"
|
||||
WHERE (paid = 'f'
|
||||
OR (
|
||||
-- for non-textarea images, they are free and paid is null
|
||||
paid IS NULL
|
||||
-- if the image is not used by a user or item (eg jobs), delete it
|
||||
AND NOT EXISTS (SELECT * FROM users WHERE "photoId" = "Upload".id)
|
||||
AND NOT EXISTS (SELECT * FROM "Item" WHERE "uploadId" = "Upload".id)
|
||||
))
|
||||
AND created_at < date_trunc('hour', now() - interval '24 hours')`
|
||||
|
||||
const s3Keys = unpaidImages.map(({ id }) => id)
|
||||
console.log('deleting images:', s3Keys)
|
||||
await deleteObjects(s3Keys)
|
||||
await models.upload.deleteMany({ where: { id: { in: s3Keys } } })
|
||||
}
|
||||
}
|
|
@ -64,10 +64,10 @@ export function imgproxy ({ models }) {
|
|||
let imgproxyUrls = {}
|
||||
try {
|
||||
if (item.text) {
|
||||
imgproxyUrls = await createImgproxyUrls(id, item.text, { forceFetch })
|
||||
imgproxyUrls = await createImgproxyUrls(id, item.text, { models, forceFetch })
|
||||
}
|
||||
if (item.url && !isJob(item)) {
|
||||
imgproxyUrls = { ...imgproxyUrls, ...(await createImgproxyUrls(id, item.url, { forceFetch })) }
|
||||
imgproxyUrls = { ...imgproxyUrls, ...(await createImgproxyUrls(id, item.url, { models, forceFetch })) }
|
||||
}
|
||||
} catch (err) {
|
||||
console.log('[imgproxy] error:', err)
|
||||
|
@ -81,7 +81,7 @@ export function imgproxy ({ models }) {
|
|||
}
|
||||
}
|
||||
|
||||
export const createImgproxyUrls = async (id, text, { forceFetch }) => {
|
||||
export const createImgproxyUrls = async (id, text, { models, forceFetch }) => {
|
||||
const urls = extractUrls(text)
|
||||
console.log('[imgproxy] id:', id, '-- extracted urls:', urls)
|
||||
// resolutions that we target:
|
||||
|
|
|
@ -16,6 +16,7 @@ import { authenticatedLndGrpc } from 'ln-service'
|
|||
import { views, rankViews } from './views.js'
|
||||
import { imgproxy } from './imgproxy.js'
|
||||
import { deleteItem } from './ephemeralItems.js'
|
||||
import { deleteUnusedImages } from './deleteUnusedImages.js'
|
||||
|
||||
const { loadEnvConfig } = nextEnv
|
||||
const { ApolloClient, HttpLink, InMemoryCache } = apolloClient
|
||||
|
@ -70,6 +71,7 @@ async function work () {
|
|||
await boss.work('rankViews', rankViews(args))
|
||||
await boss.work('imgproxy', imgproxy(args))
|
||||
await boss.work('deleteItem', deleteItem(args))
|
||||
await boss.work('deleteUnusedImages', deleteUnusedImages(args))
|
||||
|
||||
console.log('working jobs')
|
||||
}
|
||||
|
|
Loading…
Reference in New Issue