diff --git a/api/resolvers/item.js b/api/resolvers/item.js index 9e20f5e6..596dd790 100644 --- a/api/resolvers/item.js +++ b/api/resolvers/item.js @@ -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: { diff --git a/api/resolvers/upload.js b/api/resolvers/upload.js index 8d5835b4..d0a3d8d2 100644 --- a/api/resolvers/upload.js +++ b/api/resolvers/upload.js @@ -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 } diff --git a/api/resolvers/user.js b/api/resolvers/user.js index 06ce834c..282678b3 100644 --- a/api/resolvers/user.js +++ b/api/resolvers/user.js @@ -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 } } } diff --git a/api/typeDefs/item.js b/api/typeDefs/item.js index 77d22735..1b10eaf0 100644 --- a/api/typeDefs/item.js +++ b/api/typeDefs/item.js @@ -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 { diff --git a/api/typeDefs/user.js b/api/typeDefs/user.js index 1808bb75..39e3ecbb 100644 --- a/api/typeDefs/user.js +++ b/api/typeDefs/user.js @@ -113,6 +113,5 @@ export default gql` meSubscriptionPosts: Boolean! meSubscriptionComments: Boolean! meMute: Boolean - images(submitted: Boolean): [Image] } ` diff --git a/components/bounty-form.js b/components/bounty-form.js index 740b23c0..f1b14bf4 100644 --- a/components/bounty-form.js +++ b/components/bounty-form.js @@ -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( diff --git a/components/comment-edit.js b/components/comment-edit.js index 7dcd8f49..0f6e1077 100644 --- a/components/comment-edit.js +++ b/components/comment-edit.js @@ -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}`, diff --git a/components/discussion-form.js b/components/discussion-form.js index 9443796e..bbca92b2 100644 --- a/components/discussion-form.js +++ b/components/discussion-form.js @@ -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( diff --git a/components/form.js b/components/form.js index ea1116fe..43b5bf90 100644 --- a/components/form.js +++ b/components/form.js @@ -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 ( - - - {name || url} - - - - ) -} - 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 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) + }} > @@ -270,27 +260,6 @@ export function MarkdownInput ({ label, topLevel, groupClassName, onChange, setH onBlur={() => setTimeout(resetSuggestions, 100)} />)} - {unsubmittedImages.map((img, i) => { - return ( - { - try { - await deleteImage({ variables: { id: img.id } }) - toaster.success('image deleted') - } catch (err) { - console.error(err) - toaster.danger('failed to delete image') - } - }} - /> - ) - })} {tab !== 'write' &&
diff --git a/components/image.js b/components/image.js index 337c8730..08fdc0fb 100644 --- a/components/image.js +++ b/components/image.js @@ -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 ( - - {children} - - ) -} - -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, diff --git a/components/job-form.js b/components/job-form.js index 6f9b02de..2856ceac 100644 --- a/components/job-form.js +++ b/components/job-form.js @@ -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( diff --git a/components/link-form.js b/components/link-form.js index cc73185f..f7cc7bf4 100644 --- a/components/link-form.js +++ b/components/link-form.js @@ -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( diff --git a/components/poll-form.js b/components/poll-form.js index 257b6986..fcf62773 100644 --- a/components/poll-form.js +++ b/components/poll-form.js @@ -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( diff --git a/components/reply.js b/components/reply.js index 12a6903c..e863a199 100644 --- a/components/reply.js +++ b/components/reply.js @@ -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}`, diff --git a/pages/_app.js b/pages/_app.js index a29ef1f3..e41b9c18 100644 --- a/pages/_app.js +++ b/pages/_app.js @@ -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,23 +89,21 @@ export default function MyApp ({ Component, pageProps: { ...props } }) { - - - - - - - - - - - - - - - - - + + + + + + + + + + + + + + +