Allow video uploads (#1399)
* Allow video uploads * fix video preview --------- Co-authored-by: k00b <k00b@stacker.news>
This commit is contained in:
parent
b3d9eb0eba
commit
4340a82a62
|
@ -11,14 +11,14 @@ export async function getCost ({ subName, parentId, uploadIds, boost = 0, bio },
|
||||||
const sub = (parentId || bio) ? null : await models.sub.findUnique({ where: { name: subName } })
|
const sub = (parentId || bio) ? null : await models.sub.findUnique({ where: { name: subName } })
|
||||||
const baseCost = sub ? satsToMsats(sub.baseCost) : 1000n
|
const baseCost = sub ? satsToMsats(sub.baseCost) : 1000n
|
||||||
|
|
||||||
// cost = baseCost * 10^num_items_in_10m * 100 (anon) or 1 (user) + image fees + boost
|
// cost = baseCost * 10^num_items_in_10m * 100 (anon) or 1 (user) + upload fees + boost
|
||||||
const [{ cost }] = await models.$queryRaw`
|
const [{ cost }] = await models.$queryRaw`
|
||||||
SELECT ${baseCost}::INTEGER
|
SELECT ${baseCost}::INTEGER
|
||||||
* POWER(10, item_spam(${parseInt(parentId)}::INTEGER, ${me?.id ?? USER_ID.anon}::INTEGER,
|
* POWER(10, item_spam(${parseInt(parentId)}::INTEGER, ${me?.id ?? USER_ID.anon}::INTEGER,
|
||||||
${me?.id && !bio ? ITEM_SPAM_INTERVAL : ANON_ITEM_SPAM_INTERVAL}::INTERVAL))
|
${me?.id && !bio ? ITEM_SPAM_INTERVAL : ANON_ITEM_SPAM_INTERVAL}::INTERVAL))
|
||||||
* ${me ? 1 : 100}::INTEGER
|
* ${me ? 1 : 100}::INTEGER
|
||||||
+ (SELECT "nUnpaid" * "imageFeeMsats"
|
+ (SELECT "nUnpaid" * "uploadFeesMsats"
|
||||||
FROM image_fees_info(${me?.id || USER_ID.anon}::INTEGER, ${uploadIds}::INTEGER[]))
|
FROM upload_fees(${me?.id || USER_ID.anon}::INTEGER, ${uploadIds}::INTEGER[]))
|
||||||
+ ${satsToMsats(boost)}::INTEGER as cost`
|
+ ${satsToMsats(boost)}::INTEGER as cost`
|
||||||
|
|
||||||
// sub allows freebies (or is a bio or a comment), cost is less than baseCost, not anon,
|
// sub allows freebies (or is a bio or a comment), cost is less than baseCost, not anon,
|
||||||
|
|
|
@ -1,5 +1,5 @@
|
||||||
import { USER_ID } from '@/lib/constants'
|
import { USER_ID } from '@/lib/constants'
|
||||||
import { imageFeesInfo } from '../resolvers/image'
|
import { uploadFees } from '../resolvers/upload'
|
||||||
import { getItemMentions, getMentions, performBotBehavior } from './lib/item'
|
import { getItemMentions, getMentions, performBotBehavior } from './lib/item'
|
||||||
import { notifyItemMention, notifyMention } from '@/lib/webPush'
|
import { notifyItemMention, notifyMention } from '@/lib/webPush'
|
||||||
import { satsToMsats } from '@/lib/format'
|
import { satsToMsats } from '@/lib/format'
|
||||||
|
@ -12,7 +12,7 @@ export async function getCost ({ id, boost = 0, uploadIds }, { me, models }) {
|
||||||
// the only reason updating items costs anything is when it has new uploads
|
// the only reason updating items costs anything is when it has new uploads
|
||||||
// or more boost
|
// or more boost
|
||||||
const old = await models.item.findUnique({ where: { id: parseInt(id) } })
|
const old = await models.item.findUnique({ where: { id: parseInt(id) } })
|
||||||
const { totalFeesMsats } = await imageFeesInfo(uploadIds, { models, me })
|
const { totalFeesMsats } = await uploadFees(uploadIds, { models, me })
|
||||||
return BigInt(totalFeesMsats) + satsToMsats(boost - (old.boost || 0))
|
return BigInt(totalFeesMsats) + satsToMsats(boost - (old.boost || 0))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -1,25 +0,0 @@
|
||||||
import { 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 []
|
|
||||||
return [...new Set([...text.matchAll(AWS_S3_URL_REGEXP)].map(m => Number(m[1])))]
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function imageFeesInfo (s3Keys, { models, me }) {
|
|
||||||
// returns info object in this format:
|
|
||||||
// { bytes24h: int, bytesUnpaid: int, nUnpaid: int, imageFeeMsats: BigInt }
|
|
||||||
const [info] = await models.$queryRawUnsafe('SELECT * FROM image_fees_info($1::INTEGER, $2::INTEGER[])', me ? me.id : USER_ID.anon, s3Keys)
|
|
||||||
const imageFee = msatsToSats(info.imageFeeMsats)
|
|
||||||
const totalFeesMsats = info.nUnpaid * Number(info.imageFeeMsats)
|
|
||||||
const totalFees = msatsToSats(totalFeesMsats)
|
|
||||||
return { ...info, imageFee, totalFees, totalFeesMsats }
|
|
||||||
}
|
|
|
@ -16,7 +16,6 @@ import { GraphQLJSONObject as JSONObject } from 'graphql-type-json'
|
||||||
import admin from './admin'
|
import admin from './admin'
|
||||||
import blockHeight from './blockHeight'
|
import blockHeight from './blockHeight'
|
||||||
import chainFee from './chainFee'
|
import chainFee from './chainFee'
|
||||||
import image from './image'
|
|
||||||
import { GraphQLScalarType, Kind } from 'graphql'
|
import { GraphQLScalarType, Kind } from 'graphql'
|
||||||
import { createIntScalar } from 'graphql-scalar'
|
import { createIntScalar } from 'graphql-scalar'
|
||||||
import paidAction from './paidAction'
|
import paidAction from './paidAction'
|
||||||
|
@ -56,4 +55,4 @@ const limit = createIntScalar({
|
||||||
|
|
||||||
export default [user, item, message, wallet, lnurl, notifications, invite, sub,
|
export default [user, item, message, wallet, lnurl, notifications, invite, sub,
|
||||||
upload, search, growth, rewards, referrals, price, admin, blockHeight, chainFee,
|
upload, search, growth, rewards, referrals, price, admin, blockHeight, chainFee,
|
||||||
image, { JSONObject }, { Date: date }, { Limit: limit }, paidAction]
|
{ JSONObject }, { Date: date }, { Limit: limit }, paidAction]
|
||||||
|
|
|
@ -15,7 +15,7 @@ import uu from 'url-unshort'
|
||||||
import { actSchema, advSchema, bountySchema, commentSchema, discussionSchema, jobSchema, linkSchema, pollSchema, ssValidate } from '@/lib/validate'
|
import { actSchema, advSchema, bountySchema, commentSchema, discussionSchema, jobSchema, linkSchema, pollSchema, ssValidate } from '@/lib/validate'
|
||||||
import { defaultCommentSort, isJob, deleteItemByAuthor } from '@/lib/item'
|
import { defaultCommentSort, isJob, deleteItemByAuthor } from '@/lib/item'
|
||||||
import { datePivot, whenRange } from '@/lib/time'
|
import { datePivot, whenRange } from '@/lib/time'
|
||||||
import { uploadIdsFromText } from './image'
|
import { uploadIdsFromText } from './upload'
|
||||||
import assertGofacYourself from './ofac'
|
import assertGofacYourself from './ofac'
|
||||||
import assertApiKeyNotPermitted from './apiKey'
|
import assertApiKeyNotPermitted from './apiKey'
|
||||||
import performPaidAction from '../paidAction'
|
import performPaidAction from '../paidAction'
|
||||||
|
|
|
@ -1,8 +1,14 @@
|
||||||
import { USER_ID, IMAGE_PIXELS_MAX, UPLOAD_SIZE_MAX, UPLOAD_SIZE_MAX_AVATAR, UPLOAD_TYPES_ALLOW } from '@/lib/constants'
|
import { USER_ID, IMAGE_PIXELS_MAX, UPLOAD_SIZE_MAX, UPLOAD_SIZE_MAX_AVATAR, UPLOAD_TYPES_ALLOW, AWS_S3_URL_REGEXP } from '@/lib/constants'
|
||||||
import { createPresignedPost } from '@/api/s3'
|
import { createPresignedPost } from '@/api/s3'
|
||||||
import { GqlAuthenticationError, GqlInputError } from '@/lib/error'
|
import { GqlAuthenticationError, GqlInputError } from '@/lib/error'
|
||||||
|
import { msatsToSats } from '@/lib/format'
|
||||||
|
|
||||||
export default {
|
export default {
|
||||||
|
Query: {
|
||||||
|
uploadFees: async (parent, { s3Keys }, { models, me }) => {
|
||||||
|
return uploadFees(s3Keys, { models, me })
|
||||||
|
}
|
||||||
|
},
|
||||||
Mutation: {
|
Mutation: {
|
||||||
getSignedPOST: async (parent, { type, size, width, height, avatar }, { models, me }) => {
|
getSignedPOST: async (parent, { type, size, width, height, avatar }, { models, me }) => {
|
||||||
if (UPLOAD_TYPES_ALLOW.indexOf(type) === -1) {
|
if (UPLOAD_TYPES_ALLOW.indexOf(type) === -1) {
|
||||||
|
@ -40,3 +46,18 @@ export default {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function uploadIdsFromText (text, { models }) {
|
||||||
|
if (!text) return []
|
||||||
|
return [...new Set([...text.matchAll(AWS_S3_URL_REGEXP)].map(m => Number(m[1])))]
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function uploadFees (s3Keys, { models, me }) {
|
||||||
|
// returns info object in this format:
|
||||||
|
// { bytes24h: int, bytesUnpaid: int, nUnpaid: int, uploadFeesMsats: BigInt }
|
||||||
|
const [info] = await models.$queryRawUnsafe('SELECT * FROM upload_fees($1::INTEGER, $2::INTEGER[])', me ? me.id : USER_ID.anon, s3Keys)
|
||||||
|
const uploadFees = msatsToSats(info.uploadFeesMsats)
|
||||||
|
const totalFeesMsats = info.nUnpaid * Number(info.uploadFeesMsats)
|
||||||
|
const totalFees = msatsToSats(totalFeesMsats)
|
||||||
|
return { ...info, uploadFees, totalFees, totalFeesMsats }
|
||||||
|
}
|
||||||
|
|
|
@ -1,16 +0,0 @@
|
||||||
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!
|
|
||||||
}
|
|
||||||
`
|
|
|
@ -17,7 +17,6 @@ import price from './price'
|
||||||
import admin from './admin'
|
import admin from './admin'
|
||||||
import blockHeight from './blockHeight'
|
import blockHeight from './blockHeight'
|
||||||
import chainFee from './chainFee'
|
import chainFee from './chainFee'
|
||||||
import image from './image'
|
|
||||||
import paidAction from './paidAction'
|
import paidAction from './paidAction'
|
||||||
|
|
||||||
const common = gql`
|
const common = gql`
|
||||||
|
@ -39,4 +38,4 @@ const common = gql`
|
||||||
`
|
`
|
||||||
|
|
||||||
export default [common, user, item, itemForward, message, wallet, lnurl, notifications, invite,
|
export default [common, user, item, itemForward, message, wallet, lnurl, notifications, invite,
|
||||||
sub, upload, growth, rewards, referrals, price, admin, blockHeight, chainFee, image, paidAction]
|
sub, upload, growth, rewards, referrals, price, admin, blockHeight, chainFee, paidAction]
|
||||||
|
|
|
@ -1,12 +1,26 @@
|
||||||
import { gql } from 'graphql-tag'
|
import { gql } from 'graphql-tag'
|
||||||
|
|
||||||
export default gql`
|
export default gql`
|
||||||
extend type Mutation {
|
type UploadFees {
|
||||||
getSignedPOST(type: String!, size: Int!, width: Int!, height: Int!, avatar: Boolean): SignedPost!
|
totalFees: Int!
|
||||||
|
totalFeesMsats: Int!
|
||||||
|
uploadFees: Int!
|
||||||
|
uploadFeesMsats: Int!
|
||||||
|
nUnpaid: Int!
|
||||||
|
bytesUnpaid: Int!
|
||||||
|
bytes24h: Int!
|
||||||
}
|
}
|
||||||
|
|
||||||
type SignedPost {
|
type SignedPost {
|
||||||
url: String!
|
url: String!
|
||||||
fields: JSONObject!
|
fields: JSONObject!
|
||||||
}
|
}
|
||||||
|
|
||||||
|
extend type Query {
|
||||||
|
uploadFees(s3Keys: [Int]!): UploadFees!
|
||||||
|
}
|
||||||
|
|
||||||
|
extend type Mutation {
|
||||||
|
getSignedPOST(type: String!, size: Int!, width: Int!, height: Int!, avatar: Boolean): SignedPost!
|
||||||
|
}
|
||||||
`
|
`
|
||||||
|
|
|
@ -5,7 +5,7 @@ import BootstrapForm from 'react-bootstrap/Form'
|
||||||
import EditImage from '@/svgs/image-edit-fill.svg'
|
import EditImage from '@/svgs/image-edit-fill.svg'
|
||||||
import Moon from '@/svgs/moon-fill.svg'
|
import Moon from '@/svgs/moon-fill.svg'
|
||||||
import { useShowModal } from './modal'
|
import { useShowModal } from './modal'
|
||||||
import { ImageUpload } from './image-upload'
|
import { FileUpload } from './file-upload'
|
||||||
|
|
||||||
export default function Avatar ({ onSuccess }) {
|
export default function Avatar ({ onSuccess }) {
|
||||||
const [uploading, setUploading] = useState()
|
const [uploading, setUploading] = useState()
|
||||||
|
@ -49,7 +49,8 @@ export default function Avatar ({ onSuccess }) {
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<ImageUpload
|
<FileUpload
|
||||||
|
allow='image/*'
|
||||||
avatar
|
avatar
|
||||||
onError={e => {
|
onError={e => {
|
||||||
console.log(e)
|
console.log(e)
|
||||||
|
@ -84,6 +85,6 @@ export default function Avatar ({ onSuccess }) {
|
||||||
? <Moon className='fill-white spin' />
|
? <Moon className='fill-white spin' />
|
||||||
: <EditImage className='fill-white' />}
|
: <EditImage className='fill-white' />}
|
||||||
</div>
|
</div>
|
||||||
</ImageUpload>
|
</FileUpload>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
|
@ -5,7 +5,7 @@ import gql from 'graphql-tag'
|
||||||
import { useMutation } from '@apollo/client'
|
import { useMutation } from '@apollo/client'
|
||||||
import piexif from 'piexifjs'
|
import piexif from 'piexifjs'
|
||||||
|
|
||||||
export const ImageUpload = forwardRef(({ children, className, onSelect, onUpload, onSuccess, onError, multiple, avatar }, ref) => {
|
export const FileUpload = forwardRef(({ children, className, onSelect, onUpload, onSuccess, onError, multiple, avatar, allow }, ref) => {
|
||||||
const toaster = useToast()
|
const toaster = useToast()
|
||||||
ref ??= useRef(null)
|
ref ??= useRef(null)
|
||||||
|
|
||||||
|
@ -19,18 +19,22 @@ export const ImageUpload = forwardRef(({ children, className, onSelect, onUpload
|
||||||
}`)
|
}`)
|
||||||
|
|
||||||
const s3Upload = useCallback(async file => {
|
const s3Upload = useCallback(async file => {
|
||||||
const img = new window.Image()
|
const element = file.type.startsWith('image/')
|
||||||
|
? new window.Image()
|
||||||
|
: document.createElement('video')
|
||||||
|
|
||||||
file = await removeExifData(file)
|
file = await removeExifData(file)
|
||||||
|
|
||||||
return new Promise((resolve, reject) => {
|
return new Promise((resolve, reject) => {
|
||||||
img.onload = async () => {
|
async function onload () {
|
||||||
onUpload?.(file)
|
onUpload?.(file)
|
||||||
let data
|
let data
|
||||||
const variables = {
|
const variables = {
|
||||||
avatar,
|
avatar,
|
||||||
type: file.type,
|
type: file.type,
|
||||||
size: file.size,
|
size: file.size,
|
||||||
width: img.width,
|
width: element.width,
|
||||||
height: img.height
|
height: element.height
|
||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
({ data } = await getSignedPOST({ variables }))
|
({ data } = await getSignedPOST({ variables }))
|
||||||
|
@ -66,13 +70,22 @@ export const ImageUpload = forwardRef(({ children, className, onSelect, onUpload
|
||||||
// key is upload id in database
|
// key is upload id in database
|
||||||
const id = data.getSignedPOST.fields.key
|
const id = data.getSignedPOST.fields.key
|
||||||
onSuccess?.({ ...variables, id, name: file.name, url, file })
|
onSuccess?.({ ...variables, id, name: file.name, url, file })
|
||||||
|
|
||||||
|
console.log('resolve id', id)
|
||||||
resolve(id)
|
resolve(id)
|
||||||
}
|
}
|
||||||
img.onerror = reject
|
|
||||||
img.src = window.URL.createObjectURL(file)
|
// img fire 'load' event while videos fire 'loadeddata'
|
||||||
|
element.onload = onload
|
||||||
|
element.onloadeddata = onload
|
||||||
|
|
||||||
|
element.onerror = reject
|
||||||
|
element.src = window.URL.createObjectURL(file)
|
||||||
})
|
})
|
||||||
}, [toaster, getSignedPOST])
|
}, [toaster, getSignedPOST])
|
||||||
|
|
||||||
|
const accept = UPLOAD_TYPES_ALLOW.filter(type => allow ? new RegExp(allow).test(type) : true)
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<input
|
<input
|
||||||
|
@ -80,12 +93,12 @@ export const ImageUpload = forwardRef(({ children, className, onSelect, onUpload
|
||||||
type='file'
|
type='file'
|
||||||
multiple={multiple}
|
multiple={multiple}
|
||||||
className='d-none'
|
className='d-none'
|
||||||
accept={UPLOAD_TYPES_ALLOW.join(', ')}
|
accept={accept.join(', ')}
|
||||||
onChange={async (e) => {
|
onChange={async (e) => {
|
||||||
const fileList = e.target.files
|
const fileList = e.target.files
|
||||||
for (const file of Array.from(fileList)) {
|
for (const file of Array.from(fileList)) {
|
||||||
if (UPLOAD_TYPES_ALLOW.indexOf(file.type) === -1) {
|
if (accept.indexOf(file.type) === -1) {
|
||||||
toaster.danger(`image must be ${UPLOAD_TYPES_ALLOW.map(t => t.replace('image/', '')).join(', ')}`)
|
toaster.danger(`image must be ${accept.map(t => t.replace('image/', '').replace('video/', '')).join(', ')}`)
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
if (onSelect) await onSelect?.(file, s3Upload)
|
if (onSelect) await onSelect?.(file, s3Upload)
|
|
@ -9,7 +9,7 @@ import Dropdown from 'react-bootstrap/Dropdown'
|
||||||
import Nav from 'react-bootstrap/Nav'
|
import Nav from 'react-bootstrap/Nav'
|
||||||
import Row from 'react-bootstrap/Row'
|
import Row from 'react-bootstrap/Row'
|
||||||
import Markdown from '@/svgs/markdown-line.svg'
|
import Markdown from '@/svgs/markdown-line.svg'
|
||||||
import AddImageIcon from '@/svgs/image-add-line.svg'
|
import AddFileIcon from '@/svgs/file-upload-line.svg'
|
||||||
import styles from './form.module.css'
|
import styles from './form.module.css'
|
||||||
import Text from '@/components/text'
|
import Text from '@/components/text'
|
||||||
import AddIcon from '@/svgs/add-fill.svg'
|
import AddIcon from '@/svgs/add-fill.svg'
|
||||||
|
@ -23,7 +23,7 @@ import textAreaCaret from 'textarea-caret'
|
||||||
import ReactDatePicker from 'react-datepicker'
|
import ReactDatePicker from 'react-datepicker'
|
||||||
import 'react-datepicker/dist/react-datepicker.css'
|
import 'react-datepicker/dist/react-datepicker.css'
|
||||||
import useDebounceCallback, { debounce } from './use-debounce-callback'
|
import useDebounceCallback, { debounce } from './use-debounce-callback'
|
||||||
import { ImageUpload } from './image-upload'
|
import { FileUpload } from './file-upload'
|
||||||
import { AWS_S3_URL_REGEXP } from '@/lib/constants'
|
import { AWS_S3_URL_REGEXP } from '@/lib/constants'
|
||||||
import { whenRange } from '@/lib/time'
|
import { whenRange } from '@/lib/time'
|
||||||
import { useFeeButton } from './fee-button'
|
import { useFeeButton } from './fee-button'
|
||||||
|
@ -122,12 +122,12 @@ export function MarkdownInput ({ label, topLevel, groupClassName, onChange, onKe
|
||||||
const previousTab = useRef(tab)
|
const previousTab = useRef(tab)
|
||||||
const { merge, setDisabled: setSubmitDisabled } = useFeeButton()
|
const { merge, setDisabled: setSubmitDisabled } = useFeeButton()
|
||||||
|
|
||||||
const [updateImageFeesInfo] = useLazyQuery(gql`
|
const [updateUploadFees] = useLazyQuery(gql`
|
||||||
query imageFeesInfo($s3Keys: [Int]!) {
|
query uploadFees($s3Keys: [Int]!) {
|
||||||
imageFeesInfo(s3Keys: $s3Keys) {
|
uploadFees(s3Keys: $s3Keys) {
|
||||||
totalFees
|
totalFees
|
||||||
nUnpaid
|
nUnpaid
|
||||||
imageFee
|
uploadFees
|
||||||
bytes24h
|
bytes24h
|
||||||
}
|
}
|
||||||
}`, {
|
}`, {
|
||||||
|
@ -136,13 +136,13 @@ export function MarkdownInput ({ label, topLevel, groupClassName, onChange, onKe
|
||||||
onError: (err) => {
|
onError: (err) => {
|
||||||
console.error(err)
|
console.error(err)
|
||||||
},
|
},
|
||||||
onCompleted: ({ imageFeesInfo }) => {
|
onCompleted: ({ uploadFees }) => {
|
||||||
merge({
|
merge({
|
||||||
imageFee: {
|
uploadFees: {
|
||||||
term: `+ ${numWithUnits(imageFeesInfo.totalFees, { abbreviate: false })}`,
|
term: `+ ${numWithUnits(uploadFees.totalFees, { abbreviate: false })}`,
|
||||||
label: 'image fee',
|
label: 'upload fee',
|
||||||
modifier: cost => cost + imageFeesInfo.totalFees,
|
modifier: cost => cost + uploadFees.totalFees,
|
||||||
omit: !imageFeesInfo.totalFees
|
omit: !uploadFees.totalFees
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
@ -184,17 +184,17 @@ export function MarkdownInput ({ label, topLevel, groupClassName, onChange, onKe
|
||||||
innerRef.current.focus()
|
innerRef.current.focus()
|
||||||
}, [mention, meta?.value, helpers?.setValue])
|
}, [mention, meta?.value, helpers?.setValue])
|
||||||
|
|
||||||
const imageFeesUpdate = useDebounceCallback(
|
const uploadFeesUpdate = useDebounceCallback(
|
||||||
(text) => {
|
(text) => {
|
||||||
const s3Keys = text ? [...text.matchAll(AWS_S3_URL_REGEXP)].map(m => Number(m[1])) : []
|
const s3Keys = text ? [...text.matchAll(AWS_S3_URL_REGEXP)].map(m => Number(m[1])) : []
|
||||||
updateImageFeesInfo({ variables: { s3Keys } })
|
updateUploadFees({ variables: { s3Keys } })
|
||||||
}, 1000, [updateImageFeesInfo])
|
}, 1000, [updateUploadFees])
|
||||||
|
|
||||||
const onChangeInner = useCallback((formik, e) => {
|
const onChangeInner = useCallback((formik, e) => {
|
||||||
if (onChange) onChange(formik, e)
|
if (onChange) onChange(formik, e)
|
||||||
// check for mention editing
|
// check for mention editing
|
||||||
const { value, selectionStart } = e.target
|
const { value, selectionStart } = e.target
|
||||||
imageFeesUpdate(value)
|
uploadFeesUpdate(value)
|
||||||
|
|
||||||
if (!value || selectionStart === undefined) {
|
if (!value || selectionStart === undefined) {
|
||||||
setMention(undefined)
|
setMention(undefined)
|
||||||
|
@ -233,7 +233,7 @@ export function MarkdownInput ({ label, topLevel, groupClassName, onChange, onKe
|
||||||
} else {
|
} else {
|
||||||
setMention(undefined)
|
setMention(undefined)
|
||||||
}
|
}
|
||||||
}, [onChange, setMention, imageFeesUpdate])
|
}, [onChange, setMention, uploadFeesUpdate])
|
||||||
|
|
||||||
const onKeyDownInner = useCallback((userSuggestOnKeyDown) => {
|
const onKeyDownInner = useCallback((userSuggestOnKeyDown) => {
|
||||||
return (e) => {
|
return (e) => {
|
||||||
|
@ -321,7 +321,7 @@ export function MarkdownInput ({ label, topLevel, groupClassName, onChange, onKe
|
||||||
<Nav.Link className={styles.previewTab} eventKey='preview' disabled={!meta.value}>preview</Nav.Link>
|
<Nav.Link className={styles.previewTab} eventKey='preview' disabled={!meta.value}>preview</Nav.Link>
|
||||||
</Nav.Item>
|
</Nav.Item>
|
||||||
<span className='ms-auto text-muted d-flex align-items-center'>
|
<span className='ms-auto text-muted d-flex align-items-center'>
|
||||||
<ImageUpload
|
<FileUpload
|
||||||
multiple
|
multiple
|
||||||
ref={imageUploadRef}
|
ref={imageUploadRef}
|
||||||
className='d-flex align-items-center me-1'
|
className='d-flex align-items-center me-1'
|
||||||
|
@ -344,7 +344,7 @@ export function MarkdownInput ({ label, topLevel, groupClassName, onChange, onKe
|
||||||
text = text.replace(`![Uploading ${name}…]()`, `![](${url})`)
|
text = text.replace(`![Uploading ${name}…]()`, `![](${url})`)
|
||||||
helpers.setValue(text)
|
helpers.setValue(text)
|
||||||
const s3Keys = [...text.matchAll(AWS_S3_URL_REGEXP)].map(m => Number(m[1]))
|
const s3Keys = [...text.matchAll(AWS_S3_URL_REGEXP)].map(m => Number(m[1]))
|
||||||
updateImageFeesInfo({ variables: { s3Keys } })
|
updateUploadFees({ variables: { s3Keys } })
|
||||||
setSubmitDisabled?.(false)
|
setSubmitDisabled?.(false)
|
||||||
}}
|
}}
|
||||||
onError={({ name }) => {
|
onError={({ name }) => {
|
||||||
|
@ -354,8 +354,8 @@ export function MarkdownInput ({ label, topLevel, groupClassName, onChange, onKe
|
||||||
setSubmitDisabled?.(false)
|
setSubmitDisabled?.(false)
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<AddImageIcon width={18} height={18} />
|
<AddFileIcon width={18} height={18} />
|
||||||
</ImageUpload>
|
</FileUpload>
|
||||||
<a
|
<a
|
||||||
className='d-flex align-items-center'
|
className='d-flex align-items-center'
|
||||||
href='https://guides.github.com/features/mastering-markdown/' target='_blank' rel='noreferrer'
|
href='https://guides.github.com/features/mastering-markdown/' target='_blank' rel='noreferrer'
|
||||||
|
|
|
@ -112,7 +112,7 @@ export const useMediaHelper = ({ src, srcSet: srcSetIntital, topLevel, tab }) =>
|
||||||
const { me } = useMe()
|
const { me } = useMe()
|
||||||
const trusted = useMemo(() => !!srcSetIntital || IMGPROXY_URL_REGEXP.test(src) || MEDIA_DOMAIN_REGEXP.test(src), [!!srcSetIntital, src])
|
const trusted = useMemo(() => !!srcSetIntital || IMGPROXY_URL_REGEXP.test(src) || MEDIA_DOMAIN_REGEXP.test(src), [!!srcSetIntital, src])
|
||||||
const { dimensions, video, format, ...srcSetObj } = srcSetIntital || {}
|
const { dimensions, video, format, ...srcSetObj } = srcSetIntital || {}
|
||||||
const [isImage, setIsImage] = useState(!video && trusted)
|
const [isImage, setIsImage] = useState(video === false && trusted)
|
||||||
const [isVideo, setIsVideo] = useState(video)
|
const [isVideo, setIsVideo] = useState(video)
|
||||||
const showMedia = useMemo(() => tab === 'preview' || me?.privates?.showImagesAndVideos !== false, [tab, me?.privates?.showImagesAndVideos])
|
const showMedia = useMemo(() => tab === 'preview' || me?.privates?.showImagesAndVideos !== false, [tab, me?.privates?.showImagesAndVideos])
|
||||||
const embed = useMemo(() => parseEmbedUrl(src), [src])
|
const embed = useMemo(() => parseEmbedUrl(src), [src])
|
||||||
|
@ -123,15 +123,19 @@ export const useMediaHelper = ({ src, srcSet: srcSetIntital, topLevel, tab }) =>
|
||||||
// make sure it's not a false negative by trying to load URL as <img>
|
// make sure it's not a false negative by trying to load URL as <img>
|
||||||
const img = new window.Image()
|
const img = new window.Image()
|
||||||
img.onload = () => setIsImage(true)
|
img.onload = () => setIsImage(true)
|
||||||
|
img.onerror = () => setIsImage(false)
|
||||||
img.src = src
|
img.src = src
|
||||||
const video = document.createElement('video')
|
const video = document.createElement('video')
|
||||||
video.onloadeddata = () => setIsVideo(true)
|
video.onloadeddata = () => setIsVideo(true)
|
||||||
|
video.onerror = () => setIsVideo(false)
|
||||||
video.src = src
|
video.src = src
|
||||||
|
|
||||||
return () => {
|
return () => {
|
||||||
img.onload = null
|
img.onload = null
|
||||||
|
img.onerror = null
|
||||||
img.src = ''
|
img.src = ''
|
||||||
video.onloadeddata = null
|
video.onloadeddata = null
|
||||||
|
video.onerror = null
|
||||||
video.src = ''
|
video.src = ''
|
||||||
}
|
}
|
||||||
}, [src, setIsImage, setIsVideo, showMedia, isVideo, embed])
|
}, [src, setIsImage, setIsVideo, showMedia, isVideo, embed])
|
||||||
|
|
|
@ -19,7 +19,10 @@ export const UPLOAD_TYPES_ALLOW = [
|
||||||
'image/heic',
|
'image/heic',
|
||||||
'image/png',
|
'image/png',
|
||||||
'image/jpeg',
|
'image/jpeg',
|
||||||
'image/webp'
|
'image/webp',
|
||||||
|
'video/mp4',
|
||||||
|
'video/mpeg',
|
||||||
|
'video/webm'
|
||||||
]
|
]
|
||||||
export const INVOICE_ACTION_NOTIFICATION_TYPES = ['ITEM_CREATE', 'ZAP', 'DOWN_ZAP', 'POLL_VOTE']
|
export const INVOICE_ACTION_NOTIFICATION_TYPES = ['ITEM_CREATE', 'ZAP', 'DOWN_ZAP', 'POLL_VOTE']
|
||||||
export const BOUNTY_MIN = 1000
|
export const BOUNTY_MIN = 1000
|
||||||
|
|
|
@ -87,7 +87,7 @@ export function middleware (request) {
|
||||||
"font-src 'self' a.stacker.news",
|
"font-src 'self' a.stacker.news",
|
||||||
// we want to load images from everywhere but we can limit to HTTPS at least
|
// we want to load images from everywhere but we can limit to HTTPS at least
|
||||||
"img-src 'self' a.stacker.news m.stacker.news https: data: blob:" + devSrc,
|
"img-src 'self' a.stacker.news m.stacker.news https: data: blob:" + devSrc,
|
||||||
"media-src 'self' a.stacker.news m.stacker.news https:" + devSrc,
|
"media-src 'self' a.stacker.news m.stacker.news https: blob:" + devSrc,
|
||||||
// Using nonces and strict-dynamic deploys a strict CSP.
|
// Using nonces and strict-dynamic deploys a strict CSP.
|
||||||
// see https://cheatsheetseries.owasp.org/cheatsheets/Content_Security_Policy_Cheat_Sheet.html#strict-policy.
|
// see https://cheatsheetseries.owasp.org/cheatsheets/Content_Security_Policy_Cheat_Sheet.html#strict-policy.
|
||||||
// Old browsers will ignore nonce and strict-dynamic and fallback to host-based matching and unsafe-inline
|
// Old browsers will ignore nonce and strict-dynamic and fallback to host-based matching and unsafe-inline
|
||||||
|
|
|
@ -0,0 +1,43 @@
|
||||||
|
-- rename image_fees_info to upload_fees
|
||||||
|
-- also give stackers more free quota (50MB per 24 hours -> 250MB per 24 hours)
|
||||||
|
DROP FUNCTION image_fees_info(user_id INTEGER, upload_ids INTEGER[]);
|
||||||
|
CREATE OR REPLACE FUNCTION upload_fees(user_id INTEGER, upload_ids INTEGER[])
|
||||||
|
RETURNS TABLE (
|
||||||
|
"bytes24h" INTEGER,
|
||||||
|
"bytesUnpaid" INTEGER,
|
||||||
|
"nUnpaid" INTEGER,
|
||||||
|
"uploadFeesMsats" BIGINT
|
||||||
|
)
|
||||||
|
LANGUAGE plpgsql
|
||||||
|
AS $$
|
||||||
|
BEGIN
|
||||||
|
RETURN QUERY SELECT
|
||||||
|
uploadinfo.*,
|
||||||
|
CASE
|
||||||
|
-- anons always pay 100 sats per upload no matter the size
|
||||||
|
WHEN user_id = 27 THEN 100000::BIGINT
|
||||||
|
ELSE CASE
|
||||||
|
-- 250MB are free per stacker and 24 hours
|
||||||
|
WHEN uploadinfo."bytes24h" + uploadinfo."bytesUnpaid" <= 250 * 1024 * 1024 THEN 0::BIGINT
|
||||||
|
-- 250MB-500MB: 10 sats per upload
|
||||||
|
WHEN uploadinfo."bytes24h" + uploadinfo."bytesUnpaid" <= 500 * 1024 * 1024 THEN 10000::BIGINT
|
||||||
|
-- 500MB-1GB: 100 sats per upload
|
||||||
|
WHEN uploadinfo."bytes24h" + uploadinfo."bytesUnpaid" <= 1000 * 1024 * 1024 THEN 100000::BIGINT
|
||||||
|
-- 1GB+: 1k sats per upload
|
||||||
|
ELSE 1000000::BIGINT
|
||||||
|
END
|
||||||
|
END AS "uploadFeesMsats"
|
||||||
|
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;
|
||||||
|
$$;
|
|
@ -0,0 +1 @@
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor"><path d="M15 4H5V20H19V8H15V4ZM3 2.9918C3 2.44405 3.44749 2 3.9985 2H16L20.9997 7L21 20.9925C21 21.5489 20.5551 22 20.0066 22H3.9934C3.44476 22 3 21.5447 3 21.0082V2.9918ZM13 12V16H11V12H8L12 8L16 12H13Z"></path></svg>
|
After Width: | Height: | Size: 298 B |
Loading…
Reference in New Issue