diff --git a/__tests__/components/waves/drops/EditDropLexical.test.tsx b/__tests__/components/waves/drops/EditDropLexical.test.tsx
index 916454152d..3e1c74b2d5 100644
--- a/__tests__/components/waves/drops/EditDropLexical.test.tsx
+++ b/__tests__/components/waves/drops/EditDropLexical.test.tsx
@@ -1,14 +1,18 @@
-import React from 'react';
-import { act, render, screen } from '@testing-library/react';
-import userEvent from '@testing-library/user-event';
-import EditDropLexical from '@/components/waves/drops/EditDropLexical';
-import type { ApiDropMentionedUser } from '@/generated/models/ApiDropMentionedUser';
+import React from "react";
+import { act, render, screen } from "@testing-library/react";
+import userEvent from "@testing-library/user-event";
+import type { ApiDropMentionedUser } from "@/generated/models/ApiDropMentionedUser";
type MentionSelectHandler = (user: {
mentioned_profile_id: string;
handle_in_content: string;
}) => void;
+const useDeviceInfoMock = jest.fn(() => ({
+ isApp: false,
+ isMobileDevice: false,
+}));
+
const rootMock = {
getChildren: jest.fn(() => [] as unknown[]),
getAllTextNodes: jest.fn(() => [] as unknown[]),
@@ -26,32 +30,32 @@ const editorMock = {
let mentionSelectHandler: MentionSelectHandler | null = null;
-jest.mock('@lexical/list', () => ({
+jest.mock("@lexical/list", () => ({
ListNode: class MockListNode {},
ListItemNode: class MockListItemNode {},
}));
-jest.mock('@lexical/rich-text', () => ({
+jest.mock("@lexical/rich-text", () => ({
HeadingNode: class MockHeadingNode {},
QuoteNode: class MockQuoteNode {},
}));
-jest.mock('@lexical/react/LexicalHorizontalRuleNode', () => ({
+jest.mock("@lexical/react/LexicalHorizontalRuleNode", () => ({
HorizontalRuleNode: class MockHorizontalRuleNode {},
}));
-jest.mock('@lexical/code', () => ({
+jest.mock("@lexical/code", () => ({
CodeHighlightNode: class MockCodeHighlightNode {},
CodeNode: class MockCodeNode {},
$isCodeNode: () => false,
}));
-jest.mock('@lexical/link', () => ({
+jest.mock("@lexical/link", () => ({
AutoLinkNode: class MockAutoLinkNode {},
LinkNode: class MockLinkNode {},
}));
-jest.mock('@lexical/react/LexicalComposer', () => ({
+jest.mock("@lexical/react/LexicalComposer", () => ({
LexicalComposer: ({ children }: any) => (
{contentEditable}
@@ -59,7 +63,7 @@ jest.mock('@lexical/react/LexicalRichTextPlugin', () => ({
),
}));
-jest.mock('@lexical/react/LexicalContentEditable', () => ({
+jest.mock("@lexical/react/LexicalContentEditable", () => ({
ContentEditable: ({ className, style }: any) => (
({
/>
),
}));
-jest.mock('@lexical/react/LexicalErrorBoundary', () => ({
+jest.mock("@lexical/react/LexicalErrorBoundary", () => ({
__esModule: true,
default: () => null,
}));
-jest.mock('@lexical/react/LexicalHistoryPlugin', () => ({
+jest.mock("@lexical/react/LexicalHistoryPlugin", () => ({
HistoryPlugin: () => null,
}));
-jest.mock('@lexical/react/LexicalOnChangePlugin', () => ({
+jest.mock("@lexical/react/LexicalOnChangePlugin", () => ({
OnChangePlugin: ({ onChange }: any) => {
React.useEffect(() => {
const mockEditorState = {
@@ -88,98 +92,130 @@ jest.mock('@lexical/react/LexicalOnChangePlugin', () => ({
return null;
},
}));
-jest.mock('@lexical/react/LexicalMarkdownShortcutPlugin', () => ({
+jest.mock("@lexical/react/LexicalMarkdownShortcutPlugin", () => ({
MarkdownShortcutPlugin: () => null,
}));
-jest.mock('@lexical/react/LexicalListPlugin', () => ({
+jest.mock("@lexical/react/LexicalListPlugin", () => ({
ListPlugin: () => null,
}));
-jest.mock('@lexical/react/LexicalLinkPlugin', () => ({
+jest.mock("@lexical/react/LexicalLinkPlugin", () => ({
LinkPlugin: () => null,
}));
-jest.mock('@/components/drops/create/lexical/plugins/PlainTextPastePlugin', () => ({
- __esModule: true,
- default: () => null,
-}));
-jest.mock('@/components/drops/create/lexical/plugins/emoji/EmojiPlugin', () => ({
- __esModule: true,
- default: () => null,
-}));
-jest.mock('@/components/waves/CreateDropEmojiPicker', () => ({
+jest.mock(
+ "@/components/drops/create/lexical/plugins/PlainTextPastePlugin",
+ () => ({
+ __esModule: true,
+ default: () => null,
+ })
+);
+jest.mock(
+ "@/components/drops/create/lexical/plugins/emoji/EmojiPlugin",
+ () => ({
+ __esModule: true,
+ default: () => null,
+ })
+);
+jest.mock("@/components/waves/CreateDropEmojiPicker", () => ({
__esModule: true,
default: () =>
,
}));
-jest.mock('@/hooks/useDeviceInfo', () => ({
+jest.mock("@/hooks/useDeviceInfo", () => ({
__esModule: true,
- default: () => ({ isApp: false }),
+ default: () => useDeviceInfoMock(),
}));
-jest.mock('@/components/drops/create/lexical/plugins/mentions/MentionsPlugin', () => {
- const React = require('react');
- return {
- __esModule: true,
- default: React.forwardRef(({ onSelect }: any, ref: any) => {
- mentionSelectHandler = onSelect;
- React.useImperativeHandle(ref, () => ({
- isMentionsOpen: jest.fn(() => false),
- }));
- return
;
- }),
- };
-});
-jest.mock('@/components/drops/create/lexical/nodes/MentionNode', () => ({
+jest.mock(
+ "@/components/drops/create/lexical/plugins/mentions/MentionsPlugin",
+ () => {
+ const React = require("react");
+ return {
+ __esModule: true,
+ default: React.forwardRef(({ onSelect }: any, ref: any) => {
+ mentionSelectHandler = onSelect;
+ React.useImperativeHandle(ref, () => ({
+ isMentionsOpen: jest.fn(() => false),
+ }));
+ return
;
+ }),
+ };
+ }
+);
+jest.mock(
+ "@/components/drops/create/lexical/plugins/waves/WaveMentionsPlugin",
+ () => {
+ const React = require("react");
+ return {
+ __esModule: true,
+ default: React.forwardRef((_props: any, ref: any) => {
+ React.useImperativeHandle(ref, () => ({
+ isWaveMentionsOpen: jest.fn(() => false),
+ }));
+ return
;
+ }),
+ };
+ }
+);
+jest.mock("@/components/drops/create/lexical/nodes/MentionNode", () => ({
MentionNode: class MockMentionNode {},
- $createMentionNode: jest.fn(() => ({ type: 'mention' })),
+ $createMentionNode: jest.fn(() => ({ type: "mention" })),
}));
-jest.mock('@/components/drops/create/lexical/nodes/HashtagNode', () => ({
+jest.mock("@/components/drops/create/lexical/nodes/HashtagNode", () => ({
HashtagNode: class MockHashtagNode {},
}));
-jest.mock('@/components/drops/create/lexical/nodes/EmojiNode', () => ({
+jest.mock("@/components/drops/create/lexical/nodes/EmojiNode", () => ({
EmojiNode: class MockEmojiNode {},
}));
-jest.mock('@/components/drops/create/lexical/ExampleTheme', () => ({
+jest.mock("@/components/drops/create/lexical/ExampleTheme", () => ({
__esModule: true,
default: {},
}));
-jest.mock('@/components/drops/create/lexical/transformers/MentionTransformer', () => ({
- MENTION_TRANSFORMER: {},
-}));
-jest.mock('@/components/drops/create/lexical/transformers/HastagTransformer', () => ({
- HASHTAG_TRANSFORMER: {},
-}));
-jest.mock('@/components/drops/create/lexical/transformers/markdownTransformers', () => ({
- SAFE_MARKDOWN_TRANSFORMERS_WITHOUT_CODE: [],
-}));
-jest.mock('@lexical/react/LexicalComposerContext', () => ({
+jest.mock(
+ "@/components/drops/create/lexical/transformers/MentionTransformer",
+ () => ({
+ MENTION_TRANSFORMER: {},
+ })
+);
+jest.mock(
+ "@/components/drops/create/lexical/transformers/HastagTransformer",
+ () => ({
+ HASHTAG_TRANSFORMER: {},
+ })
+);
+jest.mock(
+ "@/components/drops/create/lexical/transformers/markdownTransformers",
+ () => ({
+ SAFE_MARKDOWN_TRANSFORMERS_WITHOUT_CODE: [],
+ })
+);
+jest.mock("@lexical/react/LexicalComposerContext", () => ({
useLexicalComposerContext: () => [editorMock],
}));
-jest.mock('@lexical/markdown', () => ({
+jest.mock("@lexical/markdown", () => ({
__esModule: true,
$convertFromMarkdownString: jest.fn(),
}));
-const {
- $convertFromMarkdownString: convertFromMarkdownStringMock,
-} = jest.requireMock('@lexical/markdown') as {
- $convertFromMarkdownString: jest.Mock;
-};
+const { $convertFromMarkdownString: convertFromMarkdownStringMock } =
+ jest.requireMock("@lexical/markdown") as {
+ $convertFromMarkdownString: jest.Mock;
+ };
-jest.mock('@/components/waves/drops/normalizeDropMarkdown', () => ({
+jest.mock("@/components/waves/drops/normalizeDropMarkdown", () => ({
__esModule: true,
default: jest.fn((value: string) => value),
normalizeDropMarkdown: jest.fn((value: string) => value),
- exportDropMarkdown: jest.fn(() => 'mock markdown'),
+ exportDropMarkdown: jest.fn(() => "mock markdown"),
}));
const {
normalizeDropMarkdown: normalizeDropMarkdownMock,
exportDropMarkdown: exportDropMarkdownMock,
-} = jest.requireMock('@/components/waves/drops/normalizeDropMarkdown') as {
+} = jest.requireMock("@/components/waves/drops/normalizeDropMarkdown") as {
normalizeDropMarkdown: jest.Mock;
exportDropMarkdown: jest.Mock;
};
-jest.mock('lexical', () => ({
+jest.mock("lexical", () => ({
$getRoot: getRootMock,
COMMAND_PRIORITY_HIGH: 4,
- KEY_ENTER_COMMAND: 'KEY_ENTER_COMMAND',
- KEY_ESCAPE_COMMAND: 'KEY_ESCAPE_COMMAND',
+ KEY_ENTER_COMMAND: "KEY_ENTER_COMMAND",
+ KEY_ESCAPE_COMMAND: "KEY_ESCAPE_COMMAND",
TextNode: class MockTextNode {},
$createParagraphNode: jest.fn(() => ({
append: jest.fn(),
@@ -190,17 +226,21 @@ jest.mock('lexical', () => ({
insertAfter: jest.fn(),
insertBefore: jest.fn(),
remove: jest.fn(),
- getTextContent: jest.fn(() => ''),
+ getTextContent: jest.fn(() => ""),
})),
$isElementNode: jest.fn(() => false),
$isCodeNode: jest.fn(() => false),
}));
-describe('EditDropLexical', () => {
+const EditDropLexical =
+ require("@/components/waves/drops/EditDropLexical").default;
+
+describe("EditDropLexical", () => {
const defaultProps = {
- initialContent: 'Initial content here',
+ initialContent: "Initial content here",
initialMentions: [] as ApiDropMentionedUser[],
- waveId: 'wave-123',
+ initialWaveMentions: [],
+ waveId: "wave-123",
isSaving: false,
onSave: jest.fn(),
onCancel: jest.fn(),
@@ -208,81 +248,98 @@ describe('EditDropLexical', () => {
beforeEach(() => {
jest.clearAllMocks();
- exportDropMarkdownMock.mockReturnValue('mock markdown');
+ exportDropMarkdownMock.mockReturnValue("mock markdown");
normalizeDropMarkdownMock.mockImplementation((value: string) => value);
convertFromMarkdownStringMock.mockReset();
rootMock.getChildren.mockReturnValue([]);
rootMock.getAllTextNodes.mockReturnValue([]);
mentionSelectHandler = null;
+ useDeviceInfoMock.mockReturnValue({
+ isApp: false,
+ isMobileDevice: false,
+ });
});
- it('renders placeholder text and emoji picker', () => {
+ it("renders placeholder text and emoji picker", () => {
render(
);
- expect(screen.getByText('Edit message...')).toBeInTheDocument();
- expect(screen.getByTestId('emoji-picker')).toBeInTheDocument();
+ expect(screen.getByText("Edit message...")).toBeInTheDocument();
+ expect(screen.getByTestId("emoji-picker")).toBeInTheDocument();
});
- it('saves updated markdown together with unique mentions', async () => {
+ it("saves updated markdown together with unique mentions", async () => {
const user = userEvent.setup();
const onSave = jest.fn();
const onCancel = jest.fn();
- exportDropMarkdownMock.mockReturnValue('updated markdown');
+ exportDropMarkdownMock.mockReturnValue("updated markdown");
- render(
);
+ render(
+
+ );
expect(mentionSelectHandler).toBeTruthy();
- const mention = { mentioned_profile_id: 'profile-1', handle_in_content: 'user1' };
+ const mention = {
+ mentioned_profile_id: "profile-1",
+ handle_in_content: "user1",
+ };
await act(async () => {
mentionSelectHandler?.(mention);
mentionSelectHandler?.(mention);
});
- const saveButton = screen.getByRole('button', { name: /save/i });
+ const saveButton = screen.getByRole("button", { name: /save/i });
await user.click(saveButton);
- expect(onSave).toHaveBeenCalledWith('updated markdown', [
- { mentioned_profile_id: 'profile-1', handle_in_content: 'user1' },
- ]);
+ expect(onSave).toHaveBeenCalledWith(
+ "updated markdown",
+ [{ mentioned_profile_id: "profile-1", handle_in_content: "user1" }],
+ []
+ );
expect(onCancel).not.toHaveBeenCalled();
});
- it('calls onCancel when markdown has not changed', async () => {
+ it("calls onCancel when markdown has not changed", async () => {
const user = userEvent.setup();
const onSave = jest.fn();
const onCancel = jest.fn();
- exportDropMarkdownMock.mockReturnValue('Initial content here');
+ exportDropMarkdownMock.mockReturnValue("Initial content here");
- render(
);
+ render(
+
+ );
- const saveButton = screen.getByRole('button', { name: /save/i });
+ const saveButton = screen.getByRole("button", { name: /save/i });
await user.click(saveButton);
expect(onCancel).toHaveBeenCalledTimes(1);
expect(onSave).not.toHaveBeenCalled();
});
- it('invokes keyboard command handlers', async () => {
+ it("invokes desktop keyboard command handlers", async () => {
const onSave = jest.fn();
const onCancel = jest.fn();
- exportDropMarkdownMock.mockReturnValue('changed content');
+ exportDropMarkdownMock.mockReturnValue("changed content");
- render(
);
+ render(
+
+ );
await act(async () => {});
const escapeCall = editorMock.registerCommand.mock.calls.find(
- ([command]) => command === 'KEY_ESCAPE_COMMAND'
+ ([command]) => command === "KEY_ESCAPE_COMMAND"
);
const enterCall = editorMock.registerCommand.mock.calls.find(
- ([command]) => command === 'KEY_ENTER_COMMAND'
+ ([command]) => command === "KEY_ENTER_COMMAND"
);
expect(escapeCall).toBeDefined();
expect(enterCall).toBeDefined();
const escapeHandler = escapeCall?.[1] as () => boolean;
- const enterHandler = enterCall?.[1] as (event?: { shiftKey?: boolean | undefined }) => boolean;
+ const enterHandler = enterCall?.[1] as (event?: {
+ shiftKey?: boolean | undefined;
+ }) => boolean;
await act(async () => {
escapeHandler?.();
@@ -297,4 +354,51 @@ describe('EditDropLexical', () => {
expect(exportDropMarkdownMock).toHaveBeenCalled();
expect(onCancel).toHaveBeenCalledTimes(1);
});
+
+ it("does not submit on Enter for mobile devices", async () => {
+ const onSave = jest.fn();
+ const onCancel = jest.fn();
+ useDeviceInfoMock.mockReturnValue({
+ isApp: false,
+ isMobileDevice: true,
+ });
+ exportDropMarkdownMock.mockReturnValue("changed content");
+
+ render(
+
+ );
+
+ await act(async () => {});
+
+ const enterCall = editorMock.registerCommand.mock.calls.find(
+ ([command]) => command === "KEY_ENTER_COMMAND"
+ );
+ expect(enterCall).toBeDefined();
+
+ const enterHandler = enterCall?.[1] as (event?: {
+ shiftKey?: boolean | undefined;
+ }) => boolean;
+
+ let handled = false;
+ await act(async () => {
+ handled = enterHandler?.({ shiftKey: false }) ?? false;
+ });
+
+ expect(handled).toBe(true);
+ expect(onSave).not.toHaveBeenCalled();
+ expect(onCancel).not.toHaveBeenCalled();
+ });
+
+ it("keeps actions visible and removes desktop shortcut copy on mobile web", () => {
+ useDeviceInfoMock.mockReturnValue({
+ isApp: false,
+ isMobileDevice: true,
+ });
+
+ render(
);
+
+ expect(screen.queryByText("enter to")).not.toBeInTheDocument();
+ expect(screen.getByRole("button", { name: /cancel/i })).toBeInTheDocument();
+ expect(screen.getByRole("button", { name: /save/i })).toBeInTheDocument();
+ });
});
diff --git a/components/waves/drops/EditDropLexical.tsx b/components/waves/drops/EditDropLexical.tsx
index 551d2718f0..0e41d30cf3 100644
--- a/components/waves/drops/EditDropLexical.tsx
+++ b/components/waves/drops/EditDropLexical.tsx
@@ -105,7 +105,7 @@ const convertCodeNodesToFences = (root: RootNode) => {
if (!node) continue;
if ($isCodeNode(node)) {
- const language = node.getLanguage?.() ?? "";
+ const language = node.getLanguage() ?? "";
const safeLanguage = language.trim().replaceAll(/[`\n\r]/g, "");
const codeText = node.getTextContent();
const normalizedCode = codeText.endsWith("\n")
@@ -146,6 +146,7 @@ function reconstructSplitMention(
if (!mentionMatch) return false;
const handle = mentionMatch[1];
+ if (!handle) return false;
const mentionNode = $createMentionNode(`@${handle}`);
const currentText = currentNode.getTextContent();
@@ -348,6 +349,7 @@ function KeyboardPlugin({
onSave,
onCancel,
isSaving,
+ isMobileOrApp,
initialContent,
mentionsRef,
waveMentionsRef,
@@ -355,6 +357,7 @@ function KeyboardPlugin({
onSave: () => void;
onCancel: () => void;
isSaving: boolean;
+ isMobileOrApp: boolean;
initialContent: string;
mentionsRef: React.RefObject
;
waveMentionsRef: React.RefObject;
@@ -382,6 +385,10 @@ function KeyboardPlugin({
return false;
}
+ if (isMobileOrApp) {
+ return true;
+ }
+
if (event?.shiftKey) {
return false;
}
@@ -415,6 +422,7 @@ function KeyboardPlugin({
onSave,
onCancel,
isSaving,
+ isMobileOrApp,
initialContent,
mentionsRef,
waveMentionsRef,
@@ -441,7 +449,8 @@ const EditDropLexical: React.FC = ({
const editorRef = useRef(null);
const mentionsRef = useRef(null);
const waveMentionsRef = useRef(null);
- const { isApp } = useDeviceInfo();
+ const { isApp, isMobileDevice } = useDeviceInfo();
+ const isMobileOrApp = isMobileDevice || isApp;
const normalizedInitialContent = useMemo(
() => normalizeDropMarkdown(initialContent),
[initialContent]
@@ -586,6 +595,7 @@ const EditDropLexical: React.FC = ({
onSave={handleSave}
onCancel={onCancel}
isSaving={isSaving}
+ isMobileOrApp={isMobileOrApp}
initialContent={normalizedInitialContent}
mentionsRef={mentionsRef}
waveMentionsRef={waveMentionsRef}
@@ -595,14 +605,14 @@ const EditDropLexical: React.FC = ({
{!isApp && (
- escape to{" "}
+ {!isMobileDevice && <>escape to >}
{" "}
- • enter to{" "}
+ {isMobileDevice ? "• " : "• enter to "}