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
This commit is contained in:
ekzyis 2023-10-23 21:40:59 +02:00
parent 167a4189ca
commit 30aed4e805
15 changed files with 37 additions and 251 deletions

View File

@ -19,7 +19,6 @@ 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 { deleteObject } from '../s3'
export async function commentFilterClause (me, models) {
let clause = ` AND ("Item"."weightedVotes" - "Item"."weightedDownVotes" > -${ITEM_FILTER_THRESHOLD}`
@ -804,23 +803,6 @@ export default {
)
return true
},
deleteImage: async (parent, { id }, { me, models }) => {
if (!me) {
throw new GraphQLError('you must be logged in', { extensions: { code: 'FORBIDDEN' } })
}
const img = await models.upload.findUnique({ where: { id: Number(id) } })
if (img.userId !== me.id) {
throw new GraphQLError('not your image', { extensions: { code: 'FORBIDDEN' } })
}
if (img.itemId) {
throw new GraphQLError('image already included in an item', { extensions: { code: 'BAD_INPUT' } })
}
await models.upload.delete({ where: { id: Number(id) } })
await deleteObject(id)
return id
}
},
Item: {

View File

@ -1,35 +1,6 @@
import { GraphQLError } from 'graphql'
import { IMAGE_PIXELS_MAX, UPLOAD_SIZE_MAX, UPLOAD_TYPES_ALLOW } from '../../lib/constants'
import { createPresignedPost } from '../s3'
import { datePivot } from '../../lib/time'
import serialize from './serial'
// factor for bytes to megabyte
const MB = 1024 * 1024
// factor for msats to sats
const SATS = 1000
async function uploadCosts (models, userId, photoId, size) {
let { _sum: { size: sumSize } } = await models.upload.aggregate({
_sum: { size: true },
where: { userId, createdAt: { gt: datePivot(new Date(), { days: -1 }) }, id: photoId ? { not: photoId } : undefined }
})
// assume the image was already uploaded in the calculation
sumSize += size
if (sumSize <= 5 * MB) {
return 0 * SATS
}
if (sumSize <= 10 * MB) {
return 10 * SATS
}
if (sumSize <= 25 * MB) {
return 100 * SATS
}
if (sumSize <= 100 * MB) {
return 1000 * SATS
}
return -1
}
export default {
Mutation: {
@ -52,12 +23,6 @@ export default {
const { photoId } = await models.user.findUnique({ where: { id: me.id } })
const costs = avatar ? 0 : await uploadCosts(models, me.id, photoId, size)
if (costs < 0) {
throw new GraphQLError('image quota of 100 MB exceeded', { extensions: { code: 'BAD_INPUT' } })
}
const feeTx = models.user.update({ data: { msats: { decrement: costs } }, where: { id: me.id } })
const data = {
type,
size,
@ -70,10 +35,10 @@ export default {
if (avatar && photoId) uploadId = photoId
if (uploadId) {
// update upload record
await serialize(models, models.upload.update({ data, where: { id: uploadId } }), feeTx)
await models.upload.update({ data, where: { id: uploadId } })
} else {
// create upload record
const [upload] = await serialize(models, models.upload.create({ data }), feeTx)
const upload = await models.upload.create({ data })
uploadId = upload.id
}

View File

@ -896,16 +896,6 @@ export default {
return contributors.has(user.name)
}
return !user.hideIsContributor && contributors.has(user.name)
},
images: async (user, { submitted }, { me, models }) => {
if (!me) return null
const uploads = await models.upload.findMany({
where: {
userId: me.id,
itemId: submitted !== undefined ? submitted ? { not: null } : null : undefined
}
})
return uploads
}
}
}

View File

@ -36,7 +36,6 @@ export default gql`
dontLikeThis(id: ID!, sats: Int, hash: String, hmac: String): Boolean!
act(id: ID!, sats: Int, hash: String, hmac: String): ItemActResult!
pollVote(id: ID!, hash: String, hmac: String): ID!
deleteImage(id: ID!): ID!
}
type PollOption {

View File

@ -113,6 +113,5 @@ export default gql`
meSubscriptionPosts: Boolean!
meSubscriptionComments: Boolean!
meMute: Boolean
images(submitted: Boolean): [Image]
}
`

View File

@ -12,7 +12,6 @@ import { useCallback } from 'react'
import { normalizeForwards } from '../lib/form'
import { MAX_TITLE_LENGTH } from '../lib/constants'
import { useMe } from './me'
import { useImages } from './image'
export function BountyForm ({
item,
@ -28,7 +27,6 @@ export function BountyForm ({
const router = useRouter()
const client = useApolloClient()
const me = useMe()
const { markImagesAsSubmitted } = useImages()
const schema = bountySchema({ client, me, existingBoost: item?.boost })
const [upsertBounty] = useMutation(
gql`
@ -57,11 +55,7 @@ export function BountyForm ({
id
}
}
`, {
onCompleted ({ upsertBounty: { text } }) {
markImagesAsSubmitted(text)
}
}
`
)
const onSubmit = useCallback(

View File

@ -5,10 +5,8 @@ import { EditFeeButton } from './fee-button'
import Button from 'react-bootstrap/Button'
import Delete from './delete'
import { commentSchema } from '../lib/validate'
import { useImages } from './image'
export default function CommentEdit ({ comment, editThreshold, onSuccess, onCancel }) {
const { markImagesAsSubmitted } = useImages()
const [upsertComment] = useMutation(
gql`
mutation upsertComment($id: ID! $text: String!) {
@ -16,9 +14,6 @@ export default function CommentEdit ({ comment, editThreshold, onSuccess, onCanc
text
}
}`, {
onCompleted ({ upsertComment: { text } }) {
markImagesAsSubmitted(text)
},
update (cache, { data: { upsertComment } }) {
cache.modify({
id: `Item:${comment.id}`,

View File

@ -17,7 +17,6 @@ import { normalizeForwards } from '../lib/form'
import { MAX_TITLE_LENGTH } from '../lib/constants'
import { useMe } from './me'
import useCrossposter from './use-crossposter'
import { useImages } from './image'
export function DiscussionForm ({
item, sub, editThreshold, titleLabel = 'title',
@ -31,7 +30,6 @@ export function DiscussionForm ({
// if Web Share Target API was used
const shareTitle = router.query.title
const crossposter = useCrossposter()
const { markImagesAsSubmitted } = useImages()
const [upsertDiscussion] = useMutation(
gql`
@ -40,12 +38,7 @@ export function DiscussionForm ({
id
text
}
}`,
{
onCompleted ({ upsertDiscussion: { text } }) {
markImagesAsSubmitted(text)
}
}
}`
)
const onSubmit = useCallback(

View File

@ -25,8 +25,7 @@ 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, useImages } from './image'
import LinkIcon from '../svgs/link.svg'
import { ImageUpload } from './image'
export function SubmitButton ({
children, variant, value, onClick, disabled, cost, ...props
@ -95,23 +94,6 @@ export function InputSkeleton ({ label, hint }) {
)
}
function ImageSelector ({ file, name, url, text, setText, onDelete }) {
const onLink = () => {
let newText = text ? text + '\n\n' : ''
newText += url
setText(newText)
}
return (
<span className='d-flex align-items-center text-muted mt-2'>
<img className='me-1' src={file ? window.URL.createObjectURL(file) : url} width={32} height={32} style={{ objectFit: 'contain' }} />
<a href={url} target='_blank' rel='noreferrer'>{name || url}</a>
<LinkIcon className='ms-auto me-1' width={18} height={18} onClick={onLink} style={{ cursor: 'pointer' }} />
<CloseIcon width={18} height={18} onClick={onDelete} style={{ cursor: 'pointer' }} />
</span>
)
}
const DEFAULT_MENTION_INDICES = { start: -1, end: -1 }
export function MarkdownInput ({ label, topLevel, groupClassName, onChange, setHasImgLink, onKeyDown, innerRef, ...props }) {
const [tab, setTab] = useState('write')
@ -119,8 +101,6 @@ export function MarkdownInput ({ label, topLevel, groupClassName, onChange, setH
const [selectionRange, setSelectionRange] = useState({ start: 0, end: 0 })
innerRef = innerRef || useRef(null)
const previousTab = useRef(tab)
const toaster = useToast()
const { unsubmittedImages, setUnsubmittedImages, deleteImage } = useImages()
props.as ||= TextareaAutosize
props.rows ||= props.minRows || 6
@ -244,7 +224,17 @@ export function MarkdownInput ({ label, topLevel, groupClassName, onChange, setH
<span className='ms-auto text-muted d-flex align-items-center'>
<ImageUpload
className='d-flex align-items-center me-1'
onSuccess={img => setUnsubmittedImages(prev => [...prev, img])}
onSelect={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)
}}
>
<AddImageIcon width={18} height={18} />
</ImageUpload>
@ -270,27 +260,6 @@ export function MarkdownInput ({ label, topLevel, groupClassName, onChange, setH
onBlur={() => setTimeout(resetSuggestions, 100)}
/>)}
</UserSuggest>
{unsubmittedImages.map((img, i) => {
return (
<ImageSelector
name={img.name}
file={img.file}
url={img.url}
key={i}
text={meta.value}
setText={helpers.setValue}
onDelete={async () => {
try {
await deleteImage({ variables: { id: img.id } })
toaster.success('image deleted')
} catch (err) {
console.error(err)
toaster.danger('failed to delete image')
}
}}
/>
)
})}
</div>
{tab !== 'write' &&
<div className='form-group'>

View File

@ -1,5 +1,5 @@
import styles from './text.module.css'
import { Fragment, useState, useEffect, useMemo, useCallback, useRef, createContext, useContext } from 'react'
import { Fragment, useState, useEffect, useMemo, useCallback, useRef } from 'react'
import { IMGPROXY_URL_REGEXP } from '../lib/url'
import { useShowModal } from './modal'
import { useMe } from './me'
@ -7,81 +7,7 @@ import { Dropdown } from 'react-bootstrap'
import { UPLOAD_TYPES_ALLOW } from '../lib/constants'
import { useToast } from './toast'
import gql from 'graphql-tag'
import { useMutation, useQuery } from '@apollo/client'
import { extractUrls } from '../lib/md'
const ImageContext = createContext({ unsubmitted: [] })
const imageIdToUrl = id => `https://${process.env.NEXT_PUBLIC_AWS_UPLOAD_BUCKET}.s3.amazonaws.com/${id}`
export function ImageProvider ({ me, children }) {
const { data, loading } = useQuery(
gql`
query images($submitted: Boolean) {
me {
images(submitted: $submitted) {
id
createdAt
updatedAt
type
size
width
height
itemId
}
}
}`, {
variables: { submitted: false }
}
)
const [deleteImage] = useMutation(gql`
mutation deleteImage($id: ID!) {
deleteImage(id: $id)
}`, {
onCompleted: (_, options) => {
const id = options.variables.id
setUnsubmittedImages(prev => prev.filter(img => img.id !== id))
}
})
const [unsubmittedImages, setUnsubmittedImages] = useState([])
const markImagesAsSubmitted = useCallback((text) => {
// mark images from S3 included in the text as submitted on the client
const urls = extractUrls(text)
const s3UrlPrefix = `https://${process.env.NEXT_PUBLIC_AWS_UPLOAD_BUCKET}.s3.amazonaws.com/`
urls
.filter(url => url.startsWith(s3UrlPrefix))
.forEach(url => {
const s3Key = url.split('/').pop()
setUnsubmittedImages(prev => prev.filter(img => img.id !== s3Key))
})
}, [setUnsubmittedImages])
useEffect(() => {
const images = data?.me?.images
if (images) {
setUnsubmittedImages(images.map(img => ({ ...img, url: imageIdToUrl(img.id) })))
}
}, [setUnsubmittedImages, loading])
const contextValue = {
unsubmittedImages,
setUnsubmittedImages,
deleteImage,
markImagesAsSubmitted
}
return (
<ImageContext.Provider value={contextValue}>
{children}
</ImageContext.Provider>
)
}
export function useImages () {
const images = useContext(ImageContext)
return images
}
import { useMutation } from '@apollo/client'
export function decodeOriginalUrl (imgproxyUrl) {
const parts = imgproxyUrl.split('/')
@ -228,6 +154,7 @@ export function ImageUpload ({ children, className, onSelect, onSuccess }) {
const img = new window.Image()
img.src = window.URL.createObjectURL(file)
img.onload = async () => {
onSelect?.(file)
let data
const variables = {
type: file.type,

View File

@ -18,7 +18,6 @@ import ActionTooltip from './action-tooltip'
import { jobSchema } from '../lib/validate'
import CancelButton from './cancel-button'
import { MAX_TITLE_LENGTH } from '../lib/constants'
import { useImages } from './image'
function satsMin2Mo (minute) {
return minute * 30 * 24 * 60
@ -41,7 +40,6 @@ export default function JobForm ({ item, sub }) {
const storageKeyPrefix = item ? undefined : `${sub.name}-job`
const router = useRouter()
const [logoId, setLogoId] = useState(item?.uploadId)
const { markImagesAsSubmitted } = useImages()
const [upsertJob] = useMutation(gql`
mutation upsertJob($sub: String!, $id: ID, $title: String!, $company: String!, $location: String,
$remote: Boolean, $text: String!, $url: String!, $maxBid: Int!, $status: String, $logo: Int, $hash: String, $hmac: String) {
@ -51,11 +49,7 @@ export default function JobForm ({ item, sub }) {
id
text
}
}`, {
onCompleted ({ upsertJob: { text } }) {
markImagesAsSubmitted(text)
}
}
}`
)
const onSubmit = useCallback(

View File

@ -17,7 +17,6 @@ import CancelButton from './cancel-button'
import { normalizeForwards } from '../lib/form'
import { MAX_TITLE_LENGTH } from '../lib/constants'
import { useMe } from './me'
import { useImages } from './image'
export function LinkForm ({ item, sub, editThreshold, children }) {
const router = useRouter()
@ -53,7 +52,6 @@ export function LinkForm ({ item, sub, editThreshold, children }) {
}
}
}`)
const { markImagesAsSubmitted } = useImages
const related = []
for (const item of relatedData?.related?.items || []) {
@ -77,12 +75,7 @@ export function LinkForm ({ item, sub, editThreshold, children }) {
id
text
}
}`,
{
onCompleted ({ upsertLink: { text } }) {
markImagesAsSubmitted(text)
}
}
}`
)
const onSubmit = useCallback(

View File

@ -13,14 +13,12 @@ import CancelButton from './cancel-button'
import { useCallback } from 'react'
import { normalizeForwards } from '../lib/form'
import { useMe } from './me'
import { useImages } from './image'
export function PollForm ({ item, sub, editThreshold, children }) {
const router = useRouter()
const client = useApolloClient()
const me = useMe()
const schema = pollSchema({ client, me, existingBoost: item?.boost })
const { markImagesAsSubmitted } = useImages()
const [upsertPoll] = useMutation(
gql`
@ -31,11 +29,7 @@ export function PollForm ({ item, sub, editThreshold, children }) {
id
text
}
}`, {
onCompleted ({ upsertPoll: { text } }) {
markImagesAsSubmitted(text)
}
}
}`
)
const onSubmit = useCallback(

View File

@ -11,7 +11,6 @@ import { commentSchema } from '../lib/validate'
import Info from './info'
import { quote } from '../lib/md'
import { COMMENT_DEPTH_LIMIT } from '../lib/constants'
import { useImages } from './image'
export function ReplyOnAnotherPage ({ item }) {
const path = item.path.split('.')
@ -50,7 +49,6 @@ export default forwardRef(function Reply ({ item, onSuccess, replyOpen, children
const parentId = item.id
const replyInput = useRef(null)
const formInnerRef = useRef()
const { markImagesAsSubmitted } = useImages()
// Start block to handle iOS Safari's weird selection clearing behavior
const savedRange = useRef()
@ -118,9 +116,6 @@ export default forwardRef(function Reply ({ item, onSuccess, replyOpen, children
}
}
}`, {
onCompleted ({ upsertComment: { text } }) {
markImagesAsSubmitted(text)
},
update (cache, { data: { upsertComment } }) {
cache.modify({
id: `Item:${parentId}`,

View File

@ -17,7 +17,6 @@ import { SSR } from '../lib/constants'
import NProgress from 'nprogress'
import 'nprogress/nprogress.css'
import { LoggerProvider } from '../components/logger'
import { ImageProvider } from '../components/image'
NProgress.configure({
showSpinner: false
@ -90,7 +89,6 @@ export default function MyApp ({ Component, pageProps: { ...props } }) {
<PlausibleProvider domain='stacker.news' trackOutboundLinks>
<ApolloProvider client={client}>
<MeProvider me={me}>
<ImageProvider>
<LoggerProvider>
<ServiceWorkerProvider>
<PriceProvider price={price}>
@ -106,7 +104,6 @@ export default function MyApp ({ Component, pageProps: { ...props } }) {
</PriceProvider>
</ServiceWorkerProvider>
</LoggerProvider>
</ImageProvider>
</MeProvider>
</ApolloProvider>
</PlausibleProvider>