From 4f2dccbe1c43218b03f1a4d4de8b0657c4500a94 Mon Sep 17 00:00:00 2001 From: Nikita <93587872+ncarazon@users.noreply.github.com> Date: Tue, 24 Dec 2024 16:50:20 +0400 Subject: [PATCH] feat(mdx-editor): tweet embed previews (#1808) --- front_end/package-lock.json | 38 +++++++++++++++++++ front_end/package.json | 1 + .../notebooks/components/notebook_editor.tsx | 2 +- .../src/components/comment_feed/comment.tsx | 1 + .../embedded_twitter/helpers.ts | 24 ++++++++++++ .../embedded_twitter/index.tsx | 12 ++++++ .../src/components/markdown_editor/helpers.ts | 8 +++- .../markdown_editor/initialized_editor.tsx | 16 ++++++-- 8 files changed, 97 insertions(+), 5 deletions(-) create mode 100644 front_end/src/components/markdown_editor/embedded_twitter/helpers.ts create mode 100644 front_end/src/components/markdown_editor/embedded_twitter/index.tsx diff --git a/front_end/package-lock.json b/front_end/package-lock.json index bef9bdcd1..9b6e416c4 100644 --- a/front_end/package-lock.json +++ b/front_end/package-lock.json @@ -57,6 +57,7 @@ "react-hook-form": "^7.52.1", "react-hot-toast": "^2.4.1", "react-merge-refs": "^2.1.1", + "react-tweet": "^3.2.1", "remark": "^15.0.1", "sass": "^1.77.6", "sharp": "^0.33.5", @@ -13068,6 +13069,21 @@ } } }, + "node_modules/react-tweet": { + "version": "3.2.1", + "resolved": "https://registry.npmjs.org/react-tweet/-/react-tweet-3.2.1.tgz", + "integrity": "sha512-dktP3RMuwRB4pnSDocKpSsW5Hq1IXRW6fONkHhxT5EBIXsKZzdQuI70qtub1XN2dtZdkJWWxfBm/Q+kN+vRYFA==", + "license": "MIT", + "dependencies": { + "@swc/helpers": "^0.5.3", + "clsx": "^2.0.0", + "swr": "^2.2.4" + }, + "peerDependencies": { + "react": ">= 18.0.0", + "react-dom": ">= 18.0.0" + } + }, "node_modules/read-cache": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/read-cache/-/read-cache-1.0.0.tgz", @@ -14315,6 +14331,19 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/swr": { + "version": "2.2.5", + "resolved": "https://registry.npmjs.org/swr/-/swr-2.2.5.tgz", + "integrity": "sha512-QtxqyclFeAsxEUeZIYmsaQ0UjimSq1RZ9Un7I68/0ClKK/U3LoyQunwkQfJZr2fc22DfIXLNDc2wFyTEikCUpg==", + "license": "MIT", + "dependencies": { + "client-only": "^0.0.1", + "use-sync-external-store": "^1.2.0" + }, + "peerDependencies": { + "react": "^16.11.0 || ^17.0.0 || ^18.0.0" + } + }, "node_modules/synckit": { "version": "0.8.8", "resolved": "https://registry.npmjs.org/synckit/-/synckit-0.8.8.tgz", @@ -15061,6 +15090,15 @@ } } }, + "node_modules/use-sync-external-store": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/use-sync-external-store/-/use-sync-external-store-1.4.0.tgz", + "integrity": "sha512-9WXSPC5fMv61vaupRkCKCxsPxBocVnwakBEkMIHHpkTTg6icbJtg6jzgtLDm4bl3cSHAca52rYWih0k4K3PfHw==", + "license": "MIT", + "peerDependencies": { + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" + } + }, "node_modules/util-deprecate": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz", diff --git a/front_end/package.json b/front_end/package.json index 779675f3f..2d1d9af5c 100644 --- a/front_end/package.json +++ b/front_end/package.json @@ -62,6 +62,7 @@ "react-hook-form": "^7.52.1", "react-hot-toast": "^2.4.1", "react-merge-refs": "^2.1.1", + "react-tweet": "^3.2.1", "remark": "^15.0.1", "sass": "^1.77.6", "sharp": "^0.33.5", diff --git a/front_end/src/app/(main)/notebooks/components/notebook_editor.tsx b/front_end/src/app/(main)/notebooks/components/notebook_editor.tsx index c41d43558..8476afef4 100644 --- a/front_end/src/app/(main)/notebooks/components/notebook_editor.tsx +++ b/front_end/src/app/(main)/notebooks/components/notebook_editor.tsx @@ -85,7 +85,7 @@ const NotebookEditor: React.FC = ({ {!isEditing && (
- +
)} diff --git a/front_end/src/components/comment_feed/comment.tsx b/front_end/src/components/comment_feed/comment.tsx index ce9b68971..91d13023e 100644 --- a/front_end/src/components/comment_feed/comment.tsx +++ b/front_end/src/components/comment_feed/comment.tsx @@ -465,6 +465,7 @@ const Comment: FC = ({ )} mode={"read"} withUgcLinks + withTwitterPreview /> )} diff --git a/front_end/src/components/markdown_editor/embedded_twitter/helpers.ts b/front_end/src/components/markdown_editor/embedded_twitter/helpers.ts new file mode 100644 index 000000000..a68de79cd --- /dev/null +++ b/front_end/src/components/markdown_editor/embedded_twitter/helpers.ts @@ -0,0 +1,24 @@ +const TWITTER_REGEX = + /https?:\/\/(?:twitter|vxtwitter|x)\.com\/(?:#!\/)?\w+\/status(?:es)?\/(\d+)/g; + +export const transformTwitterLinks = (markdown: string): string => { + const matches = markdown.match(TWITTER_REGEX); + + if (!matches) { + return markdown; + } + + const uniqueTweetIds = new Set(); + matches.forEach((match) => { + const tweetIdMatch = match.match(/(\d+)$/); + if (tweetIdMatch && tweetIdMatch[1]) { + uniqueTweetIds.add(tweetIdMatch[1]); + } + }); + + const tweetComponents = Array.from(uniqueTweetIds) + .map((id) => ``) + .join("\n"); + + return `${markdown}\n\n${tweetComponents}`; +}; diff --git a/front_end/src/components/markdown_editor/embedded_twitter/index.tsx b/front_end/src/components/markdown_editor/embedded_twitter/index.tsx new file mode 100644 index 000000000..c8b3379bc --- /dev/null +++ b/front_end/src/components/markdown_editor/embedded_twitter/index.tsx @@ -0,0 +1,12 @@ +import { JsxComponentDescriptor } from "@mdxeditor/editor"; +import { Tweet } from "react-tweet"; + +import createEditorComponent from "../createJsxComponent"; + +export const tweetDescriptor: JsxComponentDescriptor = { + name: "Tweet", + props: [{ name: "id", type: "string", required: true }], + kind: "text", + hasChildren: false, + Editor: createEditorComponent(Tweet), +}; diff --git a/front_end/src/components/markdown_editor/helpers.ts b/front_end/src/components/markdown_editor/helpers.ts index 2d8c85d3e..ef50f71dd 100644 --- a/front_end/src/components/markdown_editor/helpers.ts +++ b/front_end/src/components/markdown_editor/helpers.ts @@ -2,6 +2,7 @@ import { revertMathJaxTransform, transformMathJax, } from "./embedded_math_jax/helpers"; +import { transformTwitterLinks } from "./embedded_twitter/helpers"; // escape < and { that is not correctly used function escapePlainTextSymbols(str: string) { @@ -32,13 +33,18 @@ function formatBlockquoteNewlines(markdown: string): string { export function processMarkdown( markdown: string, - revert: boolean = false + config?: { revert?: boolean; withTwitterPreview?: boolean } ): string { + const { revert, withTwitterPreview } = config ?? {}; + markdown = formatBlockquoteNewlines(markdown); markdown = revert ? revertMathJaxTransform(markdown) : transformMathJax(markdown); markdown = escapePlainTextSymbols(markdown); + if (withTwitterPreview) { + markdown = transformTwitterLinks(markdown); + } return markdown; } diff --git a/front_end/src/components/markdown_editor/initialized_editor.tsx b/front_end/src/components/markdown_editor/initialized_editor.tsx index caa7354de..12e87e46e 100644 --- a/front_end/src/components/markdown_editor/initialized_editor.tsx +++ b/front_end/src/components/markdown_editor/initialized_editor.tsx @@ -48,6 +48,7 @@ import { embeddedQuestionDescriptor, EmbedQuestionAction, } from "@/components/markdown_editor/embedded_question"; +import { tweetDescriptor } from "@/components/markdown_editor/embedded_twitter"; import { processMarkdown } from "@/components/markdown_editor/helpers"; import { linkPlugin } from "@/components/markdown_editor/plugins/link"; import { mentionsPlugin } from "@/components/markdown_editor/plugins/mentions"; @@ -63,6 +64,7 @@ type EditorMode = "write" | "read"; const jsxComponentDescriptors: JsxComponentDescriptor[] = [ mathJaxDescriptor, embeddedQuestionDescriptor, + tweetDescriptor, ]; const PlainTextCodeEditorDescriptor: CodeBlockEditorDescriptor = { @@ -90,6 +92,7 @@ export type MarkdownEditorProps = { withUgcLinks?: boolean; className?: string; initialMention?: string; + withTwitterPreview?: boolean; }; /** @@ -110,6 +113,7 @@ const InitializedMarkdownEditor: FC< shouldConfirmLeave = false, withUgcLinks, initialMention, + withTwitterPreview = false, }) => { const { user } = useAuth(); const { theme } = useAppTheme(); @@ -121,14 +125,20 @@ const InitializedMarkdownEditor: FC< // Transform MathJax syntax to JSX embeds to properly utilise the MarkJax renderer const formattedMarkdown = useMemo( - () => processMarkdown(markdown), - [markdown] + () => + processMarkdown(markdown, { + revert: false, + withTwitterPreview: mode === "read" && withTwitterPreview, + }), + [markdown, mode, withTwitterPreview] ); const handleEditorChange = useCallback( (value: string) => { // Revert the MathJax transformation before passing the markdown to the parent component - onChange?.(processMarkdown(value, true)); + onChange?.( + processMarkdown(value, { revert: true, withTwitterPreview: false }) + ); }, [onChange] );