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 '],
+ ['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 ? `` : ``;
+ }
+
+ 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 (
-
+