diff --git a/src/script/components/RichTextEditor/RichTextEditor.tsx b/src/script/components/RichTextEditor/RichTextEditor.tsx
index 6f9c6a2f24e..a8492aa3c11 100644
--- a/src/script/components/RichTextEditor/RichTextEditor.tsx
+++ b/src/script/components/RichTextEditor/RichTextEditor.tsx
@@ -46,6 +46,7 @@ import {FormatToolbar} from './components/FormatToolbar/FormatToolbar';
import {EmojiNode} from './nodes/EmojiNode';
import {MentionNode} from './nodes/MentionNode';
import {AutoFocusPlugin} from './plugins/AutoFocusPlugin';
+import {BlockquotePlugin} from './plugins/BlockquotePlugin/BlockquotePlugin';
import {CodeHighlightPlugin} from './plugins/CodeHighlightPlugin/CodeHighlightPlugin';
import {DraftStatePlugin} from './plugins/DraftStatePlugin';
import {EditedMessagePlugin} from './plugins/EditedMessagePlugin/EditedMessagePlugin';
@@ -78,6 +79,7 @@ const theme = {
strikethrough: 'editor-strikethrough',
code: 'editor-inline-code',
},
+ quote: 'editor-quote',
list: {
ul: 'editor-list editor-list-unordered',
ol: 'editor-list editor-list-ordered',
@@ -197,6 +199,7 @@ const editorConfig: InitialConfigType = {
CodeNode,
CodeHighlightNode,
LinkNode,
+ QuoteNode,
],
};
@@ -256,17 +259,18 @@ export const RichTextEditor = ({
-
- {replaceEmojis && }
+ {replaceEmojis && }
{showMarkdownPreview && (
<>
+
+
>
)}
@@ -275,13 +279,11 @@ export const RichTextEditor = ({
placeholder={}
ErrorBoundary={LexicalErrorBoundary}
/>
-
(typeof search === 'string' ? getMentionCandidates(search) : [])}
openStateRef={mentionsOpen}
/>
-
{
diff --git a/src/script/components/RichTextEditor/plugins/BlockquotePlugin/BlockquotePlugin.tsx b/src/script/components/RichTextEditor/plugins/BlockquotePlugin/BlockquotePlugin.tsx
new file mode 100644
index 00000000000..1484594d9aa
--- /dev/null
+++ b/src/script/components/RichTextEditor/plugins/BlockquotePlugin/BlockquotePlugin.tsx
@@ -0,0 +1,136 @@
+/*
+ * Wire
+ * Copyright (C) 2025 Wire Swiss GmbH
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program. If not, see http://www.gnu.org/licenses/.
+ *
+ */
+
+import {useEffect} from 'react';
+
+import {useLexicalComposerContext} from '@lexical/react/LexicalComposerContext';
+import {$isQuoteNode} from '@lexical/rich-text';
+import {
+ COMMAND_PRIORITY_LOW,
+ KEY_ENTER_COMMAND,
+ $getSelection,
+ $isRangeSelection,
+ KEY_BACKSPACE_COMMAND,
+ $isLineBreakNode,
+ INSERT_PARAGRAPH_COMMAND,
+ LexicalEditor,
+ INSERT_LINE_BREAK_COMMAND,
+} from 'lexical';
+
+export const BlockquotePlugin = (): null => {
+ const [editor] = useLexicalComposerContext();
+
+ useEffect(() => {
+ return registerBlockquoteEnterCommand(editor);
+ }, [editor]);
+
+ useEffect(() => {
+ return registerBlockquoteBackspaceCommand(editor);
+ }, [editor]);
+
+ return null;
+};
+
+/**
+ * Because we use a custom Shift + Enter command (see SendPlugin.tsx), we need to register a custom Shify + Enter command for the blockquote.
+ * By default our Shift + Enter adds a new paragraph, which escapes the blockquote, prevents for adding multiline quotes.
+ * This command will add a new line break instead of a new paragraph, which will keep the blockquote.
+ */
+const registerBlockquoteEnterCommand = (editor: LexicalEditor) => {
+ return editor.registerCommand(
+ KEY_ENTER_COMMAND,
+ event => {
+ if (!event) {
+ return false;
+ }
+
+ const selection = $getSelection();
+
+ if (!$isRangeSelection(selection)) {
+ return false;
+ }
+
+ const anchorNode = selection.anchor.getNode();
+ const quoteBlock = anchorNode.getParent();
+
+ if (!$isQuoteNode(quoteBlock) && !$isQuoteNode(anchorNode)) {
+ return false;
+ }
+
+ event.preventDefault();
+
+ if (event.shiftKey) {
+ editor.update(() => {
+ editor.dispatchCommand(INSERT_LINE_BREAK_COMMAND, false);
+ });
+ }
+
+ return true;
+ },
+ COMMAND_PRIORITY_LOW,
+ );
+};
+
+/**
+ * Because we use a custom Shift + Enter for the blockquotes, we no longer have an abilitiy to escape a blockquote by pressing Shift + Enter (cause the above command adds a new line break).
+ * This command will remove the last line break in the blockquote and add a new paragraph, which will escape the blockquote.
+ */
+const registerBlockquoteBackspaceCommand = (editor: LexicalEditor) => {
+ return editor.registerCommand(
+ KEY_BACKSPACE_COMMAND,
+ event => {
+ const selection = $getSelection();
+
+ if (!$isRangeSelection(selection)) {
+ return false;
+ }
+
+ event.preventDefault();
+
+ const anchorNode = selection.anchor.getNode();
+ const quoteBlock = anchorNode.getParent();
+
+ if (!$isQuoteNode(quoteBlock) && !$isQuoteNode(anchorNode)) {
+ return false;
+ }
+
+ if (!('getChildren' in anchorNode)) {
+ return false;
+ }
+
+ const children = anchorNode.getChildren();
+
+ const lastChild = children?.[children.length - 1];
+
+ const isLastChildLineBreakNode = $isLineBreakNode(lastChild);
+
+ if (!isLastChildLineBreakNode) {
+ return false;
+ }
+
+ editor.update(() => {
+ lastChild.remove();
+ editor.dispatchCommand(INSERT_PARAGRAPH_COMMAND, undefined);
+ });
+
+ return true;
+ },
+ COMMAND_PRIORITY_LOW,
+ );
+};
diff --git a/src/script/components/RichTextEditor/utils/markdownTransformers.ts b/src/script/components/RichTextEditor/utils/markdownTransformers.ts
index 8c1cf0bf221..9013f9ea212 100644
--- a/src/script/components/RichTextEditor/utils/markdownTransformers.ts
+++ b/src/script/components/RichTextEditor/utils/markdownTransformers.ts
@@ -27,6 +27,7 @@ import {
ORDERED_LIST,
STRIKETHROUGH,
UNORDERED_LIST,
+ QUOTE,
} from '@lexical/markdown';
export const markdownTransformers = [
@@ -39,4 +40,5 @@ export const markdownTransformers = [
INLINE_CODE,
ITALIC_STAR,
STRIKETHROUGH,
+ QUOTE,
];
diff --git a/src/script/util/messageRenderer.test.ts b/src/script/util/messageRenderer.test.ts
index b1d20a1bb1c..2934207b676 100644
--- a/src/script/util/messageRenderer.test.ts
+++ b/src/script/util/messageRenderer.test.ts
@@ -552,11 +552,6 @@ describe('Ignored Markdown syntax', () => {
expect(renderMessage('no h2\n---')).toBe('no h2
---');
});
- it('does not render blockquotes', () => {
- expect(renderMessage('>no blockquote')).toBe('>no blockquote');
- expect(renderMessage('> no blockquote')).toBe('> no blockquote');
- });
-
it('does not render tables', () => {
const input = 'First Header | Second Header\n------------ | -------------\nCell 1 | Cell 2';
const expected = 'First Header | Second Header
------------ | -------------
Cell 1 | Cell 2';
diff --git a/src/script/util/messageRenderer.ts b/src/script/util/messageRenderer.ts
index 9ddcb316658..90594a11159 100644
--- a/src/script/util/messageRenderer.ts
+++ b/src/script/util/messageRenderer.ts
@@ -56,6 +56,7 @@ const markdownit = new MarkdownIt('zero', {
'newline',
'list',
'strikethrough',
+ 'blockquote',
]);
const originalFenceRule = markdownit.renderer.rules.fence!;
@@ -84,6 +85,9 @@ markdownit.normalizeLink = (url: string): string => {
return url;
};
+markdownit.renderer.rules.blockquote_open = () => '';
+markdownit.renderer.rules.blockquote_close = () => '
';
+
markdownit.renderer.rules.softbreak = () => '
';
markdownit.renderer.rules.hardbreak = () => '
';
markdownit.renderer.rules.paragraph_open = (tokens, idx) => {
diff --git a/src/style/components/lexical-input.less b/src/style/components/lexical-input.less
index 5240d8264b3..ad2e947f38a 100644
--- a/src/style/components/lexical-input.less
+++ b/src/style/components/lexical-input.less
@@ -258,6 +258,25 @@
}
}
+.editor-quote {
+ position: relative;
+ padding: 0 12px 0 16px;
+ margin: 0;
+ margin-left: 2px;
+
+ &::before {
+ position: absolute;
+ top: 0;
+ left: 0;
+ width: 4px;
+ height: 100%;
+ border-radius: 2px;
+ margin-right: 12px;
+ background-color: var(--message-quote-bg);
+ content: '';
+ }
+}
+
// Lexical code highlighting
// Prism One Light Theme - based on node_modules/prism-themes/themes/prism-one-light.css
diff --git a/src/style/content/conversation/input-bar.less b/src/style/content/conversation/input-bar.less
index b46fde823d1..2f48807c685 100644
--- a/src/style/content/conversation/input-bar.less
+++ b/src/style/content/conversation/input-bar.less
@@ -351,6 +351,10 @@
padding: 0 16px;
margin: 0;
}
+
+ blockquote {
+ margin: 0;
+ }
}
.md-heading {
diff --git a/src/style/content/conversation/message-list.less b/src/style/content/conversation/message-list.less
index 7d930ea6e9b..a1a7b070a68 100644
--- a/src/style/content/conversation/message-list.less
+++ b/src/style/content/conversation/message-list.less
@@ -372,6 +372,24 @@
display: none;
}
}
+
+ .md-blockquote {
+ position: relative;
+ padding: 0 12px 0 16px;
+ margin: 0;
+
+ &::before {
+ position: absolute;
+ top: 0;
+ left: 0;
+ width: 4px;
+ height: 100%;
+ border-radius: 2px;
+ margin-right: 12px;
+ background-color: var(--message-quote-bg);
+ content: '';
+ }
+ }
}
.message-body-like {