diff --git a/common/util/parse.ts b/common/util/parse.ts index f07e4097..4fac3225 100644 --- a/common/util/parse.ts +++ b/common/util/parse.ts @@ -22,6 +22,7 @@ import { Image } from '@tiptap/extension-image' import { Link } from '@tiptap/extension-link' import { Mention } from '@tiptap/extension-mention' import Iframe from './tiptap-iframe' +import TiptapTweet from './tiptap-tweet-type' import { uniq } from 'lodash' export function parseTags(text: string) { @@ -94,6 +95,7 @@ export const exhibitExts = [ Link, Mention, Iframe, + TiptapTweet, ] export function richTextToString(text?: JSONContent) { diff --git a/common/util/tiptap-tweet-type.ts b/common/util/tiptap-tweet-type.ts new file mode 100644 index 00000000..0b9acffc --- /dev/null +++ b/common/util/tiptap-tweet-type.ts @@ -0,0 +1,37 @@ +import { Node, mergeAttributes } from '@tiptap/core' + +export interface TweetOptions { + tweetId: string +} + +// This is a version of the Tiptap Node config without addNodeView, +// since that would require bundling in tsx +export const TiptapTweetNode = { + name: 'tiptapTweet', + group: 'block', + atom: true, + + addAttributes() { + return { + tweetId: { + default: null, + }, + } + }, + + parseHTML() { + return [ + { + tag: 'tiptap-tweet', + }, + ] + }, + + renderHTML(props: { HTMLAttributes: Record }) { + return ['tiptap-tweet', mergeAttributes(props.HTMLAttributes)] + }, +} + +// eslint-disable-next-line @typescript-eslint/ban-ts-comment +// @ts-ignore +export default Node.create(TiptapTweetNode) diff --git a/web/components/editor.tsx b/web/components/editor.tsx index d8a8d37f..74f608aa 100644 --- a/web/components/editor.tsx +++ b/web/components/editor.tsx @@ -21,16 +21,13 @@ import { useUsers } from 'web/hooks/use-users' import { mentionSuggestion } from './editor/mention-suggestion' import { DisplayMention } from './editor/mention' import Iframe from 'common/util/tiptap-iframe' +import TiptapTweet from './editor/tiptap-tweet' +import { EmbedModal } from './editor/embed-modal' import { CodeIcon, PhotographIcon, PresentationChartLineIcon, } from '@heroicons/react/solid' -import { Modal } from './layout/modal' -import { Col } from './layout/col' -import { Button } from './button' -import { Row } from './layout/row' -import { Spacer } from './layout/spacer' import { MarketModal } from './editor/market-modal' import { insertContent } from './editor/utils' @@ -88,6 +85,7 @@ export function useTextEditor(props: { suggestion: mentionSuggestion(users), }), Iframe, + TiptapTweet, ], content: defaultValue, }, @@ -131,13 +129,6 @@ function isValidIframe(text: string) { return /^$/.test(text) } -function isValidUrl(text: string) { - // Conjured by Codex, not sure if it's actually good - return /^(https?:\/\/)?([\da-z\.-]+)\.([a-z\.]{2,6})([\/\w \.-]*)*\/?$/.test( - text - ) -} - export function TextEditor(props: { editor: Editor | null upload: ReturnType @@ -169,7 +160,7 @@ export function TextEditor(props: { onClick={() => setIframeOpen(true)} className="-m-2.5 flex h-10 w-10 items-center justify-center rounded-full text-gray-400 hover:text-gray-500" > - void -}) { - const { editor, open, setOpen } = props - const [input, setInput] = useState('') - const valid = isValidIframe(input) || isValidUrl(input) - const embedCode = isValidIframe(input) ? input : `' - value={input} - onChange={(e) => setInput(e.target.value)} - /> - - {/* Preview the embed if it's valid */} - {valid ? : } - - - - - - - - ) -} - const useUploadMutation = (editor: Editor | null) => useMutation( (files: File[]) => @@ -292,7 +223,7 @@ const useUploadMutation = (editor: Editor | null) => } ) -function RichContent(props: { +export function RichContent(props: { content: JSONContent | string smallImage?: boolean }) { @@ -305,6 +236,7 @@ function RichContent(props: { DisplayLink, DisplayMention, Iframe, + TiptapTweet, ], content, editable: false, diff --git a/web/components/editor/embed-modal.tsx b/web/components/editor/embed-modal.tsx new file mode 100644 index 00000000..55b72d19 --- /dev/null +++ b/web/components/editor/embed-modal.tsx @@ -0,0 +1,116 @@ +import { Editor } from '@tiptap/react' +import { useState } from 'react' +import { Button } from '../button' +import { RichContent } from '../editor' +import { Col } from '../layout/col' +import { Modal } from '../layout/modal' +import { Row } from '../layout/row' +import { Spacer } from '../layout/spacer' + +type EmbedPattern = { + // Regex should have a single capture group. + regex: RegExp + rewrite: (text: string) => string +} + +const embedPatterns: EmbedPattern[] = [ + { + regex: /^()$/, + rewrite: (text: string) => text, + }, + { + regex: /^https?:\/\/manifold\.markets\/([^\/]+\/[^\/]+)/, + rewrite: (slug) => + ``, + }, + { + regex: /^https?:\/\/twitter\.com\/.*\/status\/(\d+)/, + // Hack: append a leading 't', to prevent tweetId from being interpreted as a number. + // If it's a number, there may be numeric precision issues. + rewrite: (id) => ``, + }, + { + regex: /^https?:\/\/www\.youtube\.com\/watch\?v=([^&]+)/, + rewrite: (id) => + ``, + }, + { + regex: /^https?:\/\/www\.metaculus\.com\/questions\/(\d+)/, + rewrite: (id) => + ``, + }, + { + regex: /^(https?:\/\/.*)/, + rewrite: (url) => ``, + }, +] + +function embedCode(text: string) { + for (const pattern of embedPatterns) { + const match = text.match(pattern.regex) + if (match) { + return pattern.rewrite(match[1]) + } + } + return null +} + +export function EmbedModal(props: { + editor: Editor | null + open: boolean + setOpen: (open: boolean) => void +}) { + const { editor, open, setOpen } = props + const [input, setInput] = useState('') + const embed = embedCode(input) + + return ( + + + + setInput(e.target.value)} + /> + + {/* Preview the embed if it's valid */} + {embed ? : } + + + + + + + + ) +} diff --git a/web/components/editor/tiptap-tweet.tsx b/web/components/editor/tiptap-tweet.tsx new file mode 100644 index 00000000..99106c43 --- /dev/null +++ b/web/components/editor/tiptap-tweet.tsx @@ -0,0 +1,13 @@ +import { Node } from '@tiptap/core' +import { ReactNodeViewRenderer } from '@tiptap/react' +import { TiptapTweetNode } from 'common/util/tiptap-tweet-type' +import WrappedTwitterTweetEmbed from './tweet-embed' + +// eslint-disable-next-line @typescript-eslint/ban-ts-comment +// @ts-ignore +export default Node.create({ + ...TiptapTweetNode, + addNodeView() { + return ReactNodeViewRenderer(WrappedTwitterTweetEmbed) + }, +}) diff --git a/web/components/editor/tweet-embed.tsx b/web/components/editor/tweet-embed.tsx new file mode 100644 index 00000000..91b2fa65 --- /dev/null +++ b/web/components/editor/tweet-embed.tsx @@ -0,0 +1,19 @@ +import { NodeViewWrapper } from '@tiptap/react' +import { TwitterTweetEmbed } from 'react-twitter-embed' + +export default function WrappedTwitterTweetEmbed(props: { + node: { + attrs: { + tweetId: string + } + } +}): JSX.Element { + // Remove the leading 't' from the tweet id + const tweetId = props.node.attrs.tweetId.slice(1) + + return ( + + + + ) +} diff --git a/web/package.json b/web/package.json index 4fba3359..a008026b 100644 --- a/web/package.json +++ b/web/package.json @@ -53,6 +53,7 @@ "react-hot-toast": "2.2.0", "react-instantsearch-hooks-web": "6.24.1", "react-query": "3.39.0", + "react-twitter-embed": "4.0.4", "string-similarity": "^4.0.4", "tippy.js": "6.3.7" }, diff --git a/yarn.lock b/yarn.lock index bbf8d3ee..e83ffc0a 100644 --- a/yarn.lock +++ b/yarn.lock @@ -9964,6 +9964,13 @@ react-textarea-autosize@^8.3.2: use-composed-ref "^1.3.0" use-latest "^1.2.1" +react-twitter-embed@4.0.4: + version "4.0.4" + resolved "https://registry.yarnpkg.com/react-twitter-embed/-/react-twitter-embed-4.0.4.tgz#4a6b8354acc266876ff1110b9f648518ea20db6d" + integrity sha512-2JIL7qF+U62zRzpsh6SZDXNI3hRNVYf5vOZ1WRcMvwKouw+xC00PuFaD0aEp2wlyGaZ+f4x2VvX+uDadFQ3HVA== + dependencies: + scriptjs "^2.5.9" + react-with-forwarded-ref@^0.3.3: version "0.3.4" resolved "https://registry.yarnpkg.com/react-with-forwarded-ref/-/react-with-forwarded-ref-0.3.4.tgz#b1e884ea081ec3c5dd578f37889159797454c0a5" @@ -10464,6 +10471,11 @@ schema-utils@^4.0.0: ajv-formats "^2.1.1" ajv-keywords "^5.0.0" +scriptjs@^2.5.9: + version "2.5.9" + resolved "https://registry.yarnpkg.com/scriptjs/-/scriptjs-2.5.9.tgz#343915cd2ec2ed9bfdde2b9875cd28f59394b35f" + integrity sha512-qGVDoreyYiP1pkQnbnFAUIS5AjenNwwQBdl7zeos9etl+hYKWahjRTfzAZZYBv5xNHx7vNKCmaLDQZ6Fr2AEXg== + search-insights@^2.1.0: version "2.2.1" resolved "https://registry.yarnpkg.com/search-insights/-/search-insights-2.2.1.tgz#9c93344fbae5fbf2f88c1a81b46b4b5d888c11f7"