From 4f4de3a53dbc083070c2a53b94a8569947edd3d3 Mon Sep 17 00:00:00 2001 From: Chris Bongers Date: Wed, 13 May 2026 14:07:32 +0200 Subject: [PATCH 1/5] feat: smart composer experiment --- .../fields/RichTextEditor/RichTextToolbar.tsx | 59 ++-- .../src/components/fields/RichTextInput.tsx | 276 +++++++++++------- .../src/components/icons/Maximize/filled.svg | 7 + .../src/components/icons/Maximize/index.tsx | 10 + .../components/icons/Maximize/outlined.svg | 7 + .../src/components/icons/Minimize/filled.svg | 7 + .../src/components/icons/Minimize/index.tsx | 10 + .../components/icons/Minimize/outlined.svg | 7 + packages/shared/src/components/icons/index.ts | 2 + .../shared/src/components/modals/common.tsx | 8 + .../src/components/modals/common/types.ts | 1 + .../modals/post/SmartComposerModal.tsx | 254 ++++++++++++++++ .../components/post/composer/AudienceChip.tsx | 268 +++++++++++++++++ .../post/composer/KindModePicker.tsx | 109 +++++++ .../src/components/post/composer/LinkForm.tsx | 112 +++++++ .../src/components/post/composer/PollForm.tsx | 181 ++++++++++++ .../src/components/post/composer/TextForm.tsx | 198 +++++++++++++ .../src/components/post/composer/types.ts | 30 ++ .../post/composer/useComposerAudience.ts | 81 +++++ .../post/composer/useComposerSubmit.ts | 272 +++++++++++++++++ .../components/post/composer/utils.spec.ts | 18 ++ .../src/components/post/composer/utils.ts | 25 ++ .../post/write/CreatePostButton.tsx | 48 ++- .../shared/src/hooks/post/useSmartComposer.ts | 12 + .../src/hooks/squads/usePostToSquad.tsx | 25 +- packages/shared/src/lib/featureManagement.ts | 2 + 26 files changed, 1884 insertions(+), 145 deletions(-) create mode 100644 packages/shared/src/components/icons/Maximize/filled.svg create mode 100644 packages/shared/src/components/icons/Maximize/index.tsx create mode 100644 packages/shared/src/components/icons/Maximize/outlined.svg create mode 100644 packages/shared/src/components/icons/Minimize/filled.svg create mode 100644 packages/shared/src/components/icons/Minimize/index.tsx create mode 100644 packages/shared/src/components/icons/Minimize/outlined.svg create mode 100644 packages/shared/src/components/modals/post/SmartComposerModal.tsx create mode 100644 packages/shared/src/components/post/composer/AudienceChip.tsx create mode 100644 packages/shared/src/components/post/composer/KindModePicker.tsx create mode 100644 packages/shared/src/components/post/composer/LinkForm.tsx create mode 100644 packages/shared/src/components/post/composer/PollForm.tsx create mode 100644 packages/shared/src/components/post/composer/TextForm.tsx create mode 100644 packages/shared/src/components/post/composer/types.ts create mode 100644 packages/shared/src/components/post/composer/useComposerAudience.ts create mode 100644 packages/shared/src/components/post/composer/useComposerSubmit.ts create mode 100644 packages/shared/src/components/post/composer/utils.spec.ts create mode 100644 packages/shared/src/components/post/composer/utils.ts create mode 100644 packages/shared/src/hooks/post/useSmartComposer.ts diff --git a/packages/shared/src/components/fields/RichTextEditor/RichTextToolbar.tsx b/packages/shared/src/components/fields/RichTextEditor/RichTextToolbar.tsx index c6c43ac4763..0866de26d9c 100644 --- a/packages/shared/src/components/fields/RichTextEditor/RichTextToolbar.tsx +++ b/packages/shared/src/components/fields/RichTextEditor/RichTextToolbar.tsx @@ -1,3 +1,4 @@ +import classNames from 'classnames'; import type { ReactElement, ReactNode, Ref } from 'react'; import React, { useState, @@ -24,9 +25,13 @@ import { LinkModal } from './LinkModal'; export interface RichTextToolbarProps { editor: Editor; onLinkAdd: (url: string, label?: string) => void; + leadingActions?: ReactNode; inlineActions?: ReactNode; rightActions?: ReactNode; allowBlockFormatting?: boolean; + position?: 'top' | 'bottom'; + className?: string; + hideInlineLink?: boolean; } export interface RichTextToolbarRef { @@ -47,8 +52,9 @@ const ToolbarButton = ({ isActive, onClick, disabled = false, -}: ToolbarButtonProps): ReactElement | null => { - if (disabled) { + alwaysVisible = false, +}: ToolbarButtonProps & { alwaysVisible?: boolean }): ReactElement | null => { + if (disabled && !alwaysVisible) { return null; } @@ -64,6 +70,7 @@ const ToolbarButton = ({ })} pressed={isActive} onClick={onClick} + disabled={disabled} type="button" className="leading-none" /> @@ -75,9 +82,13 @@ function RichTextToolbarComponent( { editor, onLinkAdd, + leadingActions, inlineActions, rightActions, allowBlockFormatting = true, + position = 'top', + className, + hideInlineLink = false, }: RichTextToolbarProps, ref: Ref, ): ReactElement { @@ -147,8 +158,29 @@ function RichTextToolbarComponent( return ( <> -
+
+ {leadingActions && ( +
{leadingActions}
+ )} + {inlineActions && ( +
{inlineActions}
+ )} + {!hideInlineLink && ( + } + isActive={editorState.isLink} + onClick={openLinkModal} + /> + )} +
} @@ -161,7 +193,7 @@ function RichTextToolbarComponent( isActive={editorState.isItalic} onClick={() => editor.chain().focus().toggleItalic().run()} /> - {allowBlockFormatting ? ( + {allowBlockFormatting && ( <>
editor.chain().focus().toggleOrderedList().run()} /> - ) : null} -
- } - isActive={editorState.isLink} - onClick={openLinkModal} - /> - {inlineActions && ( - <> -
-
{inlineActions}
- - )} - {(editorState.canUndo || editorState.canRedo) && ( -
)} +
} isActive={false} onClick={() => editor.chain().focus().undo().run()} disabled={!editorState.canUndo} + alwaysVisible /> editor.chain().focus().redo().run()} disabled={!editorState.canRedo} + alwaysVisible />
{rightActions && ( diff --git a/packages/shared/src/components/fields/RichTextInput.tsx b/packages/shared/src/components/fields/RichTextInput.tsx index 2172a66569f..5d45be20665 100644 --- a/packages/shared/src/components/fields/RichTextInput.tsx +++ b/packages/shared/src/components/fields/RichTextInput.tsx @@ -28,6 +28,7 @@ import Image from '@tiptap/extension-image'; import { ImageIcon, AtIcon, MarkdownIcon } from '../icons'; import { EditIcon } from '../icons/Edit'; import { GifIcon } from '../icons/Gif'; +import { LinkIcon } from '../icons/Link'; import { Button, ButtonSize, ButtonVariant } from '../buttons/Button'; import { RecommendedMentionTooltip } from '../tooltips/RecommendedMentionTooltip'; import { SimpleTooltip } from '../tooltips/SimpleTooltip'; @@ -147,6 +148,13 @@ interface RichTextInputProps { minHeightClassName?: string; markdownToHtml?: (markdown: string) => string; hideToolbar?: boolean; + toolbarPosition?: 'top' | 'bottom'; + toolbarLeading?: ReactNode; + toolbarRightActions?: ReactNode; + hideMarkdownToggle?: boolean; + hideMarkdownHeader?: boolean; + hideFooter?: boolean; + onMarkdownModeChange?: (isMarkdownMode: boolean) => void; } export interface RichTextInputRef { @@ -154,6 +162,7 @@ export interface RichTextInputRef { clearDraft: () => void; setInput: (value: string) => void; focus: () => void; + toggleMarkdownMode: () => void; } function RichTextInput( @@ -183,6 +192,13 @@ function RichTextInput( minHeightClassName = 'min-h-[8rem]', markdownToHtml = markdownToHtmlBasic, hideToolbar = false, + toolbarPosition = 'top', + toolbarLeading, + toolbarRightActions, + hideMarkdownToggle = false, + hideMarkdownHeader = false, + hideFooter = false, + onMarkdownModeChange, }: RichTextInputProps, ref: ForwardedRef, ): ReactElement { @@ -205,6 +221,7 @@ function RichTextInput( }, []); const isUploadEnabled = enabledCommand[MarkdownCommand.Upload]; + const isLinkEnabled = enabledCommand[MarkdownCommand.Link]; const isMentionEnabled = enabledCommand[MarkdownCommand.Mention]; const isEmojiEnabled = enabledCommand[MarkdownCommand.Emoji]; const isGifEnabled = enabledCommand[MarkdownCommand.Gif]; @@ -508,6 +525,18 @@ function RichTextInput( setIsMarkdownMode(false); }, [markdownToHtml]); + const toggleMarkdownMode = useCallback(() => { + if (isMarkdownMode) { + switchToRichMode(); + return; + } + switchToMarkdownMode(); + }, [isMarkdownMode, switchToMarkdownMode, switchToRichMode]); + + useEffect(() => { + onMarkdownModeChange?.(isMarkdownMode); + }, [isMarkdownMode, onMarkdownModeChange]); + const onMarkdownInput = useCallback( (event: React.FormEvent) => { const { value } = event.currentTarget; @@ -603,6 +632,7 @@ function RichTextInput( editor?.commands.focus(); }, + toggleMarkdownMode, })); useEffect(() => { @@ -638,7 +668,8 @@ function RichTextInput( : editor?.storage.characterCount?.characters?.() ?? input.length) : null; - const hasToolbarActions = isUploadEnabled || isMentionEnabled || isGifEnabled; + const hasToolbarActions = + isUploadEnabled || isLinkEnabled || isMentionEnabled || isGifEnabled; const toolbarActions = ( <> {isUploadEnabled && ( @@ -652,6 +683,15 @@ function RichTextInput( type="button" /> )} + {isLinkEnabled && ( +
-
+ )}