diff --git a/packages/commonwealth/client/scripts/hooks/useDraft.tsx b/packages/commonwealth/client/scripts/hooks/useDraft.tsx index edb2532a8e4..2f8c5bfaf5b 100644 --- a/packages/commonwealth/client/scripts/hooks/useDraft.tsx +++ b/packages/commonwealth/client/scripts/hooks/useDraft.tsx @@ -1,10 +1,14 @@ -const KEY_VERSION = 'v2'; // update this for breaking changes -const PREFIX = `cw-draft-${KEY_VERSION}`; - const MAX_DRAFT_SIZE = 1024 * 1024 * 4; -export function useDraft(key: string) { - const fullKey = `${PREFIX}-${key}`; +type DraftOpts = { + keyVersion?: string; +}; + +export function useDraft(key: string, opts: DraftOpts = {}) { + const keyVersion = opts.keyVersion ?? 'v2'; + const prefix = `cw-draft-${keyVersion}`; + + const fullKey = `${prefix}-${key}`; const saveDraft = (data: T) => { const draft = JSON.stringify(data); diff --git a/packages/commonwealth/client/scripts/navigation/CommonDomainRoutes.tsx b/packages/commonwealth/client/scripts/navigation/CommonDomainRoutes.tsx index 00333e10512..1beadf2bb36 100644 --- a/packages/commonwealth/client/scripts/navigation/CommonDomainRoutes.tsx +++ b/packages/commonwealth/client/scripts/navigation/CommonDomainRoutes.tsx @@ -4,8 +4,12 @@ import { Route } from 'react-router-dom'; import { withLayout } from 'views/Layout'; import { RouteFeatureFlags } from './Router'; +const QuillPage = lazy(() => import('views/pages/QuillPage')); const MarkdownEditorPage = lazy(() => import('views/pages/MarkdownEditorPage')); const MarkdownViewerPage = lazy(() => import('views/pages/MarkdownViewerPage')); +const MarkdownHitHighlighterPage = lazy( + () => import('views/pages/MarkdownHitHighlighterPage'), +); const DashboardPage = lazy(() => import('views/pages/user_dashboard')); const CommunitiesPage = lazy(() => import('views/pages/Communities')); @@ -118,14 +122,26 @@ const CommonDomainRoutes = ({ tokenizedCommunityEnabled, }: RouteFeatureFlags) => [ } + />, + + } />, } + />, + + } />, diff --git a/packages/commonwealth/client/scripts/views/components/MarkdownEditor/DesktopEditorFooter.tsx b/packages/commonwealth/client/scripts/views/components/MarkdownEditor/DesktopEditorFooter.tsx deleted file mode 100644 index 1b4365a8e70..00000000000 --- a/packages/commonwealth/client/scripts/views/components/MarkdownEditor/DesktopEditorFooter.tsx +++ /dev/null @@ -1,67 +0,0 @@ -import { Clipboard, DownloadSimple } from '@phosphor-icons/react'; -import React, { useCallback, useRef } from 'react'; -import './DesktopEditorFooter.scss'; - -type DesktopEditorFooterProps = Readonly<{ - onImportMarkdown?: (file: File) => void; - onSubmit?: () => void; -}>; - -export const DesktopEditorFooter = (props: DesktopEditorFooterProps) => { - const { onImportMarkdown, onSubmit } = props; - const fileInputRef = useRef(null); - - const fileHandler = useCallback( - (event: React.ChangeEvent) => { - if (event.target.files) { - onImportMarkdown?.(event.target.files[0]); - } - }, - [onImportMarkdown], - ); - - const handleImportMarkdown = useCallback(() => { - if (fileInputRef.current) { - // this is needed to clear the current file input ref or else you won't - // be able to import the same file multiple times. - fileInputRef.current.value = ''; - fileInputRef.current.click(); - } - }, []); - - return ( -
-
-
-
- -
-
Paste, drop or click to add files
-
-
-
- - -
- -
- -
-
- ); -}; diff --git a/packages/commonwealth/client/scripts/views/components/MarkdownEditor/MarkdownEditor.scss b/packages/commonwealth/client/scripts/views/components/MarkdownEditor/MarkdownEditor.scss index e5e5d6b0939..db21d01aa6a 100644 --- a/packages/commonwealth/client/scripts/views/components/MarkdownEditor/MarkdownEditor.scss +++ b/packages/commonwealth/client/scripts/views/components/MarkdownEditor/MarkdownEditor.scss @@ -18,6 +18,15 @@ body, display: none; } +.mdxeditor-container { + outline: 1px solid $neutral-200; + border-radius: 4px; +} + +.mdxeditor-container-active { + outline: 1px solid $primary-300; +} + .mdxeditor-container-mode-mobile { .mdxeditor { flex-grow: 1; @@ -25,12 +34,22 @@ body, } .mdxeditor-toolbar { + border-top: solid $neutral-200 1px; + min-height: 45px !important; } } +.mdxeditor-container-disabled { + .mdxeditor-root-contenteditable { + // TODO: disable editing here... + } +} + .mdxeditor-container-mode-desktop { .mdxeditor-root-contenteditable { + border-top: 1px solid $neutral-200; + min-height: 150px; max-height: 450px; } @@ -61,7 +80,6 @@ body, .mdxeditor-toolbar { // TODO this only shows up properly for the *mobile* editor not the desktop // one. - border-top: solid $neutral-200 1px; border-radius: inherit; background-color: inherit; overflow: hidden; @@ -74,6 +92,10 @@ body, // necessary because otherwise code mirror line numbers are too small. font-size: inherit !important; } + + button { + outline: none; + } } .mdxeditor-diff-source-wrapper { diff --git a/packages/commonwealth/client/scripts/views/components/MarkdownEditor/MarkdownEditor.tsx b/packages/commonwealth/client/scripts/views/components/MarkdownEditor/MarkdownEditor.tsx index e12a1cd7488..139b8a71141 100644 --- a/packages/commonwealth/client/scripts/views/components/MarkdownEditor/MarkdownEditor.tsx +++ b/packages/commonwealth/client/scripts/views/components/MarkdownEditor/MarkdownEditor.tsx @@ -19,22 +19,33 @@ import { } from 'commonwealth-mdxeditor'; import 'commonwealth-mdxeditor/style.css'; import { notifyError } from 'controllers/app/notifications'; -import React, { memo, useCallback, useRef, useState } from 'react'; -import { DesktopEditorFooter } from './DesktopEditorFooter'; +import React, { + memo, + MutableRefObject, + ReactNode, + useCallback, + useMemo, + useRef, + useState, +} from 'react'; +import { MarkdownEditorModeContext } from 'views/components/MarkdownEditor/MarkdownEditorModeContext'; +import { useDeviceProfile } from 'views/components/MarkdownEditor/useDeviceProfile'; +import { MarkdownEditorMethods } from 'views/components/MarkdownEditor/useMarkdownEditorMethods'; import { DragIndicator } from './indicators/DragIndicator'; import { UploadIndicator } from './indicators/UploadIndicator'; +import './MarkdownEditor.scss'; +import { MarkdownEditorContext } from './MarkdownEditorContext'; +import { DesktopEditorFooter } from './toolbars/DesktopEditorFooter'; import { ToolbarForDesktop } from './toolbars/ToolbarForDesktop'; import { ToolbarForMobile } from './toolbars/ToolbarForMobile'; -import { useEditorErrorHandler } from './useEditorErrorHandler'; import { useImageUploadHandler } from './useImageUploadHandler'; +import { useMarkdownEditorErrorHandler } from './useMarkdownEditorErrorHandler'; import { canAcceptFileForImport } from './utils/canAcceptFileForImport'; import { codeBlockLanguages } from './utils/codeBlockLanguages'; import { editorTranslator } from './utils/editorTranslator'; import { fileToText } from './utils/fileToText'; import { iconComponentFor } from './utils/iconComponentFor'; -import './MarkdownEditor.scss'; - export type ImageURL = string; export type MarkdownEditorMode = 'desktop' | 'mobile'; @@ -56,28 +67,45 @@ export type UpdateContentStrategy = 'insert' | 'replace'; export const DEFAULT_UPDATE_CONTENT_STRATEGY = 'insert' as UpdateContentStrategy; -type EditorProps = { - readonly markdown?: MarkdownStr; - readonly mode?: MarkdownEditorMode; - readonly placeholder?: string; - readonly imageHandler?: ImageHandler; - readonly onSubmit?: (markdown: MarkdownStr) => void; -}; - -export const MarkdownEditor = memo(function MarkdownEditor(props: EditorProps) { - const { onSubmit } = props; - const errorHandler = useEditorErrorHandler(); +export type MarkdownEditorProps = Readonly<{ + markdown?: MarkdownStr; + + disabled?: boolean; + + /** + * Manually set the mode for the markdown editor, You shouldn't use this + * in prod, just in stories and debug code. + * + * @internal + */ + mode?: MarkdownEditorMode; + placeholder?: string; + imageHandler?: ImageHandler; + SubmitButton?: () => ReactNode; + tooltip?: ReactNode; + onMarkdownEditorMethods?: (methods: MarkdownEditorMethods) => void; + onChange?: (markdown: MarkdownStr) => void; +}>; + +export const MarkdownEditor = memo(function MarkdownEditor( + props: MarkdownEditorProps, +) { + const { SubmitButton, onMarkdownEditorMethods, disabled, onChange } = props; + const errorHandler = useMarkdownEditorErrorHandler(); const [dragging, setDragging] = useState(false); const [uploading, setUploading] = useState(false); + const [active, setActive] = useState(false); const dragCounterRef = useRef(0); - const mode = props.mode ?? 'desktop'; + const deviceProfile = useDeviceProfile(); + + const mode = props.mode ?? deviceProfile; const imageHandler: ImageHandler = props.imageHandler ?? 'S3'; const placeholder = props.placeholder ?? 'Share your thoughts...'; - const mdxEditorRef = React.useRef(null); + const mdxEditorRef: MutableRefObject = useRef(null); const imageUploadHandlerDelegate = useImageUploadHandler(imageHandler); @@ -102,6 +130,19 @@ export const MarkdownEditor = memo(function MarkdownEditor(props: EditorProps) { [imageUploadHandlerDelegate, terminateDragging], ); + const imageUploadHandlerWithMarkdownInsertion = useCallback( + (file: File) => { + async function doAsync() { + const url = await imageUploadHandler(file); + + mdxEditorRef.current?.insertMarkdown(`![](${url} "")`); + } + + doAsync().catch(errorHandler); + }, + [errorHandler, imageUploadHandler], + ); + const handleFile = useCallback(async (file: File) => { if (!file.name.endsWith('.md')) { notifyError('Not a markdown file.'); @@ -127,9 +168,9 @@ export const MarkdownEditor = memo(function MarkdownEditor(props: EditorProps) { await handleFile(file); } - doAsync().catch(console.error); + doAsync().catch(errorHandler); }, - [handleFile], + [handleFile, errorHandler], ); const handleFiles = useCallback( @@ -174,12 +215,16 @@ export const MarkdownEditor = memo(function MarkdownEditor(props: EditorProps) { if (files.length === 1) { if (canAcceptFileForImport(files[0])) { - handleDropAsync(event).catch(console.error); event.preventDefault(); + handleDropAsync(event).catch(errorHandler); + } else { + notifyError('Can not accept files of this type.'); } } + + terminateDragging(); }, - [handleDropAsync], + [errorHandler, handleDropAsync, terminateDragging], ); const handleDragEnter = useCallback((event: React.DragEvent) => { @@ -220,77 +265,110 @@ export const MarkdownEditor = memo(function MarkdownEditor(props: EditorProps) { if (canAcceptFileForImport(files[0])) { // if we can accept this file for import, go ahead and do so... event.preventDefault(); - handleFiles(files).catch(console.error); + handleFiles(files).catch(errorHandler); } }, - [handleFiles], + [errorHandler, handleFiles], ); - const handleSubmit = useCallback(() => { + const doFocus = useCallback(() => { if (mdxEditorRef.current) { - const markdown = mdxEditorRef.current.getMarkdown(); - onSubmit?.(markdown); + mdxEditorRef.current.focus(); } - }, [onSubmit]); + }, []); + + const handleRef = useCallback( + (methods: MDXEditorMethods) => { + mdxEditorRef.current = methods; + onMarkdownEditorMethods?.(methods); + }, + [onMarkdownEditorMethods], + ); + + const mdxEditorMethods = useMemo(() => { + return { + getMarkdown: () => mdxEditorRef.current!.getMarkdown(), + }; + }, []); return ( -
-
handlePaste(event)} - > - - mode === 'mobile' ? ( - - ) : ( - - ), - }), - listsPlugin(), - quotePlugin(), - headingsPlugin(), - linkPlugin(), - linkDialogPlugin(), - codeBlockPlugin({ defaultCodeBlockLanguage: 'js' }), - codeMirrorPlugin({ - codeBlockLanguages, - }), - imagePlugin({ imageUploadHandler }), - tablePlugin(), - thematicBreakPlugin(), - frontmatterPlugin(), - diffSourcePlugin({ viewMode: 'rich-text', diffMarkdown: 'boo' }), - markdownShortcutPlugin(), - ]} - /> - - {mode === 'desktop' && ( - - )} - - {dragging && } - {uploading && } -
-
+ + +
+
handlePaste(event)} + onFocus={() => setActive(true)} + onBlur={() => setActive(false)} + > + onChange?.(markdown)} + plugins={[ + toolbarPlugin({ + location: mode === 'mobile' ? 'bottom' : 'top', + toolbarContents: () => + mode === 'mobile' ? ( + + ) : ( + + ), + }), + listsPlugin(), + quotePlugin(), + headingsPlugin(), + linkPlugin(), + linkDialogPlugin(), + codeBlockPlugin({ defaultCodeBlockLanguage: 'js' }), + codeMirrorPlugin({ + codeBlockLanguages, + }), + imagePlugin({ imageUploadHandler }), + tablePlugin(), + thematicBreakPlugin(), + frontmatterPlugin(), + diffSourcePlugin({ + viewMode: 'rich-text', + diffMarkdown: 'boo', + }), + markdownShortcutPlugin(), + ]} + /> + + {mode === 'desktop' && ( + + )} + + {dragging && } + {uploading && } +
+
+
+
); }); diff --git a/packages/commonwealth/client/scripts/views/components/MarkdownEditor/MarkdownEditorContext.ts b/packages/commonwealth/client/scripts/views/components/MarkdownEditor/MarkdownEditorContext.ts new file mode 100644 index 00000000000..c7403ac7b13 --- /dev/null +++ b/packages/commonwealth/client/scripts/views/components/MarkdownEditor/MarkdownEditorContext.ts @@ -0,0 +1,7 @@ +import { MDXEditorMethods } from 'commonwealth-mdxeditor'; +import { createContext } from 'react'; + +export const MarkdownEditorContext = createContext | null>(null); diff --git a/packages/commonwealth/client/scripts/views/components/MarkdownEditor/MarkdownEditorModeContext.ts b/packages/commonwealth/client/scripts/views/components/MarkdownEditor/MarkdownEditorModeContext.ts new file mode 100644 index 00000000000..e4159e85623 --- /dev/null +++ b/packages/commonwealth/client/scripts/views/components/MarkdownEditor/MarkdownEditorModeContext.ts @@ -0,0 +1,5 @@ +import { createContext } from 'react'; +import { MarkdownEditorMode } from 'views/components/MarkdownEditor/MarkdownEditor'; + +export const MarkdownEditorModeContext = + createContext(null); diff --git a/packages/commonwealth/client/scripts/views/components/MarkdownEditor/MarkdownSubmitButton.scss b/packages/commonwealth/client/scripts/views/components/MarkdownEditor/MarkdownSubmitButton.scss new file mode 100644 index 00000000000..afbaba96eda --- /dev/null +++ b/packages/commonwealth/client/scripts/views/components/MarkdownEditor/MarkdownSubmitButton.scss @@ -0,0 +1,8 @@ +.mdxeditor-container-mode-desktop .MarkdownSubmitButton button { + border: none; + font-size: 1.2em; + padding: 8px 16px; + cursor: pointer; + border-radius: 8px; + display: inline-block; +} diff --git a/packages/commonwealth/client/scripts/views/components/MarkdownEditor/MarkdownSubmitButton.tsx b/packages/commonwealth/client/scripts/views/components/MarkdownEditor/MarkdownSubmitButton.tsx new file mode 100644 index 00000000000..d4de1a78f38 --- /dev/null +++ b/packages/commonwealth/client/scripts/views/components/MarkdownEditor/MarkdownSubmitButton.tsx @@ -0,0 +1,33 @@ +import clsx from 'clsx'; +import React from 'react'; +import { useMarkdownEditorMode } from 'views/components/MarkdownEditor/useMarkdownEditorMode'; + +export type MarkdownSubmitButtonProps = Readonly<{ + label: string; + disabled?: boolean; + onClick?: () => void; + className?: string; + tabIndex?: number; +}>; + +/** + * Button that adapts itself to mobile vs desktop. + * + * On mobile devices we don't use the label to save space. + */ +export const MarkdownSubmitButton = (props: MarkdownSubmitButtonProps) => { + const { onClick, className, disabled, label, tabIndex } = props; + + const mode = useMarkdownEditorMode(); + + return ( + + ); +}; diff --git a/packages/commonwealth/client/scripts/views/components/MarkdownEditor/layout.scss b/packages/commonwealth/client/scripts/views/components/MarkdownEditor/layout.scss index 21c93d7856a..2bc71f6b3f8 100644 --- a/packages/commonwealth/client/scripts/views/components/MarkdownEditor/layout.scss +++ b/packages/commonwealth/client/scripts/views/components/MarkdownEditor/layout.scss @@ -38,11 +38,6 @@ img { max-width: 100%; } - - table { - width: 100%; - } - th { text-align: left; } diff --git a/packages/commonwealth/client/scripts/views/components/MarkdownEditor/markdown/supported.md b/packages/commonwealth/client/scripts/views/components/MarkdownEditor/markdown/supported.md index b5577ee181b..f18f096edf3 100644 --- a/packages/commonwealth/client/scripts/views/components/MarkdownEditor/markdown/supported.md +++ b/packages/commonwealth/client/scripts/views/components/MarkdownEditor/markdown/supported.md @@ -9,8 +9,14 @@ This just tests all the major markdown features we want to support. ## Header 3 +# Link types + https://www.youtube.com/watch?v=eRBOgtp0Hac +https://x.com/venturetwins/status/1835366788433051677 + +https://twitter.com/venturetwins/status/1835366788433051677 + # Images ![Do Not Taunt Happy Fun Ball](https://upload.wikimedia.org/wikipedia/en/6/65/Happy_fun_ball.jpg "Happy Fun Ball") diff --git a/packages/commonwealth/client/scripts/views/components/MarkdownEditor/DesktopEditorFooter.scss b/packages/commonwealth/client/scripts/views/components/MarkdownEditor/toolbars/DesktopEditorFooter.scss similarity index 56% rename from packages/commonwealth/client/scripts/views/components/MarkdownEditor/DesktopEditorFooter.scss rename to packages/commonwealth/client/scripts/views/components/MarkdownEditor/toolbars/DesktopEditorFooter.scss index 4eb24fd67a1..31db0ef0679 100644 --- a/packages/commonwealth/client/scripts/views/components/MarkdownEditor/DesktopEditorFooter.scss +++ b/packages/commonwealth/client/scripts/views/components/MarkdownEditor/toolbars/DesktopEditorFooter.scss @@ -1,30 +1,24 @@ -@import '../../../../styles/shared'; +@import '../../../../../styles/shared'; .DesktopEditorFooter { color: $neutral-500; display: flex; - padding: 8px; + padding: 6px; user-select: none; + background-color: $neutral-50; + .Item { display: flex; margin-top: auto; margin-bottom: auto; - margin-left: 8px; - } - - .FilePickerInput { - visibility: hidden; - width: 0; - height: 0; - } + gap: 6px; - .FilePickerButton { - cursor: pointer; - border: none; - color: $neutral-500; + button { + outline: none; + } } .SubmitButtonItem { @@ -40,13 +34,4 @@ display: inline-block; } } - - .IconAndText { - display: flex; - gap: 8px; - - div { - margin: auto auto; - } - } } diff --git a/packages/commonwealth/client/scripts/views/components/MarkdownEditor/toolbars/DesktopEditorFooter.tsx b/packages/commonwealth/client/scripts/views/components/MarkdownEditor/toolbars/DesktopEditorFooter.tsx new file mode 100644 index 00000000000..58ca7a8382c --- /dev/null +++ b/packages/commonwealth/client/scripts/views/components/MarkdownEditor/toolbars/DesktopEditorFooter.tsx @@ -0,0 +1,39 @@ +import React, { ReactNode } from 'react'; +import { FileUploadButton } from 'views/components/MarkdownEditor/toolbars/FileUploadButton'; +import { IMAGE_ACCEPT } from 'views/components/MarkdownEditor/toolbars/ImageButton'; +import './DesktopEditorFooter.scss'; + +type DesktopEditorFooterProps = Readonly<{ + onImportMarkdown?: (file: File) => void; + onImage?: (file: File) => void; + SubmitButton?: () => ReactNode; +}>; + +export const DesktopEditorFooter = (props: DesktopEditorFooterProps) => { + const { onImportMarkdown, SubmitButton, onImage } = props; + + return ( +
+
+ onImage?.(file)} + /> +
+
+ onImportMarkdown?.(file)} + /> +
+ +
+ {SubmitButton && } +
+
+ ); +}; diff --git a/packages/commonwealth/client/scripts/views/components/MarkdownEditor/toolbars/FileUploadButton.scss b/packages/commonwealth/client/scripts/views/components/MarkdownEditor/toolbars/FileUploadButton.scss new file mode 100644 index 00000000000..08e999c5f4a --- /dev/null +++ b/packages/commonwealth/client/scripts/views/components/MarkdownEditor/toolbars/FileUploadButton.scss @@ -0,0 +1,34 @@ +@import '../../../../../styles/shared'; + +.FileUploadButton { + display: flex; + user-select: none; + + .Item { + display: flex; + margin-top: auto; + margin-bottom: auto; + margin-left: 8px; + } + + .FilePickerInput { + visibility: hidden; + width: 0; + height: 0; + } + + .FilePickerButton { + cursor: pointer; + border: none; + color: $neutral-500; + } + + .IconAndText { + display: flex; + gap: 4px; + + div { + margin: auto auto; + } + } +} diff --git a/packages/commonwealth/client/scripts/views/components/MarkdownEditor/toolbars/FileUploadButton.tsx b/packages/commonwealth/client/scripts/views/components/MarkdownEditor/toolbars/FileUploadButton.tsx new file mode 100644 index 00000000000..d102fc07214 --- /dev/null +++ b/packages/commonwealth/client/scripts/views/components/MarkdownEditor/toolbars/FileUploadButton.tsx @@ -0,0 +1,57 @@ +import React, { useCallback, useRef } from 'react'; + +import { DEFAULT_ICON_SIZE } from 'views/components/MarkdownEditor/utils/iconComponentFor'; +import { CWIcon } from 'views/components/component_kit/cw_icons/cw_icon'; +import type { IconName } from 'views/components/component_kit/cw_icons/cw_icon_lookup'; + +import './FileUploadButton.scss'; + +type FileUploadButtonProps = Readonly<{ + accept: string; + iconName: IconName; + onFile?: (file: File) => void; + text?: string; +}>; + +export const FileUploadButton = (props: FileUploadButtonProps) => { + const { onFile, accept, iconName, text } = props; + + const fileInputRef = useRef(null); + + const handleClick = useCallback(() => { + if (fileInputRef.current) { + // this is needed to clear the current file input ref or else you won't + // be able to import the same file multiple times. + fileInputRef.current.value = ''; + fileInputRef.current.click(); + } + }, []); + + const fileHandler = useCallback( + (event: React.ChangeEvent) => { + if (event.target.files) { + onFile?.(event.target.files[0]); + } + }, + [onFile], + ); + + return ( +
+ + +
+ ); +}; diff --git a/packages/commonwealth/client/scripts/views/components/MarkdownEditor/toolbars/ImageButton.tsx b/packages/commonwealth/client/scripts/views/components/MarkdownEditor/toolbars/ImageButton.tsx new file mode 100644 index 00000000000..ecf7568fcda --- /dev/null +++ b/packages/commonwealth/client/scripts/views/components/MarkdownEditor/toolbars/ImageButton.tsx @@ -0,0 +1,18 @@ +import React from 'react'; +import { FileUploadButton } from 'views/components/MarkdownEditor/toolbars/FileUploadButton'; + +export const IMAGE_ACCEPT = + '.jpg, .jpeg, .png, .gif, .webp, .svg, .apng, .avif'; + +type ImageButtonProps = Readonly<{ + onImage?: (file: File) => void; + text?: string; +}>; + +export const ImageButton = (props: ImageButtonProps) => { + const { onImage } = props; + + return ( + + ); +}; diff --git a/packages/commonwealth/client/scripts/views/components/MarkdownEditor/toolbars/ToolbarForDesktop.tsx b/packages/commonwealth/client/scripts/views/components/MarkdownEditor/toolbars/ToolbarForDesktop.tsx index 5649d1842dd..065a0a59906 100644 --- a/packages/commonwealth/client/scripts/views/components/MarkdownEditor/toolbars/ToolbarForDesktop.tsx +++ b/packages/commonwealth/client/scripts/views/components/MarkdownEditor/toolbars/ToolbarForDesktop.tsx @@ -4,7 +4,6 @@ import { ConditionalContents, CreateLink, InsertCodeBlock, - InsertImage, InsertTable, ListsToggle, Separator, @@ -12,10 +11,17 @@ import { } from 'commonwealth-mdxeditor'; import React from 'react'; import { HeadingButton } from 'views/components/MarkdownEditor/toolbars/HeadingButton'; +import { ImageButton } from 'views/components/MarkdownEditor/toolbars/ImageButton'; import { QuoteButton } from 'views/components/MarkdownEditor/toolbars/QuoteButton'; import './ToolbarForDesktop.scss'; -export const ToolbarForDesktop = () => { +type ToolbarForDesktopProps = Readonly<{ + onImage?: (file: File) => void; +}>; + +export const ToolbarForDesktop = (props: ToolbarForDesktopProps) => { + const { onImage } = props; + return (
{
- + diff --git a/packages/commonwealth/client/scripts/views/components/MarkdownEditor/toolbars/ToolbarForMobile.scss b/packages/commonwealth/client/scripts/views/components/MarkdownEditor/toolbars/ToolbarForMobile.scss index 03f79736f92..d872be549af 100644 --- a/packages/commonwealth/client/scripts/views/components/MarkdownEditor/toolbars/ToolbarForMobile.scss +++ b/packages/commonwealth/client/scripts/views/components/MarkdownEditor/toolbars/ToolbarForMobile.scss @@ -1,6 +1,9 @@ +@import '../../../../../styles/shared'; + .ToolbarForMobile { display: flex; flex-grow: 1; + .end { justify-content: flex-end; flex-grow: 1; diff --git a/packages/commonwealth/client/scripts/views/components/MarkdownEditor/toolbars/ToolbarForMobile.tsx b/packages/commonwealth/client/scripts/views/components/MarkdownEditor/toolbars/ToolbarForMobile.tsx index bed706f90f4..ebb9a4e8fe4 100644 --- a/packages/commonwealth/client/scripts/views/components/MarkdownEditor/toolbars/ToolbarForMobile.tsx +++ b/packages/commonwealth/client/scripts/views/components/MarkdownEditor/toolbars/ToolbarForMobile.tsx @@ -2,23 +2,68 @@ import { BlockTypeSelect, BoldItalicUnderlineToggles, CreateLink, - InsertImage, ListsToggle, Separator, } from 'commonwealth-mdxeditor'; -import React from 'react'; +import React, { ReactNode, useCallback, useEffect } from 'react'; +import { ImageButton } from 'views/components/MarkdownEditor/toolbars/ImageButton'; import './ToolbarForMobile.scss'; type ToolbarForMobileProps = Readonly<{ - onSubmit?: () => void; + SubmitButton?: () => ReactNode; + + /** + * Focus the toolbar so that it is not blurred when clicking buttons in the + * toolbar. + */ + focus?: () => void; }>; export const ToolbarForMobile = (props: ToolbarForMobileProps) => { - const { onSubmit } = props; + const { SubmitButton, focus } = props; + + const adjustForKeyboard = useCallback(() => { + if (!window.visualViewport) { + return; + } + + const height = Math.floor(window.visualViewport.height); + + const root = document.getElementById('root'); + + if (root) { + root.style.maxHeight = `${height}px`; + } + }, []); + + useEffect(() => { + adjustForKeyboard(); + + // Adjust whenever the window resizes (e.g., when the keyboard appears) + window.addEventListener('resize', adjustForKeyboard); + + return () => { + window.removeEventListener('resize', adjustForKeyboard); + }; + }, [adjustForKeyboard]); + + const preventKeyboardDeactivation = useCallback( + (event: React.MouseEvent) => { + event.stopPropagation(); + event.preventDefault(); + + focus?.(); + }, + [focus], + ); return ( -
+
@@ -27,10 +72,8 @@ export const ToolbarForMobile = (props: ToolbarForMobileProps) => { - -
- -
+ +
{SubmitButton && }
); }; diff --git a/packages/commonwealth/client/scripts/views/components/MarkdownEditor/useDeviceProfile.ts b/packages/commonwealth/client/scripts/views/components/MarkdownEditor/useDeviceProfile.ts new file mode 100644 index 00000000000..209dcfd3e06 --- /dev/null +++ b/packages/commonwealth/client/scripts/views/components/MarkdownEditor/useDeviceProfile.ts @@ -0,0 +1,37 @@ +export type DeviceProfile = 'mobile' | 'desktop'; + +export type DeviceOrientation = 'vertical' | 'horizontal'; + +function useDeviceOrientation(): DeviceOrientation { + return [0, 180].includes(window.screen.orientation.angle) + ? 'vertical' + : 'horizontal'; +} + +/** + * Depending on orientation, use the width/height to determine if we're mobile. + */ +function useDeviceCutoff() { + const orientation = useDeviceOrientation(); + + switch (orientation) { + case 'vertical': + return window.screen.width; + case 'horizontal': + return window.screen.height; + } +} + +const CUTOFF = 600; + +/** + * + * Determine if we're a mobile device based just on the screen width. + * + * This is different from useBrowserWindow because we have to look at width + * not innerWidth. + */ +export function useDeviceProfile(): DeviceProfile { + const deviceCutoff = useDeviceCutoff(); + return deviceCutoff <= CUTOFF ? 'mobile' : 'desktop'; +} diff --git a/packages/commonwealth/client/scripts/views/components/MarkdownEditor/useEditorErrorHandler.ts b/packages/commonwealth/client/scripts/views/components/MarkdownEditor/useEditorErrorHandler.ts deleted file mode 100644 index 8b2e2a0c531..00000000000 --- a/packages/commonwealth/client/scripts/views/components/MarkdownEditor/useEditorErrorHandler.ts +++ /dev/null @@ -1,14 +0,0 @@ -import { notifyError } from 'controllers/app/notifications'; - -type ErrorPayload = Readonly<{ - error: string; - source: string; -}>; - -export function useEditorErrorHandler(): (err: ErrorPayload) => void { - // I want to keep this as a hook, so we can *make* it a hook later if we want. - return (err) => { - console.error('Encountered error with editor: ', err); - notifyError('Encountered error with editor: ' + err.error); - }; -} diff --git a/packages/commonwealth/client/scripts/views/components/MarkdownEditor/useMarkdownEditorErrorHandler.ts b/packages/commonwealth/client/scripts/views/components/MarkdownEditor/useMarkdownEditorErrorHandler.ts new file mode 100644 index 00000000000..0a8e1fd4b10 --- /dev/null +++ b/packages/commonwealth/client/scripts/views/components/MarkdownEditor/useMarkdownEditorErrorHandler.ts @@ -0,0 +1,25 @@ +import { notifyError } from 'controllers/app/notifications'; +import { useCallback } from 'react'; + +type ErrorPayload = Readonly<{ + error: string; + source: string; +}>; + +function isError(e: ErrorPayload | Error): e is Error { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + return (e as any).message; +} + +export function useMarkdownEditorErrorHandler(): ( + err: ErrorPayload | Error, +) => void { + // I want to keep this as a hook, so we can *make* it a hook later if we want. + return useCallback((err) => { + console.error('Encountered error with editor: ', err); + + const msg = isError(err) ? err.message : err.error; + + notifyError('Encountered error with editor: ' + msg); + }, []); +} diff --git a/packages/commonwealth/client/scripts/views/components/MarkdownEditor/useMarkdownEditorMethods.tsx b/packages/commonwealth/client/scripts/views/components/MarkdownEditor/useMarkdownEditorMethods.tsx new file mode 100644 index 00000000000..d4fd18a902e --- /dev/null +++ b/packages/commonwealth/client/scripts/views/components/MarkdownEditor/useMarkdownEditorMethods.tsx @@ -0,0 +1,9 @@ +import { MDXEditorMethods } from 'commonwealth-mdxeditor'; +import { useContext } from 'react'; +import { MarkdownEditorContext } from 'views/components/MarkdownEditor/MarkdownEditorContext'; + +export type MarkdownEditorMethods = Pick; + +export function useMarkdownEditorMethods(): MarkdownEditorMethods { + return useContext(MarkdownEditorContext)!; +} diff --git a/packages/commonwealth/client/scripts/views/components/MarkdownEditor/useMarkdownEditorMode.ts b/packages/commonwealth/client/scripts/views/components/MarkdownEditor/useMarkdownEditorMode.ts new file mode 100644 index 00000000000..429327d4260 --- /dev/null +++ b/packages/commonwealth/client/scripts/views/components/MarkdownEditor/useMarkdownEditorMode.ts @@ -0,0 +1,6 @@ +import { useContext } from 'react'; +import { MarkdownEditorModeContext } from 'views/components/MarkdownEditor/MarkdownEditorModeContext'; + +export function useMarkdownEditorMode() { + return useContext(MarkdownEditorModeContext)!; +} diff --git a/packages/commonwealth/client/scripts/views/components/MarkdownHitHighlighter/HitChunks.tsx b/packages/commonwealth/client/scripts/views/components/MarkdownHitHighlighter/HitChunks.tsx new file mode 100644 index 00000000000..bbe478c22b5 --- /dev/null +++ b/packages/commonwealth/client/scripts/views/components/MarkdownHitHighlighter/HitChunks.tsx @@ -0,0 +1,50 @@ +import React from 'react'; +import smartTruncate from 'smart-truncate'; + +type Chunk = Readonly<{ + start: number; + end: number; + highlight: string; +}>; + +type HitChunksProps = Readonly<{ + searchTerm: string; + docText: string; + chunks: ReadonlyArray; +}>; + +export const HitChunks = (props: HitChunksProps) => { + const { searchTerm, chunks, docText } = props; + + return chunks.map(({ end, highlight, start }, index) => { + const middle = 15; + + const subString = docText.substr(start, end - start); + + const hasSingleChunk = chunks.length <= 1; + const truncateLength = hasSingleChunk ? 150 : 40 + searchTerm.trim().length; + const truncateOptions = hasSingleChunk + ? {} + : index === 0 + ? { position: 0 } + : index === chunks.length - 1 + ? {} + : { position: middle }; + + let text = smartTruncate(subString, truncateLength, truncateOptions); + + // restore leading and trailing space + if (subString.startsWith(' ')) { + text = ` ${text}`; + } + if (subString.endsWith(' ')) { + text = `${text} `; + } + + const key = `chunk-${index}`; + if (highlight) { + return {text}; + } + return {text}; + }); +}; diff --git a/packages/commonwealth/client/scripts/views/components/MarkdownHitHighlighter/MarkdownHitHighlighter.tsx b/packages/commonwealth/client/scripts/views/components/MarkdownHitHighlighter/MarkdownHitHighlighter.tsx new file mode 100644 index 00000000000..a1aeb527304 --- /dev/null +++ b/packages/commonwealth/client/scripts/views/components/MarkdownHitHighlighter/MarkdownHitHighlighter.tsx @@ -0,0 +1,38 @@ +import DOMPurify from 'dompurify'; +import { findAll } from 'highlight-words-core'; +import React, { memo } from 'react'; +import removeMd from 'remove-markdown'; +import { HitChunks } from 'views/components/MarkdownHitHighlighter/HitChunks'; + +type MarkdownStr = string; + +type MarkdownHitHighlighterProps = Readonly<{ + className?: string; + markdown: MarkdownStr; + searchTerm: string; +}>; + +export const MarkdownHitHighlighter = memo(function MarkdownHitHighlighter( + props: MarkdownHitHighlighterProps, +) { + const { markdown, searchTerm, className } = props; + + const html = DOMPurify.sanitize(markdown, { + ALLOWED_TAGS: ['a'], + ADD_ATTR: ['target'], + }); + + const docText = removeMd(html).replace(/\n/g, ' ').replace(/\+/g, ' '); + + // extract highlighted text + const chunks = findAll({ + searchWords: [searchTerm.trim()], + textToHighlight: docText, + }); + + return ( +
+ +
+ ); +}); diff --git a/packages/commonwealth/client/scripts/views/components/MarkdownHitHighlighter/index.tsx b/packages/commonwealth/client/scripts/views/components/MarkdownHitHighlighter/index.tsx new file mode 100644 index 00000000000..d4e6fb36952 --- /dev/null +++ b/packages/commonwealth/client/scripts/views/components/MarkdownHitHighlighter/index.tsx @@ -0,0 +1 @@ +export { MarkdownHitHighlighter as default } from './MarkdownHitHighlighter'; diff --git a/packages/commonwealth/client/scripts/views/components/MarkdownHitHighlighterWithFallback/MarkdownHitHighlighterWithFallback.tsx b/packages/commonwealth/client/scripts/views/components/MarkdownHitHighlighterWithFallback/MarkdownHitHighlighterWithFallback.tsx new file mode 100644 index 00000000000..84b3184857c --- /dev/null +++ b/packages/commonwealth/client/scripts/views/components/MarkdownHitHighlighterWithFallback/MarkdownHitHighlighterWithFallback.tsx @@ -0,0 +1,37 @@ +import { useFlag } from 'hooks/useFlag'; +import React from 'react'; +import MarkdownHitHighlighter from 'views/components/MarkdownHitHighlighter'; +import { QuillRenderer } from 'views/components/react_quill_editor/quill_renderer'; + +type MarkdownHitHighlighterWithFallbackProps = Readonly<{ + className?: string; + markdown: string; + searchTerm: string; +}>; + +export const MarkdownHitHighlighterWithFallback = ( + props: MarkdownHitHighlighterWithFallbackProps, +) => { + const { markdown, searchTerm, className } = props; + + const newEditor = useFlag('newEditor'); + + if (newEditor) { + return ( + + ); + } + + return ( + + ); +}; diff --git a/packages/commonwealth/client/scripts/views/components/MarkdownViewer/MarkdownViewer.scss b/packages/commonwealth/client/scripts/views/components/MarkdownViewer/MarkdownViewer.scss index aab68af1ea9..ac8ab83fedc 100644 --- a/packages/commonwealth/client/scripts/views/components/MarkdownViewer/MarkdownViewer.scss +++ b/packages/commonwealth/client/scripts/views/components/MarkdownViewer/MarkdownViewer.scss @@ -1,7 +1,34 @@ @import '../MarkdownEditor/layout'; +@import './layout'; .MarkdownViewer { .mdxeditor-root-contenteditable { outline: none; } + + .show-more-button { + align-items: center; + color: $primary-500; + cursor: pointer; + display: flex; + justify-content: space-between; + width: 115px; + margin-top: 100px; + + .show-more-text { + font-weight: 600; + margin-top: -1px; + } + } + + .show-more-button-wrapper { + align-items: center; + background-image: linear-gradient(to bottom, transparent, white); + display: flex; + height: 50px; + justify-content: center; + margin-top: -50px; + margin-bottom: 30px; + position: relative; + } } diff --git a/packages/commonwealth/client/scripts/views/components/MarkdownViewer/MarkdownViewer.tsx b/packages/commonwealth/client/scripts/views/components/MarkdownViewer/MarkdownViewer.tsx index ebe918cc451..4cb01a96e27 100644 --- a/packages/commonwealth/client/scripts/views/components/MarkdownViewer/MarkdownViewer.tsx +++ b/packages/commonwealth/client/scripts/views/components/MarkdownViewer/MarkdownViewer.tsx @@ -11,30 +11,44 @@ import { tablePlugin, thematicBreakPlugin, } from 'commonwealth-mdxeditor'; -import React, { memo } from 'react'; -import { useEditorErrorHandler } from 'views/components/MarkdownEditor/useEditorErrorHandler'; +import React, { memo, ReactNode, useState } from 'react'; +import { CWIcon } from 'views/components/component_kit/cw_icons/cw_icon'; +import { useMarkdownEditorErrorHandler } from 'views/components/MarkdownEditor/useMarkdownEditorErrorHandler'; import { codeBlockLanguages } from 'views/components/MarkdownEditor/utils/codeBlockLanguages'; +import { useComputeMarkdownWithCutoff } from 'views/components/MarkdownViewer/UseComputeMarkdownWithCutoff'; +import clsx from 'clsx'; import './MarkdownViewer.scss'; export type MarkdownStr = string; export type MarkdownViewerProps = Readonly<{ - markdown: MarkdownStr; + className?: string; + markdown: MarkdownStr | undefined; + cutoffLines?: number; + customShowMoreButton?: ReactNode; }>; export const MarkdownViewer = memo(function MarkdownViewer( props: MarkdownViewerProps, ) { - const { markdown } = props; + const { customShowMoreButton, className } = props; - const errorHandler = useEditorErrorHandler(); + const errorHandler = useMarkdownEditorErrorHandler(); + + const toggleDisplay = () => setUserExpand(!userExpand); + + const [truncated, truncatedMarkdown, initialMarkdown] = + useComputeMarkdownWithCutoff(props.markdown ?? '', props.cutoffLines); + + const [userExpand, setUserExpand] = useState(false); return ( -
+
+ + {truncated && !userExpand && ( + <> + {customShowMoreButton || ( +
+
+ +
Show More
+
+
+ )} + + )}
); }); diff --git a/packages/commonwealth/client/scripts/views/components/MarkdownViewer/UseComputeMarkdownWithCutoff.ts b/packages/commonwealth/client/scripts/views/components/MarkdownViewer/UseComputeMarkdownWithCutoff.ts new file mode 100644 index 00000000000..981f95d3b9f --- /dev/null +++ b/packages/commonwealth/client/scripts/views/components/MarkdownViewer/UseComputeMarkdownWithCutoff.ts @@ -0,0 +1,19 @@ +import { useMemo } from 'react'; +import { MarkdownStr } from 'views/components/MarkdownViewer/MarkdownViewer'; + +export function useComputeMarkdownWithCutoff( + markdown: MarkdownStr, + cutoffLines: number | undefined, +): [boolean, string, string] { + return useMemo(() => { + const lines = markdown.split('\n'); + + if (!cutoffLines || cutoffLines >= lines.length) { + return [false, markdown, markdown]; + } + + const head = lines.slice(0, cutoffLines); + + return [true, head.join('\n'), markdown]; + }, [cutoffLines, markdown]); +} diff --git a/packages/commonwealth/client/scripts/views/components/MarkdownViewer/layout.scss b/packages/commonwealth/client/scripts/views/components/MarkdownViewer/layout.scss new file mode 100644 index 00000000000..7dfc368486e --- /dev/null +++ b/packages/commonwealth/client/scripts/views/components/MarkdownViewer/layout.scss @@ -0,0 +1,26 @@ +@import '../../../../styles/shared'; + +// Basic layout for the HTML content that is rendered. + +.mdxeditor-root-contenteditable { + table { + width: 100%; + } + + th { + background-color: $neutral-100; + text-align: left; + } + + table { + border-spacing: 0; + border-collapse: collapse; + } + + th, + td { + padding: 8px; + border: 1px solid $neutral-300; + margin: 0; + } +} diff --git a/packages/commonwealth/client/scripts/views/components/MarkdownViewerWithFallback/MarkdownViewerWithFallback.tsx b/packages/commonwealth/client/scripts/views/components/MarkdownViewerWithFallback/MarkdownViewerWithFallback.tsx new file mode 100644 index 00000000000..ff9ae599fdc --- /dev/null +++ b/packages/commonwealth/client/scripts/views/components/MarkdownViewerWithFallback/MarkdownViewerWithFallback.tsx @@ -0,0 +1,42 @@ +import { useFlag } from 'hooks/useFlag'; +import React, { ReactNode } from 'react'; +import MarkdownViewer from 'views/components/MarkdownViewer'; +import { QuillRenderer } from 'views/components/react_quill_editor/quill_renderer'; + +type MarkdownViewerWithFallbackProps = { + readonly markdown: string | undefined; + readonly cutoffLines?: number; + readonly customShowMoreButton?: ReactNode; + readonly className?: string; +}; + +/** + * Temporary migration component that uses a feature toggle for viewing content. + */ +export const MarkdownViewerWithFallback = ( + props: MarkdownViewerWithFallbackProps, +) => { + const { markdown, cutoffLines, customShowMoreButton, className } = props; + + const newEditor = useFlag('newEditor'); + + if (newEditor) { + return ( + + ); + } + + return ( + + ); +}; diff --git a/packages/commonwealth/client/scripts/views/components/MarkdownViewerWithFallback/index.tsx b/packages/commonwealth/client/scripts/views/components/MarkdownViewerWithFallback/index.tsx new file mode 100644 index 00000000000..53b56f46b2c --- /dev/null +++ b/packages/commonwealth/client/scripts/views/components/MarkdownViewerWithFallback/index.tsx @@ -0,0 +1 @@ +export { MarkdownViewerWithFallback as default } from './MarkdownViewerWithFallback'; diff --git a/packages/commonwealth/client/scripts/views/components/NewThreadForm/NewThreadForm.tsx b/packages/commonwealth/client/scripts/views/components/NewThreadForm/NewThreadForm.tsx index ffc052bdc83..82d22fe5a7f 100644 --- a/packages/commonwealth/client/scripts/views/components/NewThreadForm/NewThreadForm.tsx +++ b/packages/commonwealth/client/scripts/views/components/NewThreadForm/NewThreadForm.tsx @@ -1,421 +1,14 @@ -import { buildCreateThreadInput } from 'client/scripts/state/api/threads/createThread'; -import { useAuthModalStore } from 'client/scripts/state/ui/modals'; -import { notifyError } from 'controllers/app/notifications'; -import { SessionKeyError } from 'controllers/server/sessions'; -import { parseCustomStages } from 'helpers'; -import { detectURL, getThreadActionTooltipText } from 'helpers/threads'; import { useFlag } from 'hooks/useFlag'; -import useJoinCommunityBanner from 'hooks/useJoinCommunityBanner'; -import { useCommonNavigate } from 'navigation/helpers'; -import React, { useEffect, useMemo, useState } from 'react'; -import { useLocation } from 'react-router-dom'; -import app from 'state'; -import { useGetUserEthBalanceQuery } from 'state/api/communityStake'; -import { - useFetchGroupsQuery, - useRefreshMembershipQuery, -} from 'state/api/groups'; -import { useCreateThreadMutation } from 'state/api/threads'; -import { useFetchTopicsQuery } from 'state/api/topics'; -import useUserStore from 'state/ui/user'; -import JoinCommunityBanner from 'views/components/JoinCommunityBanner'; -import CustomTopicOption from 'views/components/NewThreadForm/CustomTopicOption'; -import useJoinCommunity from 'views/components/SublayoutHeader/useJoinCommunity'; -import { CWIcon } from 'views/components/component_kit/cw_icons/cw_icon'; -import { CWButton } from 'views/components/component_kit/new_designs/CWButton'; -import CWPageLayout from 'views/components/component_kit/new_designs/CWPageLayout'; -import { CWTextInput } from 'views/components/component_kit/new_designs/CWTextInput'; -import { MessageRow } from 'views/components/component_kit/new_designs/CWTextInput/MessageRow'; -import useCommunityContests from 'views/pages/CommunityManagement/Contests/useCommunityContests'; -import useAppStatus from '../../../hooks/useAppStatus'; -import { ThreadKind, ThreadStage } from '../../../models/types'; -import Permissions from '../../../utils/Permissions'; -import { CWText } from '../../components/component_kit/cw_text'; -import { CWGatedTopicBanner } from '../component_kit/CWGatedTopicBanner'; -import { CWSelectList } from '../component_kit/new_designs/CWSelectList'; -import { ReactQuillEditor } from '../react_quill_editor'; -import { - createDeltaFromText, - getTextFromDelta, - serializeDelta, -} from '../react_quill_editor/utils'; -import ContestThreadBanner from './ContestThreadBanner'; -import ContestTopicBanner from './ContestTopicBanner'; -import './NewThreadForm.scss'; -import { checkNewThreadErrors, useNewThreadForm } from './helpers'; - -const MIN_ETH_FOR_CONTEST_THREAD = 0.0005; +import React from 'react'; +import { NewThreadForm as NewThreadFormLegacy } from '../NewThreadFormLegacy'; +import { NewThreadForm as NewThreadFormModern } from '../NewThreadFormModern'; export const NewThreadForm = () => { - const navigate = useCommonNavigate(); - const location = useLocation(); - const contestsEnabled = useFlag('contest'); - - const [submitEntryChecked, setSubmitEntryChecked] = useState(false); - - useAppStatus(); - - const communityId = app.activeChainId() || ''; - const { data: topics = [], refetch: refreshTopics } = useFetchTopicsQuery({ - communityId, - includeContestData: contestsEnabled, - apiEnabled: !!communityId, - }); - - const { isContestAvailable } = useCommunityContests(); - - const sortedTopics = [...topics].sort((a, b) => a.name.localeCompare(b.name)); - const hasTopics = sortedTopics?.length; - const isAdmin = Permissions.isCommunityAdmin() || Permissions.isSiteAdmin(); - const topicsForSelector = hasTopics ? sortedTopics : []; - - const { - threadTitle, - setThreadTitle, - threadKind, - threadTopic, - setThreadTopic, - threadUrl, - setThreadUrl, - threadContentDelta, - setThreadContentDelta, - setIsSaving, - isDisabled, - clearDraft, - canShowGatingBanner, - setCanShowGatingBanner, - } = useNewThreadForm(communityId, topicsForSelector); - - const hasTopicOngoingContest = threadTopic?.activeContestManagers?.length > 0; - - const user = useUserStore(); - const { checkForSessionKeyRevalidationErrors } = useAuthModalStore(); - - const contestTopicError = threadTopic?.activeContestManagers?.length - ? threadTopic?.activeContestManagers - ?.map( - (acm) => - acm?.content?.filter( - (c) => c.actor_address === user.activeAccount?.address, - ).length || 0, - ) - ?.every((n) => n >= 2) - : false; - - const { handleJoinCommunity, JoinCommunityModals } = useJoinCommunity(); - const { isBannerVisible, handleCloseBanner } = useJoinCommunityBanner(); - - const { data: groups = [] } = useFetchGroupsQuery({ - communityId, - includeTopics: true, - enabled: !!communityId, - }); - const { data: memberships = [] } = useRefreshMembershipQuery({ - communityId, - address: user.activeAccount?.address || '', - apiEnabled: !!user.activeAccount?.address && !!communityId, - }); - - const { mutateAsync: createThread } = useCreateThreadMutation({ - communityId, - }); - - const chainRpc = app?.chain?.meta?.ChainNode?.url || ''; - const ethChainId = app?.chain?.meta?.ChainNode?.eth_chain_id || 0; - - const { data: userEthBalance } = useGetUserEthBalanceQuery({ - chainRpc, - walletAddress: user.activeAccount?.address || '', - apiEnabled: - isContestAvailable && - !!user.activeAccount?.address && - Number(ethChainId) > 0, - ethChainId: ethChainId || 0, - }); - - const isDiscussion = threadKind === ThreadKind.Discussion; - - const isPopulated = useMemo(() => { - return threadTitle || getTextFromDelta(threadContentDelta).length > 0; - }, [threadContentDelta, threadTitle]); - - const isTopicGated = !!(memberships || []).find( - (membership) => - threadTopic?.id && membership.topicIds.includes(threadTopic.id), - ); - const isActionAllowedInGatedTopic = !!(memberships || []).find( - (membership) => - threadTopic?.id && - membership.topicIds.includes(threadTopic?.id) && - membership.isAllowed, - ); - const gatedGroupNames = groups - .filter((group) => - group.topics.find((topic) => topic.id === threadTopic?.id), - ) - .map((group) => group.name); - const isRestrictedMembership = - !isAdmin && isTopicGated && !isActionAllowedInGatedTopic; - - const handleNewThreadCreation = async () => { - if (isRestrictedMembership) { - notifyError('Topic is gated!'); - return; - } - - if (!isDiscussion && !detectURL(threadUrl)) { - notifyError('Must provide a valid URL.'); - return; - } - - const deltaString = JSON.stringify(threadContentDelta); - - checkNewThreadErrors( - { threadKind, threadUrl, threadTitle, threadTopic }, - deltaString, - !!hasTopics, - ); - - setIsSaving(true); - - try { - const input = await buildCreateThreadInput({ - address: user.activeAccount?.address || '', - kind: threadKind, - stage: app.chain.meta?.custom_stages - ? parseCustomStages(app.chain.meta?.custom_stages)[0] - : ThreadStage.Discussion, - communityId, - title: threadTitle, - topic: threadTopic, - body: serializeDelta(threadContentDelta), - url: threadUrl, - }); - const thread = await createThread(input); - - setThreadContentDelta(createDeltaFromText('')); - clearDraft(); - - navigate(`/discussion/${thread.id}`); - } catch (err) { - if (err instanceof SessionKeyError) { - checkForSessionKeyRevalidationErrors(err); - return; - } - - if (err?.message?.includes('limit')) { - notifyError( - 'Limit of submitted threads in selected contest has been exceeded.', - ); - return; - } - - console.error(err?.message); - notifyError('Failed to create thread'); - } finally { - setIsSaving(false); - } - }; - - const handleCancel = () => { - setThreadTitle(''); - setThreadTopic( - // @ts-expect-error - topicsForSelector?.find((t) => t?.name?.includes('General')) || null, - ); - setThreadContentDelta(createDeltaFromText('')); - }; - - const showBanner = !user.activeAccount && isBannerVisible; - const disabledActionsTooltipText = getThreadActionTooltipText({ - isCommunityMember: !!user.activeAccount, - isThreadTopicGated: isRestrictedMembership, - }); - - const contestThreadBannerVisible = - contestsEnabled && isContestAvailable && hasTopicOngoingContest; - const isDisabledBecauseOfContestsConsent = - contestThreadBannerVisible && !submitEntryChecked; - - const contestTopicAffordanceVisible = - contestsEnabled && isContestAvailable && hasTopicOngoingContest; - - const walletBalanceError = - isContestAvailable && - hasTopicOngoingContest && - parseFloat(userEthBalance || '0') < MIN_ETH_FOR_CONTEST_THREAD; - - useEffect(() => { - refreshTopics().catch(console.error); - }, [refreshTopics]); - - return ( - <> - -
- - Create thread - -
-
- setThreadTitle(e.target.value)} - /> - - {!!hasTopics && ( - - CustomTopicOption({ - originalProps, - topic: topicsForSelector.find( - (t) => String(t.id) === originalProps.data.value, - ), - }), - }} - formatOptionLabel={(option) => ( - <> - {contestTopicAffordanceVisible && ( - - )} - {option.label} - - )} - options={sortedTopics.map((topic) => ({ - label: topic?.name, - value: `${topic?.id}`, - }))} - {...(!!location.search && - threadTopic?.name && - threadTopic?.id && { - defaultValue: { - label: threadTopic?.name, - value: `${threadTopic?.id}`, - }, - })} - placeholder="Select topic" - customError={ - contestTopicError - ? 'Can no longer post in this topic while contest is active.' - : '' - } - onChange={(topic) => { - setCanShowGatingBanner(true); - setThreadTopic( - // @ts-expect-error - topicsForSelector.find((t) => `${t.id}` === topic.value), - ); - }} - /> - )} - - {contestTopicAffordanceVisible && ( - { - return { - name: acm?.contest_manager?.name, - address: acm?.contest_manager?.contest_address, - submittedEntries: - acm?.content?.filter( - (c) => - c.actor_address === user.activeAccount?.address, - ).length || 0, - }; - })} - /> - )} - - {!isDiscussion && ( - setThreadUrl(e.target.value)} - /> - )} - - - - {contestThreadBannerVisible && ( - - )} - - - -
- {isPopulated && user.activeAccount && ( - - )} - -
+ const newEditor = useFlag('newEditor'); - {showBanner && ( - - )} + if (newEditor) { + return ; + } - {isRestrictedMembership && canShowGatingBanner && ( -
- setCanShowGatingBanner(false)} - /> -
- )} -
-
-
-
- {JoinCommunityModals} - - ); + return ; }; diff --git a/packages/commonwealth/client/scripts/views/components/NewThreadForm/ContestThreadBanner/ContestThreadBanner.scss b/packages/commonwealth/client/scripts/views/components/NewThreadFormLegacy/ContestThreadBanner/ContestThreadBanner.scss similarity index 100% rename from packages/commonwealth/client/scripts/views/components/NewThreadForm/ContestThreadBanner/ContestThreadBanner.scss rename to packages/commonwealth/client/scripts/views/components/NewThreadFormLegacy/ContestThreadBanner/ContestThreadBanner.scss diff --git a/packages/commonwealth/client/scripts/views/components/NewThreadForm/ContestThreadBanner/ContestThreadBanner.tsx b/packages/commonwealth/client/scripts/views/components/NewThreadFormLegacy/ContestThreadBanner/ContestThreadBanner.tsx similarity index 100% rename from packages/commonwealth/client/scripts/views/components/NewThreadForm/ContestThreadBanner/ContestThreadBanner.tsx rename to packages/commonwealth/client/scripts/views/components/NewThreadFormLegacy/ContestThreadBanner/ContestThreadBanner.tsx diff --git a/packages/commonwealth/client/scripts/views/components/NewThreadForm/ContestThreadBanner/index.ts b/packages/commonwealth/client/scripts/views/components/NewThreadFormLegacy/ContestThreadBanner/index.ts similarity index 100% rename from packages/commonwealth/client/scripts/views/components/NewThreadForm/ContestThreadBanner/index.ts rename to packages/commonwealth/client/scripts/views/components/NewThreadFormLegacy/ContestThreadBanner/index.ts diff --git a/packages/commonwealth/client/scripts/views/components/NewThreadForm/ContestTopicBanner/ContestTopicBanner.scss b/packages/commonwealth/client/scripts/views/components/NewThreadFormLegacy/ContestTopicBanner/ContestTopicBanner.scss similarity index 100% rename from packages/commonwealth/client/scripts/views/components/NewThreadForm/ContestTopicBanner/ContestTopicBanner.scss rename to packages/commonwealth/client/scripts/views/components/NewThreadFormLegacy/ContestTopicBanner/ContestTopicBanner.scss diff --git a/packages/commonwealth/client/scripts/views/components/NewThreadForm/ContestTopicBanner/ContestTopicBanner.tsx b/packages/commonwealth/client/scripts/views/components/NewThreadFormLegacy/ContestTopicBanner/ContestTopicBanner.tsx similarity index 100% rename from packages/commonwealth/client/scripts/views/components/NewThreadForm/ContestTopicBanner/ContestTopicBanner.tsx rename to packages/commonwealth/client/scripts/views/components/NewThreadFormLegacy/ContestTopicBanner/ContestTopicBanner.tsx diff --git a/packages/commonwealth/client/scripts/views/components/NewThreadForm/ContestTopicBanner/index.ts b/packages/commonwealth/client/scripts/views/components/NewThreadFormLegacy/ContestTopicBanner/index.ts similarity index 100% rename from packages/commonwealth/client/scripts/views/components/NewThreadForm/ContestTopicBanner/index.ts rename to packages/commonwealth/client/scripts/views/components/NewThreadFormLegacy/ContestTopicBanner/index.ts diff --git a/packages/commonwealth/client/scripts/views/components/NewThreadForm/CustomTopicOption.tsx b/packages/commonwealth/client/scripts/views/components/NewThreadFormLegacy/CustomTopicOption.tsx similarity index 100% rename from packages/commonwealth/client/scripts/views/components/NewThreadForm/CustomTopicOption.tsx rename to packages/commonwealth/client/scripts/views/components/NewThreadFormLegacy/CustomTopicOption.tsx diff --git a/packages/commonwealth/client/scripts/views/components/NewThreadForm/NewThreadForm.scss b/packages/commonwealth/client/scripts/views/components/NewThreadFormLegacy/NewThreadForm.scss similarity index 100% rename from packages/commonwealth/client/scripts/views/components/NewThreadForm/NewThreadForm.scss rename to packages/commonwealth/client/scripts/views/components/NewThreadFormLegacy/NewThreadForm.scss diff --git a/packages/commonwealth/client/scripts/views/components/NewThreadFormLegacy/NewThreadForm.tsx b/packages/commonwealth/client/scripts/views/components/NewThreadFormLegacy/NewThreadForm.tsx new file mode 100644 index 00000000000..907a8e3dca9 --- /dev/null +++ b/packages/commonwealth/client/scripts/views/components/NewThreadFormLegacy/NewThreadForm.tsx @@ -0,0 +1,424 @@ +import { buildCreateThreadInput } from 'client/scripts/state/api/threads/createThread'; +import { useAuthModalStore } from 'client/scripts/state/ui/modals'; +import { notifyError } from 'controllers/app/notifications'; +import { SessionKeyError } from 'controllers/server/sessions'; +import { parseCustomStages } from 'helpers'; +import { detectURL, getThreadActionTooltipText } from 'helpers/threads'; +import { useFlag } from 'hooks/useFlag'; +import useJoinCommunityBanner from 'hooks/useJoinCommunityBanner'; +import { useCommonNavigate } from 'navigation/helpers'; +import React, { useEffect, useMemo, useState } from 'react'; +import { useLocation } from 'react-router-dom'; +import app from 'state'; +import { useGetUserEthBalanceQuery } from 'state/api/communityStake'; +import { + useFetchGroupsQuery, + useRefreshMembershipQuery, +} from 'state/api/groups'; +import { useCreateThreadMutation } from 'state/api/threads'; +import { useFetchTopicsQuery } from 'state/api/topics'; +import useUserStore from 'state/ui/user'; +import JoinCommunityBanner from 'views/components/JoinCommunityBanner'; +import CustomTopicOption from 'views/components/NewThreadFormLegacy/CustomTopicOption'; +import useJoinCommunity from 'views/components/SublayoutHeader/useJoinCommunity'; +import { CWIcon } from 'views/components/component_kit/cw_icons/cw_icon'; +import { CWButton } from 'views/components/component_kit/new_designs/CWButton'; +import CWPageLayout from 'views/components/component_kit/new_designs/CWPageLayout'; +import { CWTextInput } from 'views/components/component_kit/new_designs/CWTextInput'; +import { MessageRow } from 'views/components/component_kit/new_designs/CWTextInput/MessageRow'; +import useCommunityContests from 'views/pages/CommunityManagement/Contests/useCommunityContests'; +import useAppStatus from '../../../hooks/useAppStatus'; +import { ThreadKind, ThreadStage } from '../../../models/types'; +import Permissions from '../../../utils/Permissions'; +import { CWText } from '../../components/component_kit/cw_text'; +import { CWGatedTopicBanner } from '../component_kit/CWGatedTopicBanner'; +import { CWSelectList } from '../component_kit/new_designs/CWSelectList'; +import { ReactQuillEditor } from '../react_quill_editor'; +import { + createDeltaFromText, + getTextFromDelta, + serializeDelta, +} from '../react_quill_editor/utils'; +import ContestThreadBanner from './ContestThreadBanner'; +import ContestTopicBanner from './ContestTopicBanner'; +import './NewThreadForm.scss'; +import { checkNewThreadErrors, useNewThreadForm } from './helpers'; + +const MIN_ETH_FOR_CONTEST_THREAD = 0.0005; + +export const NewThreadForm = () => { + const navigate = useCommonNavigate(); + const location = useLocation(); + const contestsEnabled = useFlag('contest'); + + const [submitEntryChecked, setSubmitEntryChecked] = useState(false); + + useAppStatus(); + + const communityId = app.activeChainId() || ''; + const { data: topics = [], refetch: refreshTopics } = useFetchTopicsQuery({ + communityId, + includeContestData: contestsEnabled, + apiEnabled: !!communityId, + }); + + const { isContestAvailable } = useCommunityContests(); + + const sortedTopics = [...topics].sort((a, b) => a.name.localeCompare(b.name)); + const hasTopics = sortedTopics?.length; + const isAdmin = Permissions.isCommunityAdmin() || Permissions.isSiteAdmin(); + const topicsForSelector = hasTopics ? sortedTopics : []; + + const { + threadTitle, + setThreadTitle, + threadKind, + threadTopic, + setThreadTopic, + threadUrl, + setThreadUrl, + threadContentDelta, + setThreadContentDelta, + setIsSaving, + isDisabled, + clearDraft, + canShowGatingBanner, + setCanShowGatingBanner, + } = useNewThreadForm(communityId, topicsForSelector); + + const hasTopicOngoingContest = threadTopic?.activeContestManagers?.length > 0; + + const user = useUserStore(); + const { checkForSessionKeyRevalidationErrors } = useAuthModalStore(); + + const contestTopicError = threadTopic?.activeContestManagers?.length + ? threadTopic?.activeContestManagers + ?.map( + (acm) => + acm?.content?.filter( + (c) => c.actor_address === user.activeAccount?.address, + ).length || 0, + ) + ?.every((n) => n >= 2) + : false; + + const { handleJoinCommunity, JoinCommunityModals } = useJoinCommunity(); + const { isBannerVisible, handleCloseBanner } = useJoinCommunityBanner(); + + const { data: groups = [] } = useFetchGroupsQuery({ + communityId, + includeTopics: true, + enabled: !!communityId, + }); + const { data: memberships = [] } = useRefreshMembershipQuery({ + communityId, + address: user.activeAccount?.address || '', + apiEnabled: !!user.activeAccount?.address && !!communityId, + }); + + const { mutateAsync: createThread } = useCreateThreadMutation({ + communityId, + }); + + const chainRpc = app?.chain?.meta?.ChainNode?.url || ''; + const ethChainId = app?.chain?.meta?.ChainNode?.eth_chain_id || 0; + + const { data: userEthBalance } = useGetUserEthBalanceQuery({ + chainRpc, + walletAddress: user.activeAccount?.address || '', + apiEnabled: + isContestAvailable && + !!user.activeAccount?.address && + Number(ethChainId) > 0, + ethChainId: ethChainId || 0, + }); + + const isDiscussion = threadKind === ThreadKind.Discussion; + + const isPopulated = useMemo(() => { + return threadTitle || getTextFromDelta(threadContentDelta).length > 0; + }, [threadContentDelta, threadTitle]); + + const isTopicGated = !!(memberships || []).find( + (membership) => + threadTopic?.id && membership.topicIds.includes(threadTopic.id), + ); + const isActionAllowedInGatedTopic = !!(memberships || []).find( + (membership) => + threadTopic.id && + threadTopic?.id && + membership.topicIds.includes(threadTopic?.id) && + membership.isAllowed, + ); + const gatedGroupNames = groups + .filter((group) => + group.topics.find((topic) => topic.id === threadTopic?.id), + ) + .map((group) => group.name); + const isRestrictedMembership = + !isAdmin && isTopicGated && !isActionAllowedInGatedTopic; + + const handleNewThreadCreation = async () => { + if (isRestrictedMembership) { + notifyError('Topic is gated!'); + return; + } + + if (!isDiscussion && !detectURL(threadUrl)) { + notifyError('Must provide a valid URL.'); + return; + } + + const deltaString = JSON.stringify(threadContentDelta); + + checkNewThreadErrors( + { threadKind, threadUrl, threadTitle, threadTopic }, + deltaString, + !!hasTopics, + ); + + setIsSaving(true); + + try { + const input = await buildCreateThreadInput({ + address: user.activeAccount?.address || '', + kind: threadKind, + stage: app.chain.meta?.custom_stages + ? parseCustomStages(app.chain.meta?.custom_stages)[0] + : ThreadStage.Discussion, + communityId, + title: threadTitle, + topic: threadTopic, + body: serializeDelta(threadContentDelta), + url: threadUrl, + }); + const thread = await createThread(input); + + setThreadContentDelta(createDeltaFromText('')); + clearDraft(); + + navigate(`/discussion/${thread.id}`); + } catch (err) { + if (err instanceof SessionKeyError) { + checkForSessionKeyRevalidationErrors(err); + return; + } + + if (err?.message?.includes('limit')) { + notifyError( + 'Limit of submitted threads in selected contest has been exceeded.', + ); + return; + } + + console.error(err?.message); + notifyError('Failed to create thread'); + } finally { + setIsSaving(false); + } + }; + + const handleCancel = () => { + setThreadTitle(''); + setThreadTopic( + // @ts-expect-error + topicsForSelector?.find((t) => t?.name?.includes('General')) || null, + ); + setThreadContentDelta(createDeltaFromText('')); + }; + + const showBanner = !user.activeAccount && isBannerVisible; + const disabledActionsTooltipText = getThreadActionTooltipText({ + isCommunityMember: !!user.activeAccount, + isThreadTopicGated: isRestrictedMembership, + }); + + const contestThreadBannerVisible = + contestsEnabled && isContestAvailable && hasTopicOngoingContest; + const isDisabledBecauseOfContestsConsent = + contestThreadBannerVisible && !submitEntryChecked; + + const contestTopicAffordanceVisible = + contestsEnabled && isContestAvailable && hasTopicOngoingContest; + + const walletBalanceError = + isContestAvailable && + hasTopicOngoingContest && + parseFloat(userEthBalance || '0') < MIN_ETH_FOR_CONTEST_THREAD; + + useEffect(() => { + refreshTopics().catch(console.error); + }, [refreshTopics]); + + return ( + <> + +
+ + Create thread + +
+
+ setThreadTitle(e.target.value)} + /> + + {!!hasTopics && ( + + CustomTopicOption({ + originalProps, + topic: topicsForSelector.find( + (t) => String(t.id) === originalProps.data.value, + ), + }), + }} + formatOptionLabel={(option) => ( + <> + {contestTopicAffordanceVisible && ( + + )} + {option.label} + + )} + options={sortedTopics.map((topic) => ({ + label: topic?.name, + value: `${topic?.id}`, + }))} + {...(!!location.search && + threadTopic?.name && + threadTopic?.id && { + defaultValue: { + label: threadTopic?.name, + value: `${threadTopic?.id}`, + }, + })} + placeholder="Select topic" + customError={ + contestTopicError + ? 'Can no longer post in this topic while contest is active.' + : '' + } + onChange={(topic) => { + setCanShowGatingBanner(true); + setThreadTopic( + // @ts-expect-error + topicsForSelector.find((t) => `${t.id}` === topic.value), + ); + }} + /> + )} + + {contestTopicAffordanceVisible && ( + { + return { + name: acm?.contest_manager?.name, + address: acm?.contest_manager?.contest_address, + submittedEntries: + acm?.content?.filter( + (c) => + c.actor_address === user.activeAccount?.address, + ).length || 0, + }; + })} + /> + )} + + {!isDiscussion && ( + setThreadUrl(e.target.value)} + /> + )} + + + + {contestThreadBannerVisible && ( + + )} + + + +
+ {isPopulated && user.activeAccount && ( + + )} + +
+ + {showBanner && ( + + )} + + {isRestrictedMembership && canShowGatingBanner && ( +
+ setCanShowGatingBanner(false)} + /> +
+ )} +
+
+
+
+ {JoinCommunityModals} + + ); +}; diff --git a/packages/commonwealth/client/scripts/views/components/NewThreadForm/helpers/helpers.ts b/packages/commonwealth/client/scripts/views/components/NewThreadFormLegacy/helpers/helpers.ts similarity index 100% rename from packages/commonwealth/client/scripts/views/components/NewThreadForm/helpers/helpers.ts rename to packages/commonwealth/client/scripts/views/components/NewThreadFormLegacy/helpers/helpers.ts diff --git a/packages/commonwealth/client/scripts/views/components/NewThreadForm/helpers/index.ts b/packages/commonwealth/client/scripts/views/components/NewThreadFormLegacy/helpers/index.ts similarity index 100% rename from packages/commonwealth/client/scripts/views/components/NewThreadForm/helpers/index.ts rename to packages/commonwealth/client/scripts/views/components/NewThreadFormLegacy/helpers/index.ts diff --git a/packages/commonwealth/client/scripts/views/components/NewThreadForm/helpers/useNewThreadForm.ts b/packages/commonwealth/client/scripts/views/components/NewThreadFormLegacy/helpers/useNewThreadForm.ts similarity index 100% rename from packages/commonwealth/client/scripts/views/components/NewThreadForm/helpers/useNewThreadForm.ts rename to packages/commonwealth/client/scripts/views/components/NewThreadFormLegacy/helpers/useNewThreadForm.ts diff --git a/packages/commonwealth/client/scripts/views/components/NewThreadFormLegacy/index.ts b/packages/commonwealth/client/scripts/views/components/NewThreadFormLegacy/index.ts new file mode 100644 index 00000000000..b2deb89d14f --- /dev/null +++ b/packages/commonwealth/client/scripts/views/components/NewThreadFormLegacy/index.ts @@ -0,0 +1,3 @@ +import { NewThreadForm } from './NewThreadForm'; + +export { NewThreadForm }; diff --git a/packages/commonwealth/client/scripts/views/components/NewThreadForm/types.ts b/packages/commonwealth/client/scripts/views/components/NewThreadFormLegacy/types.ts similarity index 100% rename from packages/commonwealth/client/scripts/views/components/NewThreadForm/types.ts rename to packages/commonwealth/client/scripts/views/components/NewThreadFormLegacy/types.ts diff --git a/packages/commonwealth/client/scripts/views/components/NewThreadFormModern/ContestThreadBanner/ContestThreadBanner.scss b/packages/commonwealth/client/scripts/views/components/NewThreadFormModern/ContestThreadBanner/ContestThreadBanner.scss new file mode 100644 index 00000000000..cf8929dd46e --- /dev/null +++ b/packages/commonwealth/client/scripts/views/components/NewThreadFormModern/ContestThreadBanner/ContestThreadBanner.scss @@ -0,0 +1,27 @@ +@import '../../../../../styles/shared'; + +.ContestThreadBanner { + .content-container { + @include extraSmall { + flex-direction: column; + } + + .right-side { + display: flex; + padding-left: 64px; + margin-top: 8px; + + @include extraSmall { + padding-left: 0; + margin-left: unset !important; + } + + .banner-accessory-right { + display: flex; + align-items: center; + gap: 8px; + width: 120px; + } + } + } +} diff --git a/packages/commonwealth/client/scripts/views/components/NewThreadFormModern/ContestThreadBanner/ContestThreadBanner.tsx b/packages/commonwealth/client/scripts/views/components/NewThreadFormModern/ContestThreadBanner/ContestThreadBanner.tsx new file mode 100644 index 00000000000..db03500d598 --- /dev/null +++ b/packages/commonwealth/client/scripts/views/components/NewThreadFormModern/ContestThreadBanner/ContestThreadBanner.tsx @@ -0,0 +1,44 @@ +import React from 'react'; + +import { CWCheckbox } from 'views/components/component_kit/cw_checkbox'; +import { CWText } from 'views/components/component_kit/cw_text'; +import CWBanner from 'views/components/component_kit/new_designs/CWBanner'; +import { CONTEST_FAQ_URL } from 'views/pages/CommunityManagement/Contests/utils'; + +import './ContestThreadBanner.scss'; + +interface ContestThreadBannerProps { + submitEntryChecked: boolean; + onSetSubmitEntryChecked: (value: boolean) => void; +} + +const ContestThreadBanner = ({ + submitEntryChecked, + onSetSubmitEntryChecked, +}: ContestThreadBannerProps) => { + return ( + + Submit Entry + onSetSubmitEntryChecked(!submitEntryChecked)} + /> +
+ } + footer={ + + Learn more about contests + + } + /> + ); +}; + +export default ContestThreadBanner; diff --git a/packages/commonwealth/client/scripts/views/components/NewThreadFormModern/ContestThreadBanner/index.ts b/packages/commonwealth/client/scripts/views/components/NewThreadFormModern/ContestThreadBanner/index.ts new file mode 100644 index 00000000000..e2e65097b76 --- /dev/null +++ b/packages/commonwealth/client/scripts/views/components/NewThreadFormModern/ContestThreadBanner/index.ts @@ -0,0 +1,3 @@ +import ContestThreadBanner from './ContestThreadBanner'; + +export default ContestThreadBanner; diff --git a/packages/commonwealth/client/scripts/views/components/NewThreadFormModern/ContestTopicBanner/ContestTopicBanner.scss b/packages/commonwealth/client/scripts/views/components/NewThreadFormModern/ContestTopicBanner/ContestTopicBanner.scss new file mode 100644 index 00000000000..ae9ab7d0774 --- /dev/null +++ b/packages/commonwealth/client/scripts/views/components/NewThreadFormModern/ContestTopicBanner/ContestTopicBanner.scss @@ -0,0 +1,18 @@ +@import '../../../../../styles/shared'; + +.ContestTopicBanner { + .body { + ul { + list-style: initial; + margin-left: 32px; + line-height: 32px; + margin-bottom: 8px; + + li { + .Text.disabled { + color: $neutral-400; + } + } + } + } +} diff --git a/packages/commonwealth/client/scripts/views/components/NewThreadFormModern/ContestTopicBanner/ContestTopicBanner.tsx b/packages/commonwealth/client/scripts/views/components/NewThreadFormModern/ContestTopicBanner/ContestTopicBanner.tsx new file mode 100644 index 00000000000..0c104146ac1 --- /dev/null +++ b/packages/commonwealth/client/scripts/views/components/NewThreadFormModern/ContestTopicBanner/ContestTopicBanner.tsx @@ -0,0 +1,58 @@ +import clsx from 'clsx'; +import React from 'react'; + +import { CWText } from 'views/components/component_kit/cw_text'; +import CWBanner from 'views/components/component_kit/new_designs/CWBanner'; + +import { CONTEST_FAQ_URL } from 'views/pages/CommunityManagement/Contests/utils'; +import './ContestTopicBanner.scss'; + +interface ContestTopicBannerProps { + contests?: { + name?: string; + address?: string; + submittedEntries?: number; + }[]; +} + +const ContestTopicBanner = ({ contests }: ContestTopicBannerProps) => { + return ( + + Your thread will be submitted to the following contests: +
    + {contests?.map((contest) => { + const disabled = (contest?.submittedEntries || 0) >= 2; + return ( +
  • + + {contest.name}{' '} + {disabled && '(Reached max of 2 entries per contest)'} + +
  • + ); + })} +
+ Once a post is submitted it cannot be edited. The post with the most + upvotes will win the contest prize. +
+ } + type="info" + footer={ + + Learn more about contests + + } + /> + ); +}; + +export default ContestTopicBanner; diff --git a/packages/commonwealth/client/scripts/views/components/NewThreadFormModern/ContestTopicBanner/index.ts b/packages/commonwealth/client/scripts/views/components/NewThreadFormModern/ContestTopicBanner/index.ts new file mode 100644 index 00000000000..1996ce59730 --- /dev/null +++ b/packages/commonwealth/client/scripts/views/components/NewThreadFormModern/ContestTopicBanner/index.ts @@ -0,0 +1,3 @@ +import ContestTopicBanner from './ContestTopicBanner'; + +export default ContestTopicBanner; diff --git a/packages/commonwealth/client/scripts/views/components/NewThreadFormModern/CustomTopicOption.tsx b/packages/commonwealth/client/scripts/views/components/NewThreadFormModern/CustomTopicOption.tsx new file mode 100644 index 00000000000..ab695f2a11d --- /dev/null +++ b/packages/commonwealth/client/scripts/views/components/NewThreadFormModern/CustomTopicOption.tsx @@ -0,0 +1,26 @@ +import Topic from 'models/Topic'; +import React from 'react'; +import { components, OptionProps } from 'react-select'; +import { CWIcon } from 'views/components/component_kit/cw_icons/cw_icon'; + +interface CustomTopicOptionProps { + originalProps: OptionProps<{ value: string; label: string }>; + topic?: Topic; +} + +const CustomTopicOption = ({ + originalProps, + topic, +}: CustomTopicOptionProps) => { + return ( + // @ts-expect-error + + {(topic?.activeContestManagers?.length || 0) > 0 && ( + + )} + {originalProps.label} + + ); +}; + +export default CustomTopicOption; diff --git a/packages/commonwealth/client/scripts/views/components/NewThreadFormModern/NewThreadForm.scss b/packages/commonwealth/client/scripts/views/components/NewThreadFormModern/NewThreadForm.scss new file mode 100644 index 00000000000..9c84b3a2cb2 --- /dev/null +++ b/packages/commonwealth/client/scripts/views/components/NewThreadFormModern/NewThreadForm.scss @@ -0,0 +1,144 @@ +@import '../../../../styles/shared'; + +.NewThreadForm { + display: flex; + flex: 1; + flex-direction: column; + width: 100%; + + .no-pad { + padding: 0 !important; + } + + & > .header { + padding-bottom: 24px; + } + + @include mediumSmallInclusive { + width: 100%; + } + + .topic-select { + .cwsl__option { + justify-content: start !important; + gap: 4px; + } + .trophy-icon { + margin-bottom: -2px; + margin-right: 4px; + } + } + + .new-thread-header { + display: flex; + justify-content: space-between; + margin-bottom: 8px; + } + + .new-thread-body { + display: flex; + gap: 16px; + justify-content: space-between; + width: 100%; + + @include smallInclusive { + flex-direction: column; + } + + .new-thread-form-inputs { + display: flex; + flex-direction: column; + gap: 16px; + width: 100%; + + .set-display-name-callout { + background-color: $purple-50; + border-radius: $border-radius-corners; + display: flex; + flex-direction: column; + gap: 8px; + margin-bottom: 16px; + padding: 16px; + width: fit-content; + } + + .draft-text.Text { + background-color: $rorange-50; + border-radius: $border-radius-rounded-corners; + color: $rorange-500; + margin-bottom: 16px; + padding: 4px 16px; + width: fit-content; + } + + .topics-and-title-row { + display: flex; + flex-wrap: wrap; + gap: 8px; + + @include mediumInclusive { + .Button { + flex: 1; + } + } + + .TextInput { + flex: 2; + } + } + + .buttons-row { + display: flex; + gap: 8px; + justify-content: end; + } + } + } + + .drafts-list-container { + display: flex; + flex-direction: column; + + .drafts-list-title-text.Text { + border-bottom: 2px solid $primary-500; + margin: 0 0 8px 16px; + width: fit-content; + } + + .drafts-list { + display: flex; + flex-direction: column; + height: 360px; + overflow-y: auto; + width: 200px; + + @include visibleScrollbar(light); + + .draft-item { + cursor: pointer; + display: flex; + flex-direction: column; + gap: 4px; + padding: 8px 16px; + + &:hover { + background-color: $neutral-50; + } + + .draft-title { + align-items: center; + display: flex; + gap: 4px; + } + + .draft-delete-text { + color: $rorange-500; + } + } + } + } + + .JoinCommunityBanner { + margin-top: 8px; + } +} diff --git a/packages/commonwealth/client/scripts/views/components/NewThreadFormModern/NewThreadForm.tsx b/packages/commonwealth/client/scripts/views/components/NewThreadFormModern/NewThreadForm.tsx new file mode 100644 index 00000000000..a49282fa545 --- /dev/null +++ b/packages/commonwealth/client/scripts/views/components/NewThreadFormModern/NewThreadForm.tsx @@ -0,0 +1,399 @@ +import { buildCreateThreadInput } from 'client/scripts/state/api/threads/createThread'; +import { useAuthModalStore } from 'client/scripts/state/ui/modals'; +import { notifyError } from 'controllers/app/notifications'; +import { SessionKeyError } from 'controllers/server/sessions'; +import { parseCustomStages } from 'helpers'; +import { detectURL, getThreadActionTooltipText } from 'helpers/threads'; +import { useFlag } from 'hooks/useFlag'; +import useJoinCommunityBanner from 'hooks/useJoinCommunityBanner'; +import { useCommonNavigate } from 'navigation/helpers'; +import React, { useEffect, useRef, useState } from 'react'; +import { useLocation } from 'react-router-dom'; +import app from 'state'; +import { useGetUserEthBalanceQuery } from 'state/api/communityStake'; +import { + useFetchGroupsQuery, + useRefreshMembershipQuery, +} from 'state/api/groups'; +import { useCreateThreadMutation } from 'state/api/threads'; +import { useFetchTopicsQuery } from 'state/api/topics'; +import useUserStore from 'state/ui/user'; +import JoinCommunityBanner from 'views/components/JoinCommunityBanner'; +import MarkdownEditor from 'views/components/MarkdownEditor'; +import { MarkdownSubmitButton } from 'views/components/MarkdownEditor/MarkdownSubmitButton'; +import { MarkdownEditorMethods } from 'views/components/MarkdownEditor/useMarkdownEditorMethods'; +import CustomTopicOption from 'views/components/NewThreadFormLegacy/CustomTopicOption'; +import useJoinCommunity from 'views/components/SublayoutHeader/useJoinCommunity'; +import { CWIcon } from 'views/components/component_kit/cw_icons/cw_icon'; +import CWPageLayout from 'views/components/component_kit/new_designs/CWPageLayout'; +import { CWTextInput } from 'views/components/component_kit/new_designs/CWTextInput'; +import { MessageRow } from 'views/components/component_kit/new_designs/CWTextInput/MessageRow'; +import useCommunityContests from 'views/pages/CommunityManagement/Contests/useCommunityContests'; +import useAppStatus from '../../../hooks/useAppStatus'; +import { ThreadKind, ThreadStage } from '../../../models/types'; +import Permissions from '../../../utils/Permissions'; +import { CWText } from '../../components/component_kit/cw_text'; +import { CWGatedTopicBanner } from '../component_kit/CWGatedTopicBanner'; +import { CWSelectList } from '../component_kit/new_designs/CWSelectList'; +import ContestThreadBanner from './ContestThreadBanner'; +import ContestTopicBanner from './ContestTopicBanner'; +import './NewThreadForm.scss'; +import { checkNewThreadErrors, useNewThreadForm } from './helpers'; + +const MIN_ETH_FOR_CONTEST_THREAD = 0.0005; + +export const NewThreadForm = () => { + const navigate = useCommonNavigate(); + const location = useLocation(); + const contestsEnabled = useFlag('contest'); + + const markdownEditorMethodsRef = useRef(null); + + const [submitEntryChecked, setSubmitEntryChecked] = useState(false); + + useAppStatus(); + + const communityId = app.activeChainId() || ''; + const { data: topics = [], refetch: refreshTopics } = useFetchTopicsQuery({ + communityId, + includeContestData: contestsEnabled, + apiEnabled: !!communityId, + }); + + const { isContestAvailable } = useCommunityContests(); + + const sortedTopics = [...topics].sort((a, b) => a.name.localeCompare(b.name)); + const hasTopics = sortedTopics?.length; + const isAdmin = Permissions.isCommunityAdmin() || Permissions.isSiteAdmin(); + const topicsForSelector = hasTopics ? sortedTopics : []; + + const { + threadTitle, + setThreadTitle, + threadKind, + threadTopic, + setThreadTopic, + threadUrl, + setThreadUrl, + setEditorText, + setIsSaving, + isDisabled, + clearDraft, + canShowGatingBanner, + setCanShowGatingBanner, + } = useNewThreadForm(communityId, topicsForSelector); + + const hasTopicOngoingContest = threadTopic?.activeContestManagers?.length > 0; + + const user = useUserStore(); + const { checkForSessionKeyRevalidationErrors } = useAuthModalStore(); + + const contestTopicError = threadTopic?.activeContestManagers?.length + ? threadTopic?.activeContestManagers + ?.map( + (acm) => + acm?.content?.filter( + (c) => c.actor_address === user.activeAccount?.address, + ).length || 0, + ) + ?.every((n) => n >= 2) + : false; + + const { handleJoinCommunity, JoinCommunityModals } = useJoinCommunity(); + const { isBannerVisible, handleCloseBanner } = useJoinCommunityBanner(); + + const { data: groups = [] } = useFetchGroupsQuery({ + communityId, + includeTopics: true, + enabled: !!communityId, + }); + const { data: memberships = [] } = useRefreshMembershipQuery({ + communityId, + address: user.activeAccount?.address || '', + apiEnabled: !!user.activeAccount?.address && !!communityId, + }); + + const { mutateAsync: createThread } = useCreateThreadMutation({ + communityId, + }); + + const chainRpc = app?.chain?.meta?.ChainNode?.url || ''; + const ethChainId = app?.chain?.meta?.ChainNode?.eth_chain_id || 0; + + const { data: userEthBalance } = useGetUserEthBalanceQuery({ + chainRpc, + walletAddress: user.activeAccount?.address || '', + apiEnabled: + isContestAvailable && + !!user.activeAccount?.address && + Number(ethChainId) > 0, + ethChainId: ethChainId || 0, + }); + + const isDiscussion = threadKind === ThreadKind.Discussion; + + const isTopicGated = !!(memberships || []).find( + (membership) => + threadTopic?.id && membership.topicIds.includes(threadTopic.id), + ); + const isActionAllowedInGatedTopic = !!(memberships || []).find( + (membership) => + threadTopic.id && + threadTopic?.id && + membership.topicIds.includes(threadTopic?.id) && + membership.isAllowed, + ); + const gatedGroupNames = groups + .filter((group) => + group.topics.find((topic) => topic.id === threadTopic?.id), + ) + .map((group) => group.name); + const isRestrictedMembership = + !isAdmin && isTopicGated && !isActionAllowedInGatedTopic; + + const handleNewThreadCreation = async () => { + const body = markdownEditorMethodsRef.current!.getMarkdown(); + + if (isRestrictedMembership) { + notifyError('Topic is gated!'); + return; + } + + if (!isDiscussion && !detectURL(threadUrl)) { + notifyError('Must provide a valid URL.'); + return; + } + + checkNewThreadErrors( + { threadKind, threadUrl, threadTitle, threadTopic }, + body, + !!hasTopics, + ); + + setIsSaving(true); + + try { + const input = await buildCreateThreadInput({ + address: user.activeAccount?.address || '', + kind: threadKind, + stage: app.chain.meta?.custom_stages + ? parseCustomStages(app.chain.meta?.custom_stages)[0] + : ThreadStage.Discussion, + communityId, + title: threadTitle, + topic: threadTopic, + body, + url: threadUrl, + }); + const thread = await createThread(input); + + setEditorText(''); + clearDraft(); + + navigate(`/discussion/${thread.id}`); + } catch (err) { + if (err instanceof SessionKeyError) { + checkForSessionKeyRevalidationErrors(err); + return; + } + + if (err?.message?.includes('limit')) { + notifyError( + 'Limit of submitted threads in selected contest has been exceeded.', + ); + return; + } + + console.error(err?.message); + notifyError('Failed to create thread'); + } finally { + setIsSaving(false); + } + }; + + const showBanner = !user.activeAccount && isBannerVisible; + const disabledActionsTooltipText = getThreadActionTooltipText({ + isCommunityMember: !!user.activeAccount, + isThreadTopicGated: isRestrictedMembership, + }); + + const contestThreadBannerVisible = + contestsEnabled && isContestAvailable && hasTopicOngoingContest; + const isDisabledBecauseOfContestsConsent = + contestThreadBannerVisible && !submitEntryChecked; + + const contestTopicAffordanceVisible = + contestsEnabled && isContestAvailable && hasTopicOngoingContest; + + const walletBalanceError = + isContestAvailable && + hasTopicOngoingContest && + parseFloat(userEthBalance || '0') < MIN_ETH_FOR_CONTEST_THREAD; + + useEffect(() => { + refreshTopics().catch(console.error); + }, [refreshTopics]); + + return ( + <> + +
+ + Create thread + +
+
+ setThreadTitle(e.target.value)} + /> + + {!!hasTopics && ( + + CustomTopicOption({ + originalProps, + topic: topicsForSelector.find( + (t) => String(t.id) === originalProps.data.value, + ), + }), + }} + formatOptionLabel={(option) => ( + <> + {contestTopicAffordanceVisible && ( + + )} + {option.label} + + )} + options={sortedTopics.map((topic) => ({ + label: topic?.name, + value: `${topic?.id}`, + }))} + {...(!!location.search && + threadTopic?.name && + threadTopic?.id && { + defaultValue: { + label: threadTopic?.name, + value: `${threadTopic?.id}`, + }, + })} + placeholder="Select topic" + customError={ + contestTopicError + ? 'Can no longer post in this topic while contest is active.' + : '' + } + onChange={(topic) => { + setCanShowGatingBanner(true); + setThreadTopic( + // @ts-expect-error + topicsForSelector.find((t) => `${t.id}` === topic.value), + ); + }} + /> + )} + + {contestTopicAffordanceVisible && ( + { + return { + name: acm?.contest_manager?.name, + address: acm?.contest_manager?.contest_address, + submittedEntries: + acm?.content?.filter( + (c) => + c.actor_address === user.activeAccount?.address, + ).length || 0, + }; + })} + /> + )} + + {!isDiscussion && ( + setThreadUrl(e.target.value)} + /> + )} + + + (markdownEditorMethodsRef.current = methods) + } + onChange={(markdown) => setEditorText(markdown)} + disabled={isRestrictedMembership || !user.activeAccount} + tooltip={ + typeof disabledActionsTooltipText === 'function' + ? disabledActionsTooltipText?.('submit') + : disabledActionsTooltipText + } + placeholder="Enter text or drag images and media here. Use the tab button to see your formatted post." + SubmitButton={() => ( + + )} + /> + + {contestThreadBannerVisible && ( + + )} + + + + {showBanner && ( + + )} + + {isRestrictedMembership && canShowGatingBanner && ( +
+ setCanShowGatingBanner(false)} + /> +
+ )} +
+
+
+
+ {JoinCommunityModals} + + ); +}; diff --git a/packages/commonwealth/client/scripts/views/components/NewThreadFormModern/helpers/helpers.ts b/packages/commonwealth/client/scripts/views/components/NewThreadFormModern/helpers/helpers.ts new file mode 100644 index 00000000000..93b6b1ef39c --- /dev/null +++ b/packages/commonwealth/client/scripts/views/components/NewThreadFormModern/helpers/helpers.ts @@ -0,0 +1,46 @@ +import { notifyError } from 'controllers/app/notifications'; +import { Contest } from 'views/pages/CommunityManagement/Contests/ContestsList'; +import { isContestActive } from 'views/pages/CommunityManagement/Contests/utils'; +import { ThreadKind } from '../../../../models/types'; +import type { NewThreadFormType } from '../types'; +import { NewThreadErrors } from '../types'; + +export const checkNewThreadErrors = ( + { threadTitle, threadKind, threadTopic, threadUrl }: NewThreadFormType, + bodyText?: string, + hasTopics?: boolean, +) => { + if (!threadTitle) { + return notifyError(NewThreadErrors.NoTitle); + } + + if (!threadTopic && hasTopics) { + return notifyError(NewThreadErrors.NoTopic); + } + + // @ts-expect-error StrictNullChecks + if (threadKind === ThreadKind.Discussion && !bodyText.length) { + return notifyError(NewThreadErrors.NoBody); + } else if (threadKind === ThreadKind.Link && !threadUrl) { + return notifyError(NewThreadErrors.NoUrl); + } +}; + +export const checkIsTopicInContest = ( + data: Contest[], + topicId?: number, + checkOnlyActiveContest = false, +) => { + if (!topicId) { + return false; + } + + return (data || []) + .filter((item) => + checkOnlyActiveContest ? isContestActive({ contest: item }) : true, + ) + .some( + (item) => + item?.topics && item?.topics.some((topic) => topic?.id === topicId), + ); +}; diff --git a/packages/commonwealth/client/scripts/views/components/NewThreadFormModern/helpers/index.ts b/packages/commonwealth/client/scripts/views/components/NewThreadFormModern/helpers/index.ts new file mode 100644 index 00000000000..54b113b7512 --- /dev/null +++ b/packages/commonwealth/client/scripts/views/components/NewThreadFormModern/helpers/index.ts @@ -0,0 +1,4 @@ +import { checkIsTopicInContest, checkNewThreadErrors } from './helpers'; +import useNewThreadForm from './useNewThreadForm'; + +export { checkIsTopicInContest, checkNewThreadErrors, useNewThreadForm }; diff --git a/packages/commonwealth/client/scripts/views/components/NewThreadFormModern/helpers/useNewThreadForm.ts b/packages/commonwealth/client/scripts/views/components/NewThreadFormModern/helpers/useNewThreadForm.ts new file mode 100644 index 00000000000..7bef182947f --- /dev/null +++ b/packages/commonwealth/client/scripts/views/components/NewThreadFormModern/helpers/useNewThreadForm.ts @@ -0,0 +1,119 @@ +import { useEffect, useMemo, useState } from 'react'; + +import { useDraft } from 'hooks/useDraft'; +import { useSearchParams } from 'react-router-dom'; +import type Topic from '../../../../models/Topic'; +import { ThreadKind } from '../../../../models/types'; + +type NewThreadDraft = { + topicId: number; + title: string; + body: string; +}; + +const useNewThreadForm = (communityId: string, topicsForSelector: Topic[]) => { + const [searchParams] = useSearchParams(); + const topicIdFromUrl: number = parseInt(searchParams.get('topic') || '0'); + + const { saveDraft, restoreDraft, clearDraft } = useDraft( + `new-thread-${communityId}-info`, + { keyVersion: 'v3' }, + ); + const [canShowGatingBanner, setCanShowGatingBanner] = useState(true); + + // get restored draft on init + const restoredDraft: NewThreadDraft | null = useMemo(() => { + if (!topicsForSelector.length || topicIdFromUrl === 0) { + return null; + } + return restoreDraft(); + }, [restoreDraft, topicsForSelector, topicIdFromUrl]); + + const defaultTopic = useMemo(() => { + return ( + topicsForSelector.find( + (t) => + t.id === restoredDraft?.topicId || + (topicIdFromUrl && t.id === topicIdFromUrl), + ) || + topicsForSelector.find((t) => t.name.includes('General')) || + null + ); + }, [restoredDraft, topicsForSelector, topicIdFromUrl]); + + const [threadKind, setThreadKind] = useState( + ThreadKind.Discussion, + ); + const [threadUrl, setThreadUrl] = useState(''); + // @ts-expect-error StrictNullChecks + const [threadTopic, setThreadTopic] = useState(defaultTopic); + const [threadTitle, setThreadTitle] = useState(restoredDraft?.title || ''); + const [editorText, setEditorText] = useState( + restoredDraft?.body ?? '', + ); + const [isSaving, setIsSaving] = useState(false); + + const isDiscussion = threadKind === ThreadKind.Discussion; + const disableSave = isSaving; + const hasTopics = !!topicsForSelector?.length; + const topicMissing = hasTopics && !threadTopic; + const titleMissing = !threadTitle; + const linkContentMissing = !isDiscussion && !threadUrl; + const contentMissing = editorText.length === 0; + + const isDisabled = + disableSave || + titleMissing || + topicMissing || + linkContentMissing || + contentMissing; + + // on content updated, save draft + useEffect(() => { + const draft = { + topicId: threadTopic?.id || 0, + title: threadTitle, + body: editorText, + }; + if (!draft.topicId && !draft.title && !draft.body) { + return; + } + saveDraft(draft); + + if (!editorText && threadTopic?.defaultOffchainTemplate) { + try { + const template = JSON.parse( + threadTopic.defaultOffchainTemplate, + ) as string; + setEditorText(template); + } catch (e) { + console.log(e); + } + } + + if (!threadTopic && defaultTopic) { + setThreadTopic(defaultTopic); + } + }, [saveDraft, threadTopic, threadTitle, defaultTopic, editorText]); + + return { + threadKind, + setThreadKind, + threadTitle, + setThreadTitle, + threadTopic, + setThreadTopic, + threadUrl, + setThreadUrl, + editorText, + setEditorText, + isSaving, + setIsSaving, + isDisabled, + clearDraft, + canShowGatingBanner, + setCanShowGatingBanner, + }; +}; + +export default useNewThreadForm; diff --git a/packages/commonwealth/client/scripts/views/components/NewThreadFormModern/index.ts b/packages/commonwealth/client/scripts/views/components/NewThreadFormModern/index.ts new file mode 100644 index 00000000000..b2deb89d14f --- /dev/null +++ b/packages/commonwealth/client/scripts/views/components/NewThreadFormModern/index.ts @@ -0,0 +1,3 @@ +import { NewThreadForm } from './NewThreadForm'; + +export { NewThreadForm }; diff --git a/packages/commonwealth/client/scripts/views/components/NewThreadFormModern/types.ts b/packages/commonwealth/client/scripts/views/components/NewThreadFormModern/types.ts new file mode 100644 index 00000000000..20afa7863e4 --- /dev/null +++ b/packages/commonwealth/client/scripts/views/components/NewThreadFormModern/types.ts @@ -0,0 +1,16 @@ +import type Topic from '../../../models/Topic'; +import type { ThreadKind } from '../../../models/types'; + +export type NewThreadFormType = { + threadKind: ThreadKind; + threadTitle: string; + threadTopic: Topic; + threadUrl?: string; +}; + +export enum NewThreadErrors { + NoBody = 'Thread body cannot be blank', + NoTopic = 'Thread must have a topic', + NoTitle = 'Title cannot be blank', + NoUrl = 'URL cannot be blank', +} diff --git a/packages/commonwealth/client/scripts/views/components/Profile/ProfileActivityRow.tsx b/packages/commonwealth/client/scripts/views/components/Profile/ProfileActivityRow.tsx index cb66b0c2524..1c2df2f9eef 100644 --- a/packages/commonwealth/client/scripts/views/components/Profile/ProfileActivityRow.tsx +++ b/packages/commonwealth/client/scripts/views/components/Profile/ProfileActivityRow.tsx @@ -9,11 +9,11 @@ import withRouter, { useCommonNavigate, } from 'navigation/helpers'; import { useGetCommunityByIdQuery } from 'state/api/communities'; +import { MarkdownViewerWithFallback } from 'views/components/MarkdownViewerWithFallback/MarkdownViewerWithFallback'; import { PopoverMenu } from 'views/components/component_kit/CWPopoverMenu'; import { CWIconButton } from '../component_kit/cw_icon_button'; import { CWText } from '../component_kit/cw_text'; import { CWTag } from '../component_kit/new_designs/CWTag'; -import { QuillRenderer } from '../react_quill_editor/quill_renderer'; import type { CommentWithAssociatedThread } from './ProfileActivity'; type ProfileActivityRowProps = { @@ -23,7 +23,8 @@ type ProfileActivityRowProps = { const ProfileActivityRow = ({ activity }: ProfileActivityRowProps) => { const navigate = useCommonNavigate(); const { communityId, createdAt, author, id } = activity; - let title: string, body: string; + let title: string; + let body: string = ''; if (activity instanceof Thread) { title = activity.title; body = activity.body; @@ -138,8 +139,9 @@ const ProfileActivityRow = ({ activity }: ProfileActivityRowProps) => {
- {/* @ts-expect-error StrictNullChecks*/} - +
{
Bio - +
)} diff --git a/packages/commonwealth/client/scripts/views/components/UpvoteDrawer/ViewUpvotesDrawer/ViewUpvotesDrawer.tsx b/packages/commonwealth/client/scripts/views/components/UpvoteDrawer/ViewUpvotesDrawer/ViewUpvotesDrawer.tsx index d27f3f6a4fc..5e6bda9e03c 100644 --- a/packages/commonwealth/client/scripts/views/components/UpvoteDrawer/ViewUpvotesDrawer/ViewUpvotesDrawer.tsx +++ b/packages/commonwealth/client/scripts/views/components/UpvoteDrawer/ViewUpvotesDrawer/ViewUpvotesDrawer.tsx @@ -4,6 +4,7 @@ import AddressInfo from 'models/AddressInfo'; import MinimumProfile from 'models/MinimumProfile'; import React, { Dispatch, SetStateAction } from 'react'; import app from 'state'; +import { MarkdownViewerWithFallback } from 'views/components/MarkdownViewerWithFallback/MarkdownViewerWithFallback'; import { User } from 'views/components/user/user'; import { AuthorAndPublishInfo } from '../../../pages/discussions/ThreadCard/AuthorAndPublishInfo'; import { CWText } from '../../component_kit/cw_text'; @@ -13,7 +14,6 @@ import CWDrawer, { import { CWTable } from '../../component_kit/new_designs/CWTable'; import { CWTableColumnInfo } from '../../component_kit/new_designs/CWTable/CWTable'; import { useCWTableState } from '../../component_kit/new_designs/CWTable/useCWTableState'; -import { QuillRenderer } from '../../react_quill_editor/quill_renderer'; import './ViewUpvotesDrawer.scss'; type Profile = Account | AddressInfo | MinimumProfile; @@ -142,7 +142,10 @@ export const ViewUpvotesDrawer = ({ />
- +
{reactorData?.length > 0 && isOpen ? ( diff --git a/packages/commonwealth/client/scripts/views/components/collapsible_body_text.tsx b/packages/commonwealth/client/scripts/views/components/collapsible_body_text.tsx index bf70a491b29..c51a503a68d 100644 --- a/packages/commonwealth/client/scripts/views/components/collapsible_body_text.tsx +++ b/packages/commonwealth/client/scripts/views/components/collapsible_body_text.tsx @@ -1,6 +1,5 @@ import React from 'react'; - -import { QuillRenderer } from './react_quill_editor/quill_renderer'; +import { MarkdownViewerWithFallback } from 'views/components/MarkdownViewerWithFallback/MarkdownViewerWithFallback'; type CollapsibleProposalBodyProps = { doc: string; @@ -9,5 +8,5 @@ type CollapsibleProposalBodyProps = { export const CollapsibleProposalBody = ({ doc, }: CollapsibleProposalBodyProps) => { - return ; + return ; }; diff --git a/packages/commonwealth/client/scripts/views/components/component_kit/cw_icons/cw_icon_lookup.ts b/packages/commonwealth/client/scripts/views/components/component_kit/cw_icons/cw_icon_lookup.ts index 4cd86a20937..b9f88a4c94a 100644 --- a/packages/commonwealth/client/scripts/views/components/component_kit/cw_icons/cw_icon_lookup.ts +++ b/packages/commonwealth/client/scripts/views/components/component_kit/cw_icons/cw_icon_lookup.ts @@ -24,6 +24,7 @@ import { CheckCircle, CircleNotch, CirclesThreePlus, + Clipboard, ClockCounterClockwise, CloudArrowUp, Code, @@ -32,12 +33,14 @@ import { Copy, DotsThreeVertical, Download, + DownloadSimple, Export, Eye, Flag, FunnelSimple, Heart, House, + Image, ImageSquare, Lightbulb, Link, @@ -102,6 +105,8 @@ export const iconLookup = { h3: withPhosphorIcon(TextHThree), quotes: withPhosphorIcon(Quotes), table: withPhosphorIcon(Table), + image: withPhosphorIcon(Image), + clipboard: withPhosphorIcon(Clipboard), cloudArrowUp: withPhosphorIcon(CloudArrowUp), archiveTrayFilled: Icons.CWArchiveTrayFilled, arrowDownBlue500: Icons.CWArrowDownBlue500, @@ -274,6 +279,7 @@ export const iconLookup = { write: Icons.CWWrite, members: Icons.CWMembers, download: withPhosphorIcon(Download), + downloadSimple: withPhosphorIcon(DownloadSimple), }; export const customIconLookup = { diff --git a/packages/commonwealth/client/scripts/views/components/component_kit/new_designs/CWSearchBar/SearchBarCommentPreviewRow.tsx b/packages/commonwealth/client/scripts/views/components/component_kit/new_designs/CWSearchBar/SearchBarCommentPreviewRow.tsx index 686c7835857..153a2c9ea3f 100644 --- a/packages/commonwealth/client/scripts/views/components/component_kit/new_designs/CWSearchBar/SearchBarCommentPreviewRow.tsx +++ b/packages/commonwealth/client/scripts/views/components/component_kit/new_designs/CWSearchBar/SearchBarCommentPreviewRow.tsx @@ -1,13 +1,14 @@ import moment from 'moment'; import React, { FC } from 'react'; +import { getDecodedString } from '@hicommonwealth/shared'; +// eslint-disable-next-line max-len +import { MarkdownHitHighlighterWithFallback } from 'views/components/MarkdownHitHighlighterWithFallback/MarkdownHitHighlighterWithFallback'; import { useCommonNavigate } from '../../../../../navigation/helpers'; import { ReplyResult } from '../../../../pages/search/helpers'; import { renderTruncatedHighlights } from '../../../react_quill_editor/highlighter'; -import { QuillRenderer } from '../../../react_quill_editor/quill_renderer'; import { CWText } from '../../cw_text'; -import { getDecodedString } from '@hicommonwealth/shared'; import './SearchBarCommentPreviewRow.scss'; interface SearchBarCommentPreviewRowProps { @@ -40,11 +41,10 @@ export const SearchBarCommentPreviewRow: FC< {renderTruncatedHighlights(searchTerm, title)} -
diff --git a/packages/commonwealth/client/scripts/views/components/component_kit/new_designs/CWSearchBar/SearchBarThreadPreviewRow.tsx b/packages/commonwealth/client/scripts/views/components/component_kit/new_designs/CWSearchBar/SearchBarThreadPreviewRow.tsx index 08925cc4945..cd41344d610 100644 --- a/packages/commonwealth/client/scripts/views/components/component_kit/new_designs/CWSearchBar/SearchBarThreadPreviewRow.tsx +++ b/packages/commonwealth/client/scripts/views/components/component_kit/new_designs/CWSearchBar/SearchBarThreadPreviewRow.tsx @@ -4,11 +4,12 @@ import React, { FC } from 'react'; import { useCommonNavigate } from '../../../../../navigation/helpers'; import { ThreadResult } from '../../../../pages/search/helpers'; import { renderTruncatedHighlights } from '../../../react_quill_editor/highlighter'; -import { QuillRenderer } from '../../../react_quill_editor/quill_renderer'; import { User } from '../../../user/user'; import { CWText } from '../../cw_text'; import { getDecodedString } from '@hicommonwealth/shared'; +// eslint-disable-next-line max-len +import { MarkdownHitHighlighterWithFallback } from 'views/components/MarkdownHitHighlighterWithFallback/MarkdownHitHighlighterWithFallback'; import './SearchBarThreadPreviewRow.scss'; interface SearchBarThreadPreviewRowProps { @@ -55,11 +56,10 @@ export const SearchBarThreadPreviewRow: FC = ({ {renderTruncatedHighlights(searchTerm, title)} - diff --git a/packages/commonwealth/client/scripts/views/components/react_quill_editor/highlighter.tsx b/packages/commonwealth/client/scripts/views/components/react_quill_editor/highlighter.tsx index 8241a271ed4..f6ac6f4cef6 100644 --- a/packages/commonwealth/client/scripts/views/components/react_quill_editor/highlighter.tsx +++ b/packages/commonwealth/client/scripts/views/components/react_quill_editor/highlighter.tsx @@ -1,10 +1,10 @@ -import React from 'react'; import { findAll } from 'highlight-words-core'; +import React from 'react'; import smartTruncate from 'smart-truncate'; export const renderTruncatedHighlights = ( searchTerm: string, - docText: string + docText: string, ) => { // extract highlighted text const chunks = findAll({ @@ -23,10 +23,10 @@ export const renderTruncatedHighlights = ( const truncateOptions = hasSingleChunk ? {} : index === 0 - ? { position: 0 } - : index === chunks.length - 1 - ? {} - : { position: middle }; + ? { position: 0 } + : index === chunks.length - 1 + ? {} + : { position: middle }; let text = smartTruncate(subString, truncateLength, truncateOptions); diff --git a/packages/commonwealth/client/scripts/views/pages/MarkdownEditorPage/MarkdownEditorPage.scss b/packages/commonwealth/client/scripts/views/pages/MarkdownEditorPage/MarkdownEditorPage.scss new file mode 100644 index 00000000000..c5336abab0d --- /dev/null +++ b/packages/commonwealth/client/scripts/views/pages/MarkdownEditorPage/MarkdownEditorPage.scss @@ -0,0 +1,8 @@ +.MarkdownEditorPage { + display: flex; + flex-grow: 1; + .desktop { + flex-grow: 1; + margin: 16px; + } +} diff --git a/packages/commonwealth/client/scripts/views/pages/MarkdownEditorPage/MarkdownEditorPage.tsx b/packages/commonwealth/client/scripts/views/pages/MarkdownEditorPage/MarkdownEditorPage.tsx index 50f569e1077..4c54e61645a 100644 --- a/packages/commonwealth/client/scripts/views/pages/MarkdownEditorPage/MarkdownEditorPage.tsx +++ b/packages/commonwealth/client/scripts/views/pages/MarkdownEditorPage/MarkdownEditorPage.tsx @@ -1,10 +1,17 @@ import React from 'react'; import { useSearchParams } from 'react-router-dom'; import MarkdownEditor from 'views/components/MarkdownEditor'; -import { MarkdownEditorMode } from 'views/components/MarkdownEditor/MarkdownEditor'; +import { + MarkdownEditorMode, + MarkdownEditorProps, +} from 'views/components/MarkdownEditor/MarkdownEditor'; + +import './MarkdownEditorPage.scss'; import overview from 'views/components/MarkdownEditor/markdown/editor_overview.md?raw'; import supported from 'views/components/MarkdownEditor/markdown/supported.md?raw'; +import { MarkdownSubmitButton } from 'views/components/MarkdownEditor/MarkdownSubmitButton'; +import { useMarkdownEditorMethods } from 'views/components/MarkdownEditor/useMarkdownEditorMethods'; function useParams() { const [searchParams] = useSearchParams(); @@ -20,12 +27,38 @@ function useParams() { export const MarkdownEditorPage = () => { const { mode } = useParams(); + if (mode === 'desktop') { + return ( +
+
+ +
+
+ ); + } + + return ; +}; + +// eslint-disable-next-line react/no-multi-comp +const SubmitButton = () => { + const methods = useMarkdownEditorMethods(); + + const handleClick = () => { + console.log(methods.getMarkdown()); + }; + + return ; +}; + +// eslint-disable-next-line react/no-multi-comp +const Inner = (props: Pick) => { return ( console.log('markdown: \n' + markdown)} + SubmitButton={SubmitButton} /> ); }; diff --git a/packages/commonwealth/client/scripts/views/pages/MarkdownHitHighlighterPage/MarkdownHitHighlighterPage.scss b/packages/commonwealth/client/scripts/views/pages/MarkdownHitHighlighterPage/MarkdownHitHighlighterPage.scss new file mode 100644 index 00000000000..2e988bf1772 --- /dev/null +++ b/packages/commonwealth/client/scripts/views/pages/MarkdownHitHighlighterPage/MarkdownHitHighlighterPage.scss @@ -0,0 +1,12 @@ +.MarkdownHitHighlighterPage { + height: 100%; + overflow: auto; + display: flex; + + margin-top: 16px; + + .inner { + width: 800px; + margin: 0 auto; + } +} diff --git a/packages/commonwealth/client/scripts/views/pages/MarkdownHitHighlighterPage/MarkdownHitHighlighterPage.tsx b/packages/commonwealth/client/scripts/views/pages/MarkdownHitHighlighterPage/MarkdownHitHighlighterPage.tsx new file mode 100644 index 00000000000..56d4fdc09cd --- /dev/null +++ b/packages/commonwealth/client/scripts/views/pages/MarkdownHitHighlighterPage/MarkdownHitHighlighterPage.tsx @@ -0,0 +1,29 @@ +import React from 'react'; +import { useSearchParams } from 'react-router-dom'; +import supported from 'views/components/MarkdownEditor/markdown/supported.md?raw'; +import MarkdownHitHighlighter from 'views/components/MarkdownHitHighlighter'; + +import './MarkdownHitHighlighterPage.scss'; + +function useParams() { + const [searchParams] = useSearchParams(); + const searchTerm = searchParams.get('searchTerm') ?? undefined; + + return { + searchTerm, + }; +} +export const MarkdownHitHighlighterPage = () => { + const { searchTerm } = useParams(); + + return ( +
+
+ +
+
+ ); +}; diff --git a/packages/commonwealth/client/scripts/views/pages/MarkdownHitHighlighterPage/index.tsx b/packages/commonwealth/client/scripts/views/pages/MarkdownHitHighlighterPage/index.tsx new file mode 100644 index 00000000000..e202be68cde --- /dev/null +++ b/packages/commonwealth/client/scripts/views/pages/MarkdownHitHighlighterPage/index.tsx @@ -0,0 +1,3 @@ +import { MarkdownHitHighlighterPage } from 'views/pages/MarkdownHitHighlighterPage/MarkdownHitHighlighterPage'; + +export default MarkdownHitHighlighterPage; diff --git a/packages/commonwealth/client/scripts/views/pages/MarkdownViewerPage/MarkdownViewerPage.scss b/packages/commonwealth/client/scripts/views/pages/MarkdownViewerPage/MarkdownViewerPage.scss index de89bbcd573..2a66524df4e 100644 --- a/packages/commonwealth/client/scripts/views/pages/MarkdownViewerPage/MarkdownViewerPage.scss +++ b/packages/commonwealth/client/scripts/views/pages/MarkdownViewerPage/MarkdownViewerPage.scss @@ -3,7 +3,7 @@ overflow: auto; display: flex; .inner { - max-width: 800px; + width: 800px; margin: 0 auto; } } diff --git a/packages/commonwealth/client/scripts/views/pages/MarkdownViewerPage/MarkdownViewerPage.tsx b/packages/commonwealth/client/scripts/views/pages/MarkdownViewerPage/MarkdownViewerPage.tsx index f86938cd098..f11b515cca4 100644 --- a/packages/commonwealth/client/scripts/views/pages/MarkdownViewerPage/MarkdownViewerPage.tsx +++ b/packages/commonwealth/client/scripts/views/pages/MarkdownViewerPage/MarkdownViewerPage.tsx @@ -1,20 +1,41 @@ import React from 'react'; - -import supported from 'views/components/MarkdownEditor/markdown/supported.md?raw'; +import { useSearchParams } from 'react-router-dom'; import MarkdownViewer from 'views/components/MarkdownViewer'; - +import { QuillRenderer } from 'views/components/react_quill_editor/quill_renderer'; import '../../../../styles/index.scss'; import './MarkdownViewerPage.scss'; +import supported from 'views/components/MarkdownEditor/markdown/supported.md?raw'; + +function useParams() { + const [searchParams] = useSearchParams(); + const cutoffLines = searchParams.get('cutoffLines'); + const quill = searchParams.get('quill') === 'true'; + + return { + cutoffLines: cutoffLines ? parseInt(cutoffLines) : undefined, + quill, + }; +} /** * Basic demo page that allows us to use either mode and to log the markdown. */ export const MarkdownViewerPage = () => { + const { cutoffLines, quill } = useParams(); + return (
-
- -
+ {!quill && ( +
+ +
+ )} + + {quill && ( +
+ +
+ )}
); }; diff --git a/packages/commonwealth/client/scripts/views/pages/NotificationSettings/CommentSubscriptionEntry.tsx b/packages/commonwealth/client/scripts/views/pages/NotificationSettings/CommentSubscriptionEntry.tsx index e88611637fc..6122af1a297 100644 --- a/packages/commonwealth/client/scripts/views/pages/NotificationSettings/CommentSubscriptionEntry.tsx +++ b/packages/commonwealth/client/scripts/views/pages/NotificationSettings/CommentSubscriptionEntry.tsx @@ -10,10 +10,10 @@ import { getRelativeTimestamp } from 'helpers/dates'; import { navigateToCommunity, useCommonNavigate } from 'navigation/helpers'; import React, { useCallback } from 'react'; import { useDeleteCommentSubscriptionMutation } from 'state/api/trpc/subscription/useDeleteCommentSubscriptionMutation'; +import MarkdownViewerUsingQuillOrNewEditor from 'views/components/MarkdownViewerWithFallback'; import { CWCommunityAvatar } from 'views/components/component_kit/cw_community_avatar'; import { CWText } from 'views/components/component_kit/cw_text'; import { CWThreadAction } from 'views/components/component_kit/new_designs/cw_thread_action'; -import { QuillRenderer } from 'views/components/react_quill_editor/quill_renderer'; import { User } from 'views/components/user/user'; import { z } from 'zod'; @@ -112,8 +112,8 @@ export const CommentSubscriptionEntry = (
- } /> diff --git a/packages/commonwealth/client/scripts/views/pages/QuillPage/QuillPage.tsx b/packages/commonwealth/client/scripts/views/pages/QuillPage/QuillPage.tsx new file mode 100644 index 00000000000..a67ced85bbd --- /dev/null +++ b/packages/commonwealth/client/scripts/views/pages/QuillPage/QuillPage.tsx @@ -0,0 +1,13 @@ +import React from 'react'; +import { ReactQuillEditor } from 'views/components/react_quill_editor'; + +export const QuillPage = () => { + return ( + console.log('got delta')} + /> + ); +}; diff --git a/packages/commonwealth/client/scripts/views/pages/QuillPage/index.tsx b/packages/commonwealth/client/scripts/views/pages/QuillPage/index.tsx new file mode 100644 index 00000000000..a9284badc00 --- /dev/null +++ b/packages/commonwealth/client/scripts/views/pages/QuillPage/index.tsx @@ -0,0 +1,3 @@ +import { QuillPage } from 'views/pages/QuillPage/QuillPage'; + +export default QuillPage; diff --git a/packages/commonwealth/client/scripts/views/pages/Snapshots/ViewSnapshotProposal/ViewSnapshotProposal.tsx b/packages/commonwealth/client/scripts/views/pages/Snapshots/ViewSnapshotProposal/ViewSnapshotProposal.tsx index cc08e6406c5..ae2ee8b0069 100644 --- a/packages/commonwealth/client/scripts/views/pages/Snapshots/ViewSnapshotProposal/ViewSnapshotProposal.tsx +++ b/packages/commonwealth/client/scripts/views/pages/Snapshots/ViewSnapshotProposal/ViewSnapshotProposal.tsx @@ -17,23 +17,22 @@ import useNecessaryEffect from 'hooks/useNecessaryEffect'; import AddressInfo from 'models/AddressInfo'; import { LinkSource } from 'models/Thread'; import app from 'state'; +import { + useGetSnapshotProposalsQuery, + useGetSnapshotSpaceQuery, +} from 'state/api/snapshots'; import { useGetThreadsByLinkQuery } from 'state/api/threads'; import useUserStore from 'state/ui/user'; import { CWContentPage } from 'views/components/component_kit/CWContentPage'; import CWPageLayout from 'views/components/component_kit/new_designs/CWPageLayout'; +import { MarkdownViewerWithFallback } from 'views/components/MarkdownViewerWithFallback/MarkdownViewerWithFallback'; import { ActiveProposalPill, ClosedProposalPill, } from 'views/components/proposal_pills'; -import { QuillRenderer } from 'views/components/react_quill_editor/quill_renderer'; - -import { - useGetSnapshotProposalsQuery, - useGetSnapshotSpaceQuery, -} from 'state/api/snapshots'; -import { SnapshotInformationCard } from './SnapshotInformationCard'; -import { SnapshotPollCardContainer } from './SnapshotPollCard'; -import { SnapshotVotesTable } from './SnapshotVotesTable'; +import { SnapshotInformationCard } from 'views/pages/Snapshots/ViewSnapshotProposal/SnapshotInformationCard'; +import { SnapshotPollCardContainer } from 'views/pages/Snapshots/ViewSnapshotProposal/SnapshotPollCard'; +import { SnapshotVotesTable } from 'views/pages/Snapshots/ViewSnapshotProposal/SnapshotVotesTable'; type ViewSnapshotProposalProps = { identifier: string; @@ -174,7 +173,7 @@ const ViewSnapshotProposal = ({ ) } - body={() => } + body={() => } subBody={ votes.length > 0 && ( {isSpam && } - + {!comment.deleted && (
diff --git a/packages/commonwealth/client/scripts/views/pages/discussions/DiscussionsPage.tsx b/packages/commonwealth/client/scripts/views/pages/discussions/DiscussionsPage.tsx index 232fdad64d4..126aa48bf7b 100644 --- a/packages/commonwealth/client/scripts/views/pages/discussions/DiscussionsPage.tsx +++ b/packages/commonwealth/client/scripts/views/pages/discussions/DiscussionsPage.tsx @@ -30,7 +30,7 @@ import { useFetchCustomDomainQuery } from 'state/api/configuration'; import { useRefreshMembershipQuery } from 'state/api/groups'; import useUserStore from 'state/ui/user'; import Permissions from 'utils/Permissions'; -import { checkIsTopicInContest } from 'views/components/NewThreadForm/helpers'; +import { checkIsTopicInContest } from 'views/components/NewThreadFormLegacy/helpers'; import TokenBanner from 'views/components/TokenBanner'; import CWPageLayout from 'views/components/component_kit/new_designs/CWPageLayout'; import useCommunityContests from 'views/pages/CommunityManagement/Contests/useCommunityContests'; diff --git a/packages/commonwealth/client/scripts/views/pages/discussions/HeaderWithFilters/HeaderWithFilters.tsx b/packages/commonwealth/client/scripts/views/pages/discussions/HeaderWithFilters/HeaderWithFilters.tsx index d31883b88c9..39259c7f73c 100644 --- a/packages/commonwealth/client/scripts/views/pages/discussions/HeaderWithFilters/HeaderWithFilters.tsx +++ b/packages/commonwealth/client/scripts/views/pages/discussions/HeaderWithFilters/HeaderWithFilters.tsx @@ -12,12 +12,12 @@ import { useFetchTopicsQuery } from 'state/api/topics'; import useUserStore from 'state/ui/user'; import Permissions from 'utils/Permissions'; import { useCommunityStake } from 'views/components/CommunityStake'; +import MarkdownViewerUsingQuillOrNewEditor from 'views/components/MarkdownViewerWithFallback'; import { Select } from 'views/components/Select'; import { CWCheckbox } from 'views/components/component_kit/cw_checkbox'; import { CWText } from 'views/components/component_kit/cw_text'; import { CWButton } from 'views/components/component_kit/new_designs/CWButton'; import { CWModal } from 'views/components/component_kit/new_designs/CWModal'; -import { QuillRenderer } from 'views/components/react_quill_editor/quill_renderer'; import { EditTopicModal } from 'views/modals/edit_topic_modal'; import { Contest } from 'views/pages/CommunityManagement/Contests/ContestsList'; import ContestCard from 'views/pages/CommunityManagement/Contests/ContestsList/ContestCard'; @@ -250,9 +250,9 @@ export const HeaderWithFilters = ({
{selectedTopic?.description && ( - )} diff --git a/packages/commonwealth/client/scripts/views/pages/discussions/ThreadCard/ThreadCard.tsx b/packages/commonwealth/client/scripts/views/pages/discussions/ThreadCard/ThreadCard.tsx index f69e6b193f5..05bf2cc107e 100644 --- a/packages/commonwealth/client/scripts/views/pages/discussions/ThreadCard/ThreadCard.tsx +++ b/packages/commonwealth/client/scripts/views/pages/discussions/ThreadCard/ThreadCard.tsx @@ -11,6 +11,7 @@ import React, { useEffect, useState } from 'react'; import { Link } from 'react-router-dom'; import { useGetCommunityByIdQuery } from 'state/api/communities'; import useUserStore from 'state/ui/user'; +import MarkdownViewerUsingQuillOrNewEditor from 'views/components/MarkdownViewerWithFallback'; import { ThreadContestTagContainer } from 'views/components/ThreadContestTag'; import { ViewThreadUpvotesDrawer } from 'views/components/UpvoteDrawer'; import { CWDivider } from 'views/components/component_kit/cw_divider'; @@ -18,7 +19,6 @@ import { CWIcon } from 'views/components/component_kit/cw_icons/cw_icon'; import { CWText } from 'views/components/component_kit/cw_text'; import { getClasses } from 'views/components/component_kit/helpers'; import { CWTag } from 'views/components/component_kit/new_designs/CWTag'; -import { QuillRenderer } from 'views/components/react_quill_editor/quill_renderer'; import useBrowserWindow from '../../../../hooks/useBrowserWindow'; import { ThreadStage } from '../../../../models/types'; import Permissions from '../../../../utils/Permissions'; @@ -207,8 +207,8 @@ export const ThreadCard = ({ )}
- diff --git a/packages/commonwealth/client/scripts/views/pages/view_thread/ViewThreadPage.tsx b/packages/commonwealth/client/scripts/views/pages/view_thread/ViewThreadPage.tsx index ebf4ca24e7c..807e5f859ac 100644 --- a/packages/commonwealth/client/scripts/views/pages/view_thread/ViewThreadPage.tsx +++ b/packages/commonwealth/client/scripts/views/pages/view_thread/ViewThreadPage.tsx @@ -26,7 +26,8 @@ import { import useUserStore from 'state/ui/user'; import ExternalLink from 'views/components/ExternalLink'; import JoinCommunityBanner from 'views/components/JoinCommunityBanner'; -import { checkIsTopicInContest } from 'views/components/NewThreadForm/helpers'; +import MarkdownViewerUsingQuillOrNewEditor from 'views/components/MarkdownViewerWithFallback'; +import { checkIsTopicInContest } from 'views/components/NewThreadFormLegacy/helpers'; import useJoinCommunity from 'views/components/SublayoutHeader/useJoinCommunity'; import CWPageLayout from 'views/components/component_kit/new_designs/CWPageLayout'; import { PageNotFound } from 'views/pages/404'; @@ -53,7 +54,6 @@ import { isWindowMediumSmallInclusive, } from '../../components/component_kit/helpers'; import { getTextFromDelta } from '../../components/react_quill_editor/'; -import { QuillRenderer } from '../../components/react_quill_editor/quill_renderer'; import { CommentTree } from '../discussions/CommentTree'; import { clearEditingLocalStorage } from '../discussions/CommentTree/helpers'; import { LinkedUrlCard } from './LinkedUrlCard'; @@ -564,11 +564,11 @@ const ViewThreadPage = ({ identifier }: ViewThreadPageProps) => { ) : ( <> - - doc={threadBody ?? thread?.body} + + {/* @ts-expect-error StrictNullChecks*/} {thread.readOnly || fromDiscordBot ? ( <>