diff --git a/apps/meteor/app/ui-message/client/messageBox/createRichTextComposerAPI.spec.ts b/apps/meteor/app/ui-message/client/messageBox/createRichTextComposerAPI.spec.ts index 9f181fcf6cc7f..a25e9dc1ef16e 100644 --- a/apps/meteor/app/ui-message/client/messageBox/createRichTextComposerAPI.spec.ts +++ b/apps/meteor/app/ui-message/client/messageBox/createRichTextComposerAPI.spec.ts @@ -5,10 +5,6 @@ jest.mock('../../../../client/lib/chats/uploads', () => ({ createUploadsAPI: () => ({}), })); -jest.mock('../../../../client/lib/utils/renderEmoji', () => ({ - getEmojiClassNameAndDataTitle: () => ({}), -})); - let innerTextDescriptor: PropertyDescriptor | undefined; let originalExecCommand: typeof document.execCommand | undefined; @@ -114,7 +110,7 @@ describe('RichText Composer API - insertText', () => { composer.insertText('hi'); - expect(input.textContent).toBe('hi'); + expect(input.textContent).toBe('hi\n'); expect(getSelectionRange(input)).toEqual({ selectionStart: 2, selectionEnd: 2 }); }); @@ -123,10 +119,28 @@ describe('RichText Composer API - insertText', () => { composer.insertText('b'); - expect(input.textContent).toBe('abc'); + expect(input.textContent).toBe('abc\n'); expect(getSelectionRange(input)).toEqual({ selectionStart: 2, selectionEnd: 2 }); }); + it('renders the markup instead of leaving it raw until the next keystroke', () => { + const { composer, input } = setupComposer('', { start: 0, end: 0 }); + + composer.insertText('*bold*'); + + expect(input.querySelector('strong')).not.toBeNull(); + expect(input.textContent).toBe('*bold*\n'); + }); + + it('keeps the surrounding markup rendered when inserting at the end', () => { + const { composer, input } = setupComposer('*bold*', { start: 6, end: 6 }); + + composer.insertText(' 😄'); + + expect(input.querySelector('strong')).not.toBeNull(); + expect(input.textContent).toBe('*bold* 😄\n'); + }); + it('still inserts when execCommand reports success but changes nothing', () => { const { execCommand } = document as unknown as { execCommand: () => boolean }; (document as unknown as { execCommand: () => boolean }).execCommand = () => true; @@ -144,6 +158,35 @@ describe('RichText Composer API - insertText', () => { }); }); +describe('RichText Composer API - draft restore', () => { + afterEach(() => { + window.getSelection()?.removeAllRanges(); + document.body.innerHTML = ''; + }); + + it('renders the restored draft markup without waiting for a keystroke', () => { + const input = document.createElement('div'); + input.contentEditable = 'true'; + document.body.appendChild(input); + + createRichTextComposerAPI(input, jest.fn(), '*bold*', Number.MAX_SAFE_INTEGER, {}, { current: null }, { rid: 'GENERAL' }); + + expect(input.querySelector('strong')).not.toBeNull(); + expect(input.textContent).toBe('*bold*\n'); + }); + + it('leaves the composer empty when there is no draft', () => { + const input = document.createElement('div'); + input.contentEditable = 'true'; + document.body.appendChild(input); + + const composer = createRichTextComposerAPI(input, jest.fn(), '', Number.MAX_SAFE_INTEGER, {}, { current: null }, { rid: 'GENERAL' }); + + expect(input.textContent).toBe(''); + expect(composer.text).toBe(''); + }); +}); + describe('RichText Composer API - wrapSelection', () => { afterEach(() => { window.getSelection()?.removeAllRanges(); diff --git a/apps/meteor/app/ui-message/client/messageBox/createRichTextComposerAPI.ts b/apps/meteor/app/ui-message/client/messageBox/createRichTextComposerAPI.ts index d979df9b967b9..117bb42cf49bf 100644 --- a/apps/meteor/app/ui-message/client/messageBox/createRichTextComposerAPI.ts +++ b/apps/meteor/app/ui-message/client/messageBox/createRichTextComposerAPI.ts @@ -53,6 +53,14 @@ export const createRichTextComposerAPI = ( input.innerHTML = escapeHTML(text); } + // The events below are synthetic, so resolveComposerBox ignores them and the markup would stay + // unrendered. Skip it while empty: rendering '' yields the renderer's trailing newline, which would + // leave `clear()` with a composer that is no longer empty. + if (input.innerText !== '') { + const { selectionStart: caretStart, selectionEnd: caretEnd } = getSelectionRange(input); + renderComposerContent(input, parseOptions, { selectionStart: caretStart, selectionEnd: caretEnd }); + } + triggerEvent(input, 'input'); triggerEvent(input, 'change'); diff --git a/apps/meteor/app/ui-message/client/messageBox/messageStateHandler.spec.ts b/apps/meteor/app/ui-message/client/messageBox/messageStateHandler.spec.ts index dc7c8f023612d..99e31f62133cf 100644 --- a/apps/meteor/app/ui-message/client/messageBox/messageStateHandler.spec.ts +++ b/apps/meteor/app/ui-message/client/messageBox/messageStateHandler.spec.ts @@ -1,15 +1,35 @@ -import { resolveComposerBox } from './messageStateHandler'; +import { renderComposerContent, resolveComposerBox } from './messageStateHandler'; +import { renderComposerMarkup } from './renderComposerMarkup'; jest.mock('./renderComposerMarkup', () => ({ - renderComposerMarkup: () => '

rendered

', + renderComposerMarkup: jest.fn(), })); -describe('resolveComposerBox', () => { - afterEach(() => { - document.body.innerHTML = ''; - }); +const renderMock = renderComposerMarkup as jest.MockedFunction; + +const mountInput = (text: string): HTMLDivElement => { + const input = document.createElement('div'); + // jsdom does not implement innerText, which is what the composer reads. + Object.defineProperty(input, 'innerText', { value: text, writable: true, configurable: true }); + document.body.appendChild(input); + return input; +}; + +const render = (input: HTMLDivElement): void => renderComposerContent(input, {}, { selectionStart: 0, selectionEnd: 0 }); + +beforeEach(() => { + renderMock.mockReset(); +}); + +afterEach(() => { + window.getSelection()?.removeAllRanges(); + document.body.innerHTML = ''; +}); +describe('resolveComposerBox', () => { it('ignores untrusted events so programmatic changes do not trigger a rerender', () => { + renderMock.mockReturnValue('rendered'); + const input = document.createElement('div'); input.innerHTML = '

original

'; document.body.appendChild(input); @@ -23,3 +43,52 @@ describe('resolveComposerBox', () => { expect(input.innerHTML).toBe('

original

'); }); }); + +describe('renderComposerContent', () => { + it('passes the parsed source to the renderer so nodes without a renderer can recover their markup', () => { + renderMock.mockReturnValue('a *b*\n'); + + render(mountInput('a *b*')); + + expect(renderMock).toHaveBeenCalledWith(expect.anything(), 'a *b*'); + }); + + it('keeps the rendered markup when it holds the same text as the source', () => { + renderMock.mockReturnValue('a b\n'); + + const input = mountInput('a b'); + render(input); + + expect(input.querySelector('strong')).not.toBeNull(); + }); + + it('falls back to the raw text when the render loses text', () => { + renderMock.mockReturnValue('\n'); + + const input = mountInput('- [ ] a task'); + render(input); + + expect(input.textContent).toBe('- [ ] a task'); + }); + + it('falls back to the raw text when the render adds text', () => { + renderMock.mockReturnValue('hello there\n'); + + const input = mountInput('hello'); + render(input); + + expect(input.textContent).toBe('hello'); + }); + + it('escapes the fallback so a lossy render cannot inject elements', () => { + renderMock.mockReturnValue('\n'); + + const text = 'x.com/'; + const input = mountInput(text); + render(input); + + expect(input.querySelector('img')).toBeNull(); + expect(input.querySelector('script')).toBeNull(); + expect(input.textContent).toBe(text); + }); +}); diff --git a/apps/meteor/app/ui-message/client/messageBox/messageStateHandler.ts b/apps/meteor/app/ui-message/client/messageBox/messageStateHandler.ts index d4c884ddf5d23..f7116b71fe3c9 100644 --- a/apps/meteor/app/ui-message/client/messageBox/messageStateHandler.ts +++ b/apps/meteor/app/ui-message/client/messageBox/messageStateHandler.ts @@ -1,44 +1,11 @@ import { parse, type Options } from '@rocket.chat/message-parser'; +import { escapeHTML } from '@rocket.chat/string-helpers'; import { renderComposerMarkup } from './renderComposerMarkup'; import { getSelectionRange, setSelectionRange } from './selectionRange'; -// TODO: Investigate an issue where Slack style links are not working properly -// This might have to do with the symbols < and > not resolving into websafe characters -const protectLinks = (text: string): { output: string; matches: string[] } => { - const matches: string[] = []; - let idx = 0; - - const patterns = [ - // Markdown reference links - /\[([^\]]*)\]\((https?:\/\/[^\)]+)\)/g, - - // Slack-style links - /<([^>|]+)\|([^>]+)>/g, - - // Emails - /\b[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Za-z]{2,}\b/g, - - // Bare domains / URLs (not after @ so we don't eat mentions) - /(? { - output = output.replace(regex, (full) => { - const placeholder = `[[[LINK_${idx}]]]`; - matches[idx++] = full; - return placeholder; - }); - }); - - return { output, matches }; -}; - -const restoreLinks = (html: string, matches: string[]): string => { - return html.replace(/\[\[\[LINK_(\d+)\]\]\]/g, (_, i) => matches[parseInt(i, 10)] || ''); -}; +// Paragraphs render with a trailing '\n' the source does not have. +const sameText = (rendered: string, source: string): boolean => rendered.replace(/\n$/, '') === source.replace(/\n$/, ''); // Parse the composer's raw text into markup and render it into the contenteditable, // restoring the caret to the given flat-offset selection afterwards. @@ -48,21 +15,16 @@ export const renderComposerContent = ( { selectionStart, selectionEnd }: { selectionStart: number; selectionEnd: number }, ): void => { const text = target.innerText; + const source = text === '' ? '\n' : text; - // Extract the URL and substitue with a safe template - const { output: safeText, matches } = protectLinks(text === '' ? '\n' : text); - - // Parse the safetext - const ast = parse(safeText, parseOptions); - - // Render the AST through the gazzodown-alt WYSIWYG components - const html = renderComposerMarkup(ast); - - // Restore the substituted links - const finalHtml = restoreLinks(html, matches); + // Parse the raw text and render the AST through the gazzodown-alt WYSIWYG components + target.innerHTML = renderComposerMarkup(parse(source, parseOptions), source); - // Rendering pipeline - target.innerHTML = finalHtml; + // Caret offsets are flat character counts over the rendered text, so a node without a renderer + // would both lose the user's text and shift the caret. Fall back to the raw text instead. + if (!sameText(target.textContent ?? '', source)) { + target.innerHTML = escapeHTML(text); + } // Restore the cursor to the correct position setSelectionRange(target, selectionStart, selectionEnd); diff --git a/apps/meteor/app/ui-message/client/messageBox/renderComposerMarkup.spec.tsx b/apps/meteor/app/ui-message/client/messageBox/renderComposerMarkup.spec.tsx index 5607a72437970..39a37ef8e12f2 100644 --- a/apps/meteor/app/ui-message/client/messageBox/renderComposerMarkup.spec.tsx +++ b/apps/meteor/app/ui-message/client/messageBox/renderComposerMarkup.spec.tsx @@ -1,42 +1,89 @@ import { parse } from '@rocket.chat/message-parser'; +import { renderComposerContent } from './messageStateHandler'; import { renderComposerMarkup } from './renderComposerMarkup'; import { getSelectionRange, setSelectionRange } from './selectionRange'; -jest.mock('../../../../client/lib/utils/renderEmoji', () => ({ - getEmojiClassNameAndDataTitle: () => ({}), -})); - const mountMarkup = (text: string): HTMLDivElement => { const input = document.createElement('div'); - input.innerHTML = renderComposerMarkup(parse(text, {})); + input.innerHTML = renderComposerMarkup(parse(text, {}), text); document.body.appendChild(input); return input; }; +const mountComposer = (text: string): HTMLDivElement => { + const input = document.createElement('div'); + // jsdom does not implement innerText, which is what the composer reads. + Object.defineProperty(input, 'innerText', { value: text, writable: true, configurable: true }); + document.body.appendChild(input); + renderComposerContent(input, {}, { selectionStart: 0, selectionEnd: 0 }); + return input; +}; + +const stripLineEnd = (text: string): string => text.replace(/\n$/, ''); + afterEach(() => { window.getSelection()?.removeAllRanges(); document.body.innerHTML = ''; }); -describe('caret round-trip on real rendered markup', () => { +describe('text exactness and caret round-trip on real rendered markup', () => { it.each([ - ['plain text', 'hello world', 'hello world\n'], - ['bold', 'a *bold* b', 'a *bold* b\n'], - ['italic', 'a _em_ b', 'a _em_ b\n'], - ['inline code', 'a `code` b', 'a `code` b\n'], - ['heading', '# Title', '# Title\n'], - ['multiline', 'first\nsecond\nthird', 'first\nsecond\nthird\n'], - ])('preserves every caret offset for %s', (_label, text, rendered) => { + ['plain text', 'hello world'], + ['bold', 'a *bold* b'], + ['italic', 'a _em_ b'], + ['strike', 'a ~out~ b'], + ['spoiler', 'a ||hidden|| b'], + ['inline code', 'a `code` b'], + ['code block', '```js\nconst a = 1;\n```'], + ['heading', '# Title'], + ['heading with a link', '# see rocket.chat'], + ['quote', '> quoted'], + ['multiline', 'first\nsecond\nthird'], + ['user mention', 'hi @rocket.cat'], + ['channel mention', 'hi #general'], + ['markdown link', 'see [the docs](https://rocket.chat/docs)'], + ['bare domain', 'see rocket.chat/docs now'], + ['email', 'mail me@rocket.chat now'], + ['link inside bold', 'a *see rocket.chat* b'], + ['image', 'look ![alt](https://rocket.chat/a.png)'], + ['timestamp', 'at ok'], + ['horizontal rule', '---'], + ['horizontal rule between paragraphs', 'a\n---\nb'], + ['table', '|a|b|\n|-|-|\n|1|2|'], + ['hyphen list', '- one\n- two'], + ['ordered list', '1. one\n2. two'], + ['tasks', '- [x] done\n- [ ] todo'], + ['emoji shortcode', 'hi :smile: there'], + ['emoji shortcode alone', ':smile:'], + ['unicode emoji in text', 'hi 😄 there'], + ['unicode emoji alone', '😄'], + ])('renders %s as its own text and preserves every caret offset', (_label, text) => { const input = mountMarkup(text); + const rendered = input.textContent ?? ''; - expect(input.textContent).toBe(rendered); + expect(stripLineEnd(rendered)).toBe(stripLineEnd(text)); - const { length } = rendered; - - for (let n = 0; n <= length; n++) { + for (let n = 0; n <= rendered.length; n++) { setSelectionRange(input, n, n); expect(getSelectionRange(input)).toEqual({ selectionStart: n, selectionEnd: n }); } }); }); + +const lossy: [string, string][] = [ + ['asterisk list', '* one\n* two'], + ['slack-style link', ''], + ['padded horizontal rule', ' ---'], + ['several big emoji', '😄 😄'], +]; + +describe('markup the renderer cannot reproduce', () => { + it.each(lossy)('does not reproduce %s', (_label, text) => { + expect(stripLineEnd(mountMarkup(text).textContent ?? '')).not.toBe(stripLineEnd(text)); + }); + + it.each(lossy)('keeps every character of %s through the text guard', (_label, text) => { + expect(stripLineEnd(mountComposer(text).textContent ?? '')).toBe(stripLineEnd(text)); + }); +}); diff --git a/apps/meteor/app/ui-message/client/messageBox/renderComposerMarkup.tsx b/apps/meteor/app/ui-message/client/messageBox/renderComposerMarkup.tsx index cc7c5d4b7ebca..b73457433027d 100644 --- a/apps/meteor/app/ui-message/client/messageBox/renderComposerMarkup.tsx +++ b/apps/meteor/app/ui-message/client/messageBox/renderComposerMarkup.tsx @@ -3,22 +3,8 @@ import type { Root } from '@rocket.chat/message-parser'; import { createElement } from 'react'; import { renderToStaticMarkup } from 'react-dom/server'; -import { getEmojiClassNameAndDataTitle } from '../../../../client/lib/utils/renderEmoji'; - -const detectEmoji: ComposerMarkupContextValue['detectEmoji'] = (text) => { - const { className, image, children, name } = getEmojiClassNameAndDataTitle(text); - - if (!className && !children) { - return []; - } - - const rawImage = image ? image.replace(/^url\(["']?/, '').replace(/["']?\)$/, '') : undefined; - - return [{ name, className: className ?? '', image: rawImage, content: children ?? '' }]; -}; - -export const renderComposerMarkup = (tokens: Root): string => { - const context: ComposerMarkupContextValue = { detectEmoji }; +export const renderComposerMarkup = (tokens: Root, source: string): string => { + const context: ComposerMarkupContextValue = { source }; return renderToStaticMarkup(createElement(ComposerMarkupContext.Provider, { value: context }, createElement(ComposerMarkup, { tokens }))); }; diff --git a/apps/meteor/client/views/room/composer/messageBox/RichTextMessageBox.tsx b/apps/meteor/client/views/room/composer/messageBox/RichTextMessageBox.tsx index 47c3260b42f0e..07eadc667337a 100644 --- a/apps/meteor/client/views/room/composer/messageBox/RichTextMessageBox.tsx +++ b/apps/meteor/client/views/room/composer/messageBox/RichTextMessageBox.tsx @@ -71,10 +71,11 @@ const reducer = (_: unknown, event: FormEvent): TypingState => { target.innerHTML = '
'; } + const text = target.innerText.replace(/\n$/, ''); + return { - isTyping: Boolean(target.innerText.trim()), - // Show placeholder only if there's exactly one
and nothing else - hideplaceholder: Boolean(childNodes.length !== 1 || childNodes[0].nodeName !== 'BR'), + isTyping: Boolean(text.trim()), + hideplaceholder: Boolean(text), }; }; @@ -88,6 +89,7 @@ const RichTextMessageBox = ({ onTyping, tshow, previewUrls, + threadExists, }: MessageBoxProps): ReactElement => { const chat = useChat(); const room = useRoom(); @@ -129,16 +131,22 @@ const RichTextMessageBox = ({ const messageComposerRef = useRef(null); const subscription = useRoomSubscription(); - const { initialValue, persistLocal, flushDraft } = useDraft(room._id, tmid ? undefined : subscription?.draft, tmid); + const { initialValue, persistLocal, flushDraft } = useDraft( + room._id, + tmid ? subscription?.threadDrafts?.[tmid] : subscription?.draft, + tmid, + threadExists, + ); // Get parse options and pass it as prop to the RichTextComposer API // Colors and KaTeX are intentionally left out: gazzodown-alt has no renderer for those nodes, // so enabling them would make the typed text disappear. + // Emoticons are left out as well: the composer keeps them as the typed text. const customDomains = useAutoLinkDomains(); const parseOptions = useMemo( () => ({ - emoticons: true, + emoticons: false, customDomains, }), [customDomains], @@ -231,7 +239,59 @@ const RichTextMessageBox = ({ }); }; + const isEditing = useSyncExternalStore(chat.composer?.editing.subscribe ?? emptySubscribe, chat.composer?.editing.get ?? getEmptyFalse); + + const isRecordingAudio = useSyncExternalStore( + chat.composer?.recording.subscribe ?? emptySubscribe, + chat.composer?.recording.get ?? getEmptyFalse, + ); + + const isMicrophoneDenied = useSyncExternalStore( + chat.composer?.isMicrophoneDenied.subscribe ?? emptySubscribe, + chat.composer?.isMicrophoneDenied.get ?? getEmptyFalse, + ); + + const isRecordingVideo = useSyncExternalStore( + chat.composer?.recordingVideo.subscribe ?? emptySubscribe, + chat.composer?.recordingVideo.get ?? getEmptyFalse, + ); + + const formatters = useSyncExternalStore( + chat.composer?.formatters.subscribe ?? emptySubscribe, + chat.composer?.formatters.get ?? getEmptyArray, + ); + + const isRecording = isRecordingAudio || isRecordingVideo; + + const federationMatrixEnabled = useIsFederationEnabled(); + const subscribeSubscriptions = useCallback((onStoreChange: () => void) => Subscriptions.use.subscribe(onStoreChange), []); + const canSend = useSyncExternalStore(subscribeSubscriptions, () => { + if (!room.t) { + return false; + } + + if (!roomCoordinator.getRoomDirectives(room.t).canSendMessage(room)) { + return false; + } + + if (isRoomFederated(room)) { + if (!isRoomNativeFederated(room)) { + return false; + } + return federationMatrixEnabled; + } + return true; + }); + + // A contenteditable ignores `disabled`, so the handlers have to bail out themselves: the browser + // keeps firing keydown on an already-focused node after it stops being editable. + const disabled = isRecording || !canSend || isProcessingUploads; + const keyboardEventHandler = useStableCallback((event: KeyboardEvent) => { + if (disabled) { + return; + } + const { which: keyCode } = event; const input = event.target as HTMLDivElement; @@ -302,50 +362,6 @@ const RichTextMessageBox = ({ onTyping?.(); }); - const isEditing = useSyncExternalStore(chat.composer?.editing.subscribe ?? emptySubscribe, chat.composer?.editing.get ?? getEmptyFalse); - - const isRecordingAudio = useSyncExternalStore( - chat.composer?.recording.subscribe ?? emptySubscribe, - chat.composer?.recording.get ?? getEmptyFalse, - ); - - const isMicrophoneDenied = useSyncExternalStore( - chat.composer?.isMicrophoneDenied.subscribe ?? emptySubscribe, - chat.composer?.isMicrophoneDenied.get ?? getEmptyFalse, - ); - - const isRecordingVideo = useSyncExternalStore( - chat.composer?.recordingVideo.subscribe ?? emptySubscribe, - chat.composer?.recordingVideo.get ?? getEmptyFalse, - ); - - const formatters = useSyncExternalStore( - chat.composer?.formatters.subscribe ?? emptySubscribe, - chat.composer?.formatters.get ?? getEmptyArray, - ); - - const isRecording = isRecordingAudio || isRecordingVideo; - - const federationMatrixEnabled = useIsFederationEnabled(); - const subscribeSubscriptions = useCallback((onStoreChange: () => void) => Subscriptions.use.subscribe(onStoreChange), []); - const canSend = useSyncExternalStore(subscribeSubscriptions, () => { - if (!room.t) { - return false; - } - - if (!roomCoordinator.getRoomDirectives(room.t).canSendMessage(room)) { - return false; - } - - if (isRoomFederated(room)) { - if (!isRoomNativeFederated(room)) { - return false; - } - return federationMatrixEnabled; - } - return true; - }); - const newSizes = useContentBoxSize(contentEditableRef); const format = useFormatDateAndTime(); @@ -355,6 +371,11 @@ const RichTextMessageBox = ({ }); const handlePaste = useStableCallback((event: ClipboardEvent) => { + if (disabled) { + event.preventDefault(); + return; + } + const files = extractImageFilesFromClipboard(event, format); if (files.length) { @@ -449,10 +470,11 @@ const RichTextMessageBox = ({ ref={newMergedRefs} aria-label={composerPlaceholder} name='msg' - disabled={isRecording || !canSend || isProcessingUploads} + disabled={disabled} onInput={setTyping} placeholder={composerPlaceholder} hideplaceholder={hideplaceholder} + hidetext={isRecordingAudio} onPaste={handlePaste} aria-activedescendant={popup.focused ? `popup-item-${popup.focused._id}` : undefined} onBlur={setLastCursorPosition} diff --git a/packages/gazzodown-alt/src/ComposerBoldSpan.tsx b/packages/gazzodown-alt/src/ComposerBoldSpan.tsx index 0a871f4634e24..6c0ba3e9b6f52 100644 --- a/packages/gazzodown-alt/src/ComposerBoldSpan.tsx +++ b/packages/gazzodown-alt/src/ComposerBoldSpan.tsx @@ -1,62 +1,20 @@ import type * as MessageParser from '@rocket.chat/message-parser'; import type { ReactElement } from 'react'; -import ComposerCodeElement from './ComposerCodeElement'; -import ComposerEmojiElement from './ComposerEmojiElement'; -import ComposerItalicSpan from './ComposerItalicSpan'; -import ComposerLinkSpan from './ComposerLinkSpan'; -import ComposerMentionChannel from './ComposerMentionChannel'; -import ComposerMentionUser from './ComposerMentionUser'; -import ComposerPlainSpan from './ComposerPlainSpan'; -import ComposerStrikeSpan from './ComposerStrikeSpan'; - -type MessageBlock = - | MessageParser.Emoji - | MessageParser.ChannelMention - | MessageParser.UserMention - | MessageParser.Link - | MessageParser.MarkupExcluding - | MessageParser.InlineCode; +import ComposerInlineElements from './ComposerInlineElements'; type ComposerBoldSpanProps = { - children: MessageBlock[]; + children: MessageParser.Bold['value']; }; const ComposerBoldSpan = ({ children }: ComposerBoldSpanProps): ReactElement => ( <> - *{children.map((block, index) => renderBlockComponent(block, index))}* + * + + {children} + + * ); -const renderBlockComponent = (block: MessageBlock, index: number): ReactElement | null => { - switch (block.type) { - case 'EMOJI': - return ; - - case 'MENTION_USER': - return ; - - case 'MENTION_CHANNEL': - return ; - - case 'PLAIN_TEXT': - return ; - - case 'LINK': - return ; - - case 'STRIKE': - return {block.value}; - - case 'ITALIC': - return {block.value}; - - case 'INLINE_CODE': - return ; - - default: - return null; - } -}; - export default ComposerBoldSpan; diff --git a/packages/gazzodown-alt/src/ComposerCodeBlock.tsx b/packages/gazzodown-alt/src/ComposerCodeBlock.tsx index 5c719e81cb901..0879b03d42dac 100644 --- a/packages/gazzodown-alt/src/ComposerCodeBlock.tsx +++ b/packages/gazzodown-alt/src/ComposerCodeBlock.tsx @@ -8,14 +8,9 @@ type ComposerCodeBlockProps = { }; const codeBlockStyle = { - fontFamily: 'var(--rcx-font-family-mono, monospace)', - backgroundColor: 'var(--rcx-color-surface-tint, rgba(0, 0, 0, 0.05))', - borderRadius: '4px', - padding: '4px 8px', display: 'inline-block', + width: '100%', verticalAlign: 'top', - maxWidth: '100%', - whiteSpace: 'pre-wrap' as const, } as const; const ComposerCodeBlock = ({ language, lines }: ComposerCodeBlockProps): ReactElement => { @@ -25,7 +20,11 @@ const ComposerCodeBlock = ({ language, lines }: ComposerCodeBlockProps): ReactEl return `${fence}\n${code}\n\`\`\``; }, [language, lines]); - return {text}; + return ( + + {text} + + ); }; export default ComposerCodeBlock; diff --git a/packages/gazzodown-alt/src/ComposerCodeElement.tsx b/packages/gazzodown-alt/src/ComposerCodeElement.tsx index 91584cfecd632..52652fdd03a4b 100644 --- a/packages/gazzodown-alt/src/ComposerCodeElement.tsx +++ b/packages/gazzodown-alt/src/ComposerCodeElement.tsx @@ -4,16 +4,9 @@ type ComposerCodeElementProps = { code: string; }; -const codeStyle = { - fontFamily: 'var(--rcx-font-family-mono, monospace)', - backgroundColor: 'var(--rcx-color-surface-tint, rgba(0, 0, 0, 0.05))', - borderRadius: '3px', - padding: '0 4px', -} as const; - const ComposerCodeElement = ({ code }: ComposerCodeElementProps): ReactElement => ( <> - `{code}` + `{code}` ); diff --git a/packages/gazzodown-alt/src/ComposerEmojiElement.tsx b/packages/gazzodown-alt/src/ComposerEmojiElement.tsx deleted file mode 100644 index 5b1a425edae48..0000000000000 --- a/packages/gazzodown-alt/src/ComposerEmojiElement.tsx +++ /dev/null @@ -1,34 +0,0 @@ -import type * as MessageParser from '@rocket.chat/message-parser'; -import type { ReactElement } from 'react'; -import { memo, useContext } from 'react'; - -import { ComposerMarkupContext } from './ComposerMarkupContext'; - -type ComposerEmojiElementProps = MessageParser.Emoji; - -const ComposerEmojiElement = (emoji: ComposerEmojiElementProps): ReactElement => { - const { detectEmoji } = useContext(ComposerMarkupContext); - - const fallback = 'unicode' in emoji ? emoji.unicode : `:${('shortCode' in emoji && emoji.shortCode) || ''}:`; - - const descriptors = detectEmoji?.(fallback); - if (descriptors && descriptors.length > 0) { - return ( - <> - {descriptors.map(({ name, className, image, content }, i) => ( - - {content} - - ))} - - ); - } - - return ( - - {fallback} - - ); -}; - -export default memo(ComposerEmojiElement); diff --git a/packages/gazzodown-alt/src/ComposerInlineElements.tsx b/packages/gazzodown-alt/src/ComposerInlineElements.tsx index 91729dbcab970..7a63162e7101b 100644 --- a/packages/gazzodown-alt/src/ComposerInlineElements.tsx +++ b/packages/gazzodown-alt/src/ComposerInlineElements.tsx @@ -1,70 +1,64 @@ import type * as MessageParser from '@rocket.chat/message-parser'; import type { ReactElement } from 'react'; +import { useContext } from 'react'; import ComposerBoldSpan from './ComposerBoldSpan'; import ComposerCodeElement from './ComposerCodeElement'; -import ComposerEmojiElement from './ComposerEmojiElement'; import ComposerItalicSpan from './ComposerItalicSpan'; -import ComposerLinkSpan from './ComposerLinkSpan'; +import { ComposerMarkupContext } from './ComposerMarkupContext'; import ComposerMentionChannel from './ComposerMentionChannel'; import ComposerMentionUser from './ComposerMentionUser'; import ComposerPlainSpan from './ComposerPlainSpan'; import ComposerSpoilerSpan from './ComposerSpoilerSpan'; import ComposerStrikeSpan from './ComposerStrikeSpan'; +import { sourceOf } from './sourceOf'; type ComposerInlineElementsProps = { children: (MessageParser.Inlines | { fallback: MessageParser.Plain; type: undefined })[]; }; -const ComposerInlineElements = ({ children }: ComposerInlineElementsProps): ReactElement => ( - <> - {children.map((child, index) => { - switch (child.type) { - case 'BOLD': - return {child.value}; +const ComposerInlineElements = ({ children }: ComposerInlineElementsProps): ReactElement => { + const { source = '' } = useContext(ComposerMarkupContext); - case 'STRIKE': - return {child.value}; + return ( + <> + {children.map((child, index) => { + switch (child.type) { + case 'BOLD': + return {child.value}; - case 'ITALIC': - return {child.value}; + case 'STRIKE': + return {child.value}; - case 'SPOILER': - return {child.value}; + case 'ITALIC': + return {child.value}; - case 'LINK': - return ( - - ); + case 'SPOILER': + return {child.value}; - case 'PLAIN_TEXT': - return ; + case 'PLAIN_TEXT': + return ; - case 'MENTION_USER': - return ; + case 'MENTION_USER': + return ; - case 'MENTION_CHANNEL': - return ; + case 'MENTION_CHANNEL': + return ; - case 'INLINE_CODE': - return ; + case 'INLINE_CODE': + return ; - case 'EMOJI': - return ; + default: { + if (child.type === undefined) { + return ; + } - default: { - if (child.type === undefined) { - return ; + return ; } - return null; } - } - })} - -); + })} + + ); +}; export default ComposerInlineElements; diff --git a/packages/gazzodown-alt/src/ComposerItalicSpan.tsx b/packages/gazzodown-alt/src/ComposerItalicSpan.tsx index b265e37017ba7..c6d8a9864ff89 100644 --- a/packages/gazzodown-alt/src/ComposerItalicSpan.tsx +++ b/packages/gazzodown-alt/src/ComposerItalicSpan.tsx @@ -1,62 +1,20 @@ import type * as MessageParser from '@rocket.chat/message-parser'; import type { ReactElement } from 'react'; -import ComposerBoldSpan from './ComposerBoldSpan'; -import ComposerCodeElement from './ComposerCodeElement'; -import ComposerEmojiElement from './ComposerEmojiElement'; -import ComposerLinkSpan from './ComposerLinkSpan'; -import ComposerMentionChannel from './ComposerMentionChannel'; -import ComposerMentionUser from './ComposerMentionUser'; -import ComposerPlainSpan from './ComposerPlainSpan'; -import ComposerStrikeSpan from './ComposerStrikeSpan'; - -type MessageBlock = - | MessageParser.Emoji - | MessageParser.ChannelMention - | MessageParser.UserMention - | MessageParser.Link - | MessageParser.MarkupExcluding - | MessageParser.InlineCode; +import ComposerInlineElements from './ComposerInlineElements'; type ComposerItalicSpanProps = { - children: MessageBlock[]; + children: MessageParser.Italic['value']; }; const ComposerItalicSpan = ({ children }: ComposerItalicSpanProps): ReactElement => ( <> - _{children.map((block, index) => renderBlockComponent(block, index))}_ + _ + + {children} + + _ ); -const renderBlockComponent = (block: MessageBlock, index: number): ReactElement | null => { - switch (block.type) { - case 'EMOJI': - return ; - - case 'MENTION_USER': - return ; - - case 'MENTION_CHANNEL': - return ; - - case 'PLAIN_TEXT': - return ; - - case 'LINK': - return ; - - case 'STRIKE': - return {block.value}; - - case 'BOLD': - return {block.value}; - - case 'INLINE_CODE': - return ; - - default: - return null; - } -}; - export default ComposerItalicSpan; diff --git a/packages/gazzodown-alt/src/ComposerLinkSpan.tsx b/packages/gazzodown-alt/src/ComposerLinkSpan.tsx deleted file mode 100644 index ef2f439e9e4bd..0000000000000 --- a/packages/gazzodown-alt/src/ComposerLinkSpan.tsx +++ /dev/null @@ -1,37 +0,0 @@ -import type * as MessageParser from '@rocket.chat/message-parser'; -import type { ReactElement } from 'react'; - -import ComposerBoldSpan from './ComposerBoldSpan'; -import ComposerItalicSpan from './ComposerItalicSpan'; -import ComposerPlainSpan from './ComposerPlainSpan'; -import ComposerStrikeSpan from './ComposerStrikeSpan'; - -type ComposerLinkSpanProps = { - href: string; - label: MessageParser.Markup | MessageParser.Markup[]; -}; - -const ComposerLinkSpan = ({ href, label }: ComposerLinkSpanProps): ReactElement => { - const labelArray = Array.isArray(label) ? label : [label]; - - return ( - - {labelArray.map((child, index) => { - switch (child.type) { - case 'PLAIN_TEXT': - return ; - case 'STRIKE': - return {child.value}; - case 'ITALIC': - return {child.value}; - case 'BOLD': - return {child.value}; - default: - return null; - } - })} - - ); -}; - -export default ComposerLinkSpan; diff --git a/packages/gazzodown-alt/src/ComposerMarkup.tsx b/packages/gazzodown-alt/src/ComposerMarkup.tsx index 8abbdcd00534c..fe5d28381d15c 100644 --- a/packages/gazzodown-alt/src/ComposerMarkup.tsx +++ b/packages/gazzodown-alt/src/ComposerMarkup.tsx @@ -1,10 +1,12 @@ import type * as MessageParser from '@rocket.chat/message-parser'; import type { ReactElement } from 'react'; -import { memo } from 'react'; +import { memo, useContext } from 'react'; import ComposerCodeBlock from './ComposerCodeBlock'; import ComposerInlineElements from './ComposerInlineElements'; +import { ComposerMarkupContext } from './ComposerMarkupContext'; import ComposerPlainSpan from './ComposerPlainSpan'; +import { sourceOf } from './sourceOf'; type ComposerMarkupProps = { tokens: MessageParser.Root; @@ -21,67 +23,126 @@ type ComposerMarkupProps = { * * It consumes the same AST produced by `@rocket.chat/message-parser` (grammar.pegjs), * making it a drop-in replacement for the rendering layer. + * + * Every renderer must emit the exact text it was parsed from: caret positions are flat character + * offsets over the rendered text, so adding or dropping a character misplaces the caret. Nodes with + * no visual renderer fall back to their literal markup through `sourceOf`. */ -const ComposerMarkup = ({ tokens }: ComposerMarkupProps): ReactElement => ( - <> - {tokens.map((block, index) => { - switch (block.type) { - case 'PARAGRAPH': - return ( - - {block.value} - {'\n'} - - ); - - case 'HEADING': - return ( - - {`${'#'.repeat(block.level)} `} - {block.value.map((plain, pidx) => ( - - ))} - {'\n'} - - ); - - case 'QUOTE': - return ( - - {block.value.map((paragraph, pidx) => ( - - {'> '} - {paragraph.value} - {'\n'} - - ))} - - ); - - case 'SPOILER_BLOCK': - return ( - - {block.value.map((paragraph, pidx) => ( - - {paragraph.value} - {'\n'} - - ))} - - ); - - case 'CODE': - return ; - - case 'LINE_BREAK': - return {'\n'}; - - default: - return null; - } - })} - -); +const ComposerMarkup = ({ tokens }: ComposerMarkupProps): ReactElement => { + const { source = '' } = useContext(ComposerMarkupContext); + + // Blocks consume their own line ending, but a node rebuilt from the source may or may not carry it. + const blockSource = (block: MessageParser.HorizontalRule | MessageParser.Table): string => { + const text = sourceOf(block, source); + + return text.endsWith('\n') ? text : `${text}\n`; + }; + + return ( + <> + {tokens.map((block, index) => { + switch (block.type) { + case 'PARAGRAPH': + return ( + + {block.value} + {'\n'} + + ); + + case 'HEADING': + return ( + + {`${'#'.repeat(block.level)} `} + {block.value} + {'\n'} + + ); + + case 'QUOTE': + return ( + + {block.value.map((paragraph, pidx) => ( + + {'> '} + {paragraph.value} + {'\n'} + + ))} + + ); + + case 'SPOILER_BLOCK': + return ( + + {block.value.map((paragraph, pidx) => ( + + {paragraph.value} + {'\n'} + + ))} + + ); + + case 'CODE': + return ; + + case 'UNORDERED_LIST': + return ( + + {block.value.map((item, iidx) => ( + + {'- '} + {item.value} + {'\n'} + + ))} + + ); + + case 'ORDERED_LIST': + return ( + + {block.value.map((item, iidx) => ( + + {`${item.number}. `} + {item.value} + {'\n'} + + ))} + + ); + + case 'TASKS': + return ( + + {block.value.map((task, tidx) => ( + + {task.status ? '- [x] ' : '- [ ] '} + {task.value} + {'\n'} + + ))} + + ); + + case 'HORIZONTAL_RULE': + case 'TABLE': + return ; + + case 'BIG_EMOJI': + return ; + + case 'LINE_BREAK': + return {'\n'}; + + default: + return null; + } + })} + + ); +}; const headingStyles: Record<1 | 2 | 3 | 4, React.CSSProperties> = { 1: { fontWeight: 'bold', fontSize: '1.5em' }, diff --git a/packages/gazzodown-alt/src/ComposerMarkupContext.ts b/packages/gazzodown-alt/src/ComposerMarkupContext.ts index 58a59c017b858..d6478a92e1d81 100644 --- a/packages/gazzodown-alt/src/ComposerMarkupContext.ts +++ b/packages/gazzodown-alt/src/ComposerMarkupContext.ts @@ -1,7 +1,8 @@ import { createContext } from 'react'; export type ComposerMarkupContextValue = { - detectEmoji?: (text: string) => { name: string; className: string; image?: string; content: string }[]; + // Raw text the AST was parsed from, used to recover the markup of nodes with no renderer. + source?: string; // TODO: renderComposerMarkup does not supply these yet, so mentions render as raw @name/#name. // Composer mentions must resolve exactly as the message list does before GA. resolveUserMention?: (mention: string) => { _id: string; username?: string; name?: string } | undefined; diff --git a/packages/gazzodown-alt/src/ComposerMentionChannel.tsx b/packages/gazzodown-alt/src/ComposerMentionChannel.tsx index 8afeb16e18bd5..65955b70f8465 100644 --- a/packages/gazzodown-alt/src/ComposerMentionChannel.tsx +++ b/packages/gazzodown-alt/src/ComposerMentionChannel.tsx @@ -7,11 +7,7 @@ type ComposerMentionChannelProps = { mention: string; }; -const mentionStyle = { - fontWeight: 'bold' as const, - color: 'var(--rcx-color-font-info, #156FF5)', - cursor: 'default', -}; +const className = 'rcx-message__highlight rcx-message__highlight--link'; const ComposerMentionChannel = ({ mention }: ComposerMentionChannelProps): ReactElement => { const { resolveChannelMention } = useContext(ComposerMarkupContext); @@ -19,10 +15,10 @@ const ComposerMentionChannel = ({ mention }: ComposerMentionChannelProps): React const resolved = useMemo(() => resolveChannelMention?.(mention), [mention, resolveChannelMention]); if (!resolved) { - return #{mention}; + return #{mention}; } - return #{resolved.fname ?? mention}; + return #{resolved.fname ?? mention}; }; export default memo(ComposerMentionChannel); diff --git a/packages/gazzodown-alt/src/ComposerMentionUser.tsx b/packages/gazzodown-alt/src/ComposerMentionUser.tsx index 4f0e961f78da3..935b9ce5fc8a1 100644 --- a/packages/gazzodown-alt/src/ComposerMentionUser.tsx +++ b/packages/gazzodown-alt/src/ComposerMentionUser.tsx @@ -7,23 +7,21 @@ type ComposerMentionUserProps = { mention: string; }; -const mentionStyle = { - fontWeight: 'bold' as const, - color: 'var(--rcx-color-font-info, #156FF5)', - cursor: 'default', -}; +const highlightClassName = (variant: 'relevant' | 'other'): string => `rcx-message__highlight rcx-message__highlight--${variant}`; const ComposerMentionUser = ({ mention }: ComposerMentionUserProps): ReactElement => { const { resolveUserMention } = useContext(ComposerMarkupContext); const resolved = useMemo(() => resolveUserMention?.(mention), [mention, resolveUserMention]); + const className = highlightClassName(mention === 'all' || mention === 'here' ? 'relevant' : 'other'); + if (!resolved) { - return @{mention}; + return @{mention}; } return ( - + @{resolved.username ?? mention} ); diff --git a/packages/gazzodown-alt/src/ComposerStrikeSpan.tsx b/packages/gazzodown-alt/src/ComposerStrikeSpan.tsx index 62eaa2de2e315..0f5a3232b5d5e 100644 --- a/packages/gazzodown-alt/src/ComposerStrikeSpan.tsx +++ b/packages/gazzodown-alt/src/ComposerStrikeSpan.tsx @@ -1,63 +1,20 @@ import type * as MessageParser from '@rocket.chat/message-parser'; import type { ReactElement } from 'react'; -import ComposerBoldSpan from './ComposerBoldSpan'; -import ComposerCodeElement from './ComposerCodeElement'; -import ComposerEmojiElement from './ComposerEmojiElement'; -import ComposerItalicSpan from './ComposerItalicSpan'; -import ComposerLinkSpan from './ComposerLinkSpan'; -import ComposerMentionChannel from './ComposerMentionChannel'; -import ComposerMentionUser from './ComposerMentionUser'; -import ComposerPlainSpan from './ComposerPlainSpan'; - -type MessageBlock = - | MessageParser.Timestamp - | MessageParser.Emoji - | MessageParser.ChannelMention - | MessageParser.UserMention - | MessageParser.Link - | MessageParser.MarkupExcluding - | MessageParser.InlineCode; +import ComposerInlineElements from './ComposerInlineElements'; type ComposerStrikeSpanProps = { - children: MessageBlock[]; + children: MessageParser.Strike['value']; }; const ComposerStrikeSpan = ({ children }: ComposerStrikeSpanProps): ReactElement => ( <> - ~{children.map((block, index) => renderBlockComponent(block, index))}~ + ~ + + {children} + + ~ ); -const renderBlockComponent = (block: MessageBlock, index: number): ReactElement | null => { - switch (block.type) { - case 'EMOJI': - return ; - - case 'MENTION_USER': - return ; - - case 'MENTION_CHANNEL': - return ; - - case 'PLAIN_TEXT': - return ; - - case 'LINK': - return ; - - case 'ITALIC': - return {block.value}; - - case 'BOLD': - return {block.value}; - - case 'INLINE_CODE': - return ; - - default: - return null; - } -}; - export default ComposerStrikeSpan; diff --git a/packages/gazzodown-alt/src/sourceOf.ts b/packages/gazzodown-alt/src/sourceOf.ts new file mode 100644 index 0000000000000..8a7bc94fdd411 --- /dev/null +++ b/packages/gazzodown-alt/src/sourceOf.ts @@ -0,0 +1,91 @@ +import type * as MessageParser from '@rocket.chat/message-parser'; + +export type SourceNode = MessageParser.Inlines | MessageParser.Blocks | MessageParser.BigEmoji; + +const fallbackSlice = (fallback: MessageParser.SourceRange | MessageParser.Plain | undefined, source: string): string => { + if (!fallback) { + return ''; + } + + if (Array.isArray(fallback)) { + return source.slice(fallback[0], fallback[1]); + } + + return fallback.value; +}; + +const emojiSource = (emoji: MessageParser.Emoji): string => { + if ('unicode' in emoji) { + return emoji.unicode; + } + + const literal = emoji.value?.value; + + return literal && literal !== emoji.shortCode ? literal : `:${emoji.shortCode}:`; +}; + +// Rebuild the markup a node was parsed from, so nodes the composer has no renderer for still show +// their literal text instead of disappearing. Returns '' when the source cannot be recovered; the +// caller's text guard then falls back to the raw input. +export const sourceOf = (node: SourceNode, source: string): string => { + const inner = (nodes: SourceNode[]): string => nodes.map((child) => sourceOf(child, source)).join(''); + + switch (node.type) { + case 'PLAIN_TEXT': + return node.value; + + case 'BOLD': + return `*${inner(node.value)}*`; + + case 'ITALIC': + return `_${inner(node.value)}_`; + + case 'STRIKE': + return `~${inner(node.value)}~`; + + case 'SPOILER': + return `||${inner(node.value)}||`; + + case 'INLINE_CODE': + return `\`${node.value.value}\``; + + case 'MENTION_USER': + return `@${node.value.value}`; + + case 'MENTION_CHANNEL': + return `#${node.value.value}`; + + case 'EMOJI': + return emojiSource(node); + + case 'BIG_EMOJI': + return node.value.map(emojiSource).join(''); + + case 'LINK': { + const src = node.value.src.value; + const label = inner(Array.isArray(node.value.label) ? node.value.label : [node.value.label]); + + // Autolinked URLs and emails keep the typed text as their label, with the scheme added to the href. + if (src === label || src === `//${label}` || src === `mailto:${label}`) { + return label; + } + + return `[${label}](${src})`; + } + + case 'IMAGE': { + const src = node.value.src.value; + const label = inner([node.value.label]); + + return label === src ? `![](${src})` : `![${label}](${src})`; + } + + case 'TIMESTAMP': + case 'HORIZONTAL_RULE': + case 'TABLE': + return fallbackSlice(node.fallback, source); + + default: + return ''; + } +}; diff --git a/packages/ui-composer/src/MessageComposer/RichTextComposerInput.spec.tsx b/packages/ui-composer/src/MessageComposer/RichTextComposerInput.spec.tsx new file mode 100644 index 0000000000000..e405281d0232c --- /dev/null +++ b/packages/ui-composer/src/MessageComposer/RichTextComposerInput.spec.tsx @@ -0,0 +1,37 @@ +import { render, screen } from '@testing-library/react'; + +import RichTextComposerInput from './RichTextComposerInput'; + +const setup = (props: Record = {}) => + render(); + +test('should be editable by default', () => { + setup(); + + expect(screen.getByLabelText('Message')).toHaveAttribute('contenteditable', 'true'); + expect(screen.getByLabelText('Message')).not.toHaveAttribute('aria-disabled', 'true'); +}); + +test('should stop being editable when disabled', () => { + setup({ disabled: true }); + + const input = screen.getByLabelText('Message'); + + expect(input).toHaveAttribute('contenteditable', 'false'); + expect(input).toHaveAttribute('aria-disabled', 'true'); + // `disabled` is inert on a contenteditable, so it must not be the only thing rendered. + expect(input).not.toHaveAttribute('disabled'); +}); + +test('should hide the text and the placeholder when hidetext is set', () => { + setup({ hidetext: true }); + + expect(screen.getByLabelText('Message')).not.toBeVisible(); + expect(screen.getByText('Type a message...')).not.toBeVisible(); +}); + +test('should keep the text visible when hidetext is not set', () => { + setup(); + + expect(screen.getByLabelText('Message')).toBeVisible(); +}); diff --git a/packages/ui-composer/src/MessageComposer/RichTextComposerInput.tsx b/packages/ui-composer/src/MessageComposer/RichTextComposerInput.tsx index 7bc500aa6af07..547cd01e4f299 100644 --- a/packages/ui-composer/src/MessageComposer/RichTextComposerInput.tsx +++ b/packages/ui-composer/src/MessageComposer/RichTextComposerInput.tsx @@ -13,14 +13,16 @@ const RichTextComposerInputStyle = css` type RichTextComposerInputProps = ComponentProps & { placeholder?: string; hideplaceholder?: boolean; + hidetext?: boolean; }; const RichTextComposerInput = forwardRef(function RichTextComposerInput(props, ref) { - // Supress warnings related to hideplaceholder being invalid DOM prop - const { placeholder, hideplaceholder, ...rest } = props; + // Supress warnings related to hideplaceholder/hidetext being invalid DOM props. + // `disabled` is inert on a contenteditable, so it drives contentEditable instead of reaching the DOM. + const { placeholder, hideplaceholder, hidetext, disabled, ...rest } = props; return ( - +