From 9eff69be75041f78a809839438b5c7eb831396c9 Mon Sep 17 00:00:00 2001 From: Austin Chen Date: Wed, 12 Oct 2022 17:25:17 -0700 Subject: [PATCH] Add /createcomment API endpoint (#946) * /dream api: Upload StableDiffusion image to Firestore * Minor tweaks * Set content type on uploaded image This makes it so the image doesn't auto-download when opened in a new tab * Allow users to dream directly from within Manifold * Remove unused import * Implement a /comment endpoint which supports html and markdown * Upgrade @tiptap/core to latest * Update all tiptap deps to beta.199 * Add @tiptap/suggestion * Import @tiptap/html in the right place * ... add deps everywhere So I have no idea how common deps work apparently * Add tiptap/suggestion too * Clean up dream * More cleanups * Rework /comment endpoint * Move API to /comment * Change imports in case that matters * Add a couple todos * Dynamically import micromark * Parallellize gsutil with -m option * Adding comments via api working, editor.tsx erroring out * Unused import * Remove disabled state from useTextEditor Co-authored-by: Ian Philips --- common/package.json | 12 +- common/util/parse.ts | 5 + functions/package.json | 18 +- functions/src/create-comment.ts | 105 +++++ functions/src/index.ts | 3 + functions/src/serve.ts | 2 + .../contract/contract-description.tsx | 4 - web/components/create-post.tsx | 1 - web/components/editor.tsx | 7 +- web/components/groups/group-overview-post.tsx | 4 - web/package.json | 18 +- web/pages/api/v0/comment.ts | 23 + web/pages/create.tsx | 1 - web/pages/date-docs/create.tsx | 4 +- web/pages/post/[...slugs]/index.tsx | 4 - yarn.lock | 427 ++++++++++-------- 16 files changed, 402 insertions(+), 236 deletions(-) create mode 100644 functions/src/create-comment.ts create mode 100644 web/pages/api/v0/comment.ts diff --git a/common/package.json b/common/package.json index 52195398..11f92e89 100644 --- a/common/package.json +++ b/common/package.json @@ -8,11 +8,13 @@ }, "sideEffects": false, "dependencies": { - "@tiptap/core": "2.0.0-beta.182", - "@tiptap/extension-image": "2.0.0-beta.30", - "@tiptap/extension-link": "2.0.0-beta.43", - "@tiptap/extension-mention": "2.0.0-beta.102", - "@tiptap/starter-kit": "2.0.0-beta.191", + "@tiptap/core": "2.0.0-beta.199", + "@tiptap/extension-image": "2.0.0-beta.199", + "@tiptap/extension-link": "2.0.0-beta.199", + "@tiptap/extension-mention": "2.0.0-beta.199", + "@tiptap/html": "2.0.0-beta.199", + "@tiptap/starter-kit": "2.0.0-beta.199", + "@tiptap/suggestion": "2.0.0-beta.199", "lodash": "4.17.21" }, "devDependencies": { diff --git a/common/util/parse.ts b/common/util/parse.ts index 04faffe4..6500d6ef 100644 --- a/common/util/parse.ts +++ b/common/util/parse.ts @@ -1,4 +1,5 @@ import { generateText, JSONContent } from '@tiptap/core' +import { generateJSON } from '@tiptap/html' // Tiptap starter extensions import { Blockquote } from '@tiptap/extension-blockquote' import { Bold } from '@tiptap/extension-bold' @@ -86,3 +87,7 @@ export function richTextToString(text?: JSONContent) { if (!text) return '' return generateText(text, stringParseExts) } + +export function htmlToRichText(html: string) { + return generateJSON(html, stringParseExts) +} diff --git a/functions/package.json b/functions/package.json index cd2a9ec5..399b307a 100644 --- a/functions/package.json +++ b/functions/package.json @@ -15,9 +15,9 @@ "dev": "nodemon src/serve.ts", "localDbScript": "firebase emulators:start --only functions,firestore,pubsub --import=./firestore_export", "serve": "firebase use dev && yarn build && firebase emulators:start --only functions,firestore,pubsub --import=./firestore_export", - "db:update-local-from-remote": "yarn db:backup-remote && gsutil rsync -r gs://$npm_package_config_firestore/firestore_export ./firestore_export", + "db:update-local-from-remote": "yarn db:backup-remote && gsutil -m rsync -r gs://$npm_package_config_firestore/firestore_export ./firestore_export", "db:backup-local": "firebase emulators:export --force ./firestore_export", - "db:rename-remote-backup-folder": "gsutil mv gs://$npm_package_config_firestore/firestore_export gs://$npm_package_config_firestore/firestore_export_$(date +%d-%m-%Y-%H-%M)", + "db:rename-remote-backup-folder": "gsutil -m mv gs://$npm_package_config_firestore/firestore_export gs://$npm_package_config_firestore/firestore_export_$(date +%d-%m-%Y-%H-%M)", "db:backup-remote": "yarn db:rename-remote-backup-folder && gcloud firestore export gs://$npm_package_config_firestore/firestore_export/", "verify": "(cd .. && yarn verify)", "verify:dir": "npx eslint . --max-warnings 0; tsc -b -v --pretty" @@ -26,11 +26,13 @@ "dependencies": { "@amplitude/node": "1.10.0", "@google-cloud/functions-framework": "3.1.2", - "@tiptap/core": "2.0.0-beta.182", - "@tiptap/extension-image": "2.0.0-beta.30", - "@tiptap/extension-link": "2.0.0-beta.43", - "@tiptap/extension-mention": "2.0.0-beta.102", - "@tiptap/starter-kit": "2.0.0-beta.191", + "@tiptap/core": "2.0.0-beta.199", + "@tiptap/extension-image": "2.0.0-beta.199", + "@tiptap/extension-link": "2.0.0-beta.199", + "@tiptap/extension-mention": "2.0.0-beta.199", + "@tiptap/html": "2.0.0-beta.199", + "@tiptap/starter-kit": "2.0.0-beta.199", + "@tiptap/suggestion": "2.0.0-beta.199", "cors": "2.8.5", "dayjs": "1.11.4", "express": "4.18.1", @@ -38,6 +40,7 @@ "firebase-functions": "3.21.2", "lodash": "4.17.21", "mailgun-js": "0.22.0", + "marked": "4.1.1", "module-alias": "2.2.2", "node-fetch": "2", "stripe": "8.194.0", @@ -45,6 +48,7 @@ }, "devDependencies": { "@types/mailgun-js": "0.22.12", + "@types/marked": "4.0.7", "@types/module-alias": "2.0.1", "@types/node-fetch": "2.6.2", "firebase-functions-test": "0.3.3", diff --git a/functions/src/create-comment.ts b/functions/src/create-comment.ts new file mode 100644 index 00000000..e0191276 --- /dev/null +++ b/functions/src/create-comment.ts @@ -0,0 +1,105 @@ +import * as admin from 'firebase-admin' + +import { getContract, getUser, log } from './utils' +import { APIError, newEndpoint, validate } from './api' +import { JSONContent } from '@tiptap/core' +import { z } from 'zod' +import { removeUndefinedProps } from '../../common/util/object' +import { htmlToRichText } from '../../common/util/parse' +import { marked } from 'marked' + +const contentSchema: z.ZodType = z.lazy(() => + z.intersection( + z.record(z.any()), + z.object({ + type: z.string().optional(), + attrs: z.record(z.any()).optional(), + content: z.array(contentSchema).optional(), + marks: z + .array( + z.intersection( + z.record(z.any()), + z.object({ + type: z.string(), + attrs: z.record(z.any()).optional(), + }) + ) + ) + .optional(), + text: z.string().optional(), + }) + ) +) + +const postSchema = z.object({ + contractId: z.string(), + content: contentSchema.optional(), + html: z.string().optional(), + markdown: z.string().optional(), +}) + +const MAX_COMMENT_JSON_LENGTH = 20000 + +// For now, only supports creating a new top-level comment on a contract. +// Replies, posts, chats are not supported yet. +export const createcomment = newEndpoint({}, async (req, auth) => { + const firestore = admin.firestore() + const { contractId, content, html, markdown } = validate(postSchema, req.body) + + const creator = await getUser(auth.uid) + const contract = await getContract(contractId) + + if (!creator) { + throw new APIError(400, 'No user exists with the authenticated user ID.') + } + if (!contract) { + throw new APIError(400, 'No contract exists with the given ID.') + } + + let contentJson = null + if (content) { + contentJson = content + } else if (html) { + console.log('html', html) + contentJson = htmlToRichText(html) + } else if (markdown) { + const markedParse = marked.parse(markdown) + log('parsed', markedParse) + contentJson = htmlToRichText(markedParse) + log('json', contentJson) + } + + if (!contentJson) { + throw new APIError(400, 'No comment content provided.') + } + + if (JSON.stringify(contentJson).length > MAX_COMMENT_JSON_LENGTH) { + throw new APIError( + 400, + `Comment is too long; should be less than ${MAX_COMMENT_JSON_LENGTH} as a JSON string.` + ) + } + + const ref = firestore.collection(`contracts/${contractId}/comments`).doc() + + const comment = removeUndefinedProps({ + id: ref.id, + content: contentJson, + createdTime: Date.now(), + + userId: creator.id, + userName: creator.name, + userUsername: creator.username, + userAvatarUrl: creator.avatarUrl, + + // OnContract fields + commentType: 'contract', + contractId: contractId, + contractSlug: contract.slug, + contractQuestion: contract.question, + }) + + await ref.set(comment) + + return { status: 'success', comment } +}) diff --git a/functions/src/index.ts b/functions/src/index.ts index b64155a3..c4e9e5f7 100644 --- a/functions/src/index.ts +++ b/functions/src/index.ts @@ -65,6 +65,7 @@ import { sellbet } from './sell-bet' import { sellshares } from './sell-shares' import { claimmanalink } from './claim-manalink' import { createmarket } from './create-market' +import { createcomment } from './create-comment' import { addcommentbounty, awardcommentbounty } from './update-comment-bounty' import { creategroup } from './create-group' import { resolvemarket } from './resolve-market' @@ -94,6 +95,7 @@ const claimManalinkFunction = toCloudFunction(claimmanalink) const createMarketFunction = toCloudFunction(createmarket) const addSubsidyFunction = toCloudFunction(addsubsidy) const addCommentBounty = toCloudFunction(addcommentbounty) +const createCommentFunction = toCloudFunction(createcomment) const awardCommentBounty = toCloudFunction(awardcommentbounty) const createGroupFunction = toCloudFunction(creategroup) const resolveMarketFunction = toCloudFunction(resolvemarket) @@ -130,6 +132,7 @@ export { acceptChallenge as acceptchallenge, createPostFunction as createpost, saveTwitchCredentials as savetwitchcredentials, + createCommentFunction as createcomment, addCommentBounty as addcommentbounty, awardCommentBounty as awardcommentbounty, updateMetricsFunction as updatemetrics, diff --git a/functions/src/serve.ts b/functions/src/serve.ts index bc09029d..597e144c 100644 --- a/functions/src/serve.ts +++ b/functions/src/serve.ts @@ -19,6 +19,7 @@ import { sellbet } from './sell-bet' import { sellshares } from './sell-shares' import { claimmanalink } from './claim-manalink' import { createmarket } from './create-market' +import { createcomment } from './create-comment' import { creategroup } from './create-group' import { resolvemarket } from './resolve-market' import { unsubscribe } from './unsubscribe' @@ -53,6 +54,7 @@ addJsonEndpointRoute('/transact', transact) addJsonEndpointRoute('/changeuserinfo', changeuserinfo) addJsonEndpointRoute('/createuser', createuser) addJsonEndpointRoute('/createanswer', createanswer) +addJsonEndpointRoute('/createcomment', createcomment) addJsonEndpointRoute('/placebet', placebet) addJsonEndpointRoute('/cancelbet', cancelbet) addJsonEndpointRoute('/sellbet', sellbet) diff --git a/web/components/contract/contract-description.tsx b/web/components/contract/contract-description.tsx index 855bc750..ecb6358f 100644 --- a/web/components/contract/contract-description.tsx +++ b/web/components/contract/contract-description.tsx @@ -45,13 +45,11 @@ function RichEditContract(props: { contract: Contract; isAdmin?: boolean }) { const { contract, isAdmin } = props const [editing, setEditing] = useState(false) const [editingQ, setEditingQ] = useState(false) - const [isSubmitting, setIsSubmitting] = useState(false) const { editor, upload } = useTextEditor({ // key: `description ${contract.id}`, max: MAX_DESCRIPTION_LENGTH, defaultValue: contract.description, - disabled: isSubmitting, }) async function saveDescription() { @@ -66,10 +64,8 @@ function RichEditContract(props: { contract: Contract; isAdmin?: boolean }) {