diff --git a/package.json b/package.json index d40a362ad4c..61ba242df06 100644 --- a/package.json +++ b/package.json @@ -3,8 +3,12 @@ "@datadog/browser-logs": "5.33.0", "@datadog/browser-rum": "5.33.0", "@emotion/react": "11.11.4", - "@lexical/history": "0.21.0", - "@lexical/react": "0.21.0", + "@lexical/code": "0.20.2", + "@lexical/history": "0.20.2", + "@lexical/list": "0.20.2", + "@lexical/markdown": "0.20.2", + "@lexical/react": "0.20.2", + "@lexical/rich-text": "0.20.2", "@mediapipe/tasks-vision": "0.10.20", "@wireapp/avs": "10.0.4", "@wireapp/avs-debugger": "0.0.7", @@ -31,7 +35,7 @@ "kalium-backup": "./TEMP-crossplatform-backup", "keyboardjs": "2.7.0", "knockout": "3.5.1", - "lexical": "0.21.0", + "lexical": "0.20.2", "libsodium-wrappers": "0.7.15", "linkify-it": "5.0.0", "long": "5.2.3", diff --git a/server/config/client.config.ts b/server/config/client.config.ts index 45e42389a69..b2f4e8ab57c 100644 --- a/server/config/client.config.ts +++ b/server/config/client.config.ts @@ -68,6 +68,7 @@ export function generateConfig(params: ConfigGeneratorParams, env: Env) { ENFORCE_CONSTANT_BITRATE: env.FEATURE_ENFORCE_CONSTANT_BITRATE == 'true', ENABLE_ENCRYPTION_AT_REST: env.FEATURE_ENABLE_ENCRYPTION_AT_REST == 'true', ENABLE_BLUR_BACKGROUND: env.FEATURE_ENABLE_BLUR_BACKGROUND == 'true', + ENABLE_MESSAGE_FORMAT_BUTTONS: env.FEATURE_ENABLE_MESSAGE_FORMAT_BUTTONS == 'true', FORCE_EXTRA_CLIENT_ENTROPY: env.FEATURE_FORCE_EXTRA_CLIENT_ENTROPY == 'true', MLS_CONFIG_KEYING_MATERIAL_UPDATE_THRESHOLD: env.FEATURE_MLS_CONFIG_KEYING_MATERIAL_UPDATE_THRESHOLD ? Number(env.FEATURE_MLS_CONFIG_KEYING_MATERIAL_UPDATE_THRESHOLD) diff --git a/server/config/env.ts b/server/config/env.ts index e135893e441..a4e6112b0cb 100644 --- a/server/config/env.ts +++ b/server/config/env.ts @@ -174,6 +174,9 @@ export type Env = { /** Feature to enable team creation flow for individual users */ FEATURE_ENABLE_TEAM_CREATION: string; + /** Feature to enable rich text editor */ + FEATURE_ENABLE_MESSAGE_FORMAT_BUTTONS: string; + /** Feature to enable Cross Platform Backup export */ FEATURE_ENABLE_CROSS_PLATFORM_BACKUP_EXPORT: string; diff --git a/src/i18n/en-US.json b/src/i18n/en-US.json index 49728de7441..1decd39afa2 100644 --- a/src/i18n/en-US.json +++ b/src/i18n/en-US.json @@ -1376,6 +1376,13 @@ "replyQuoteShowMore": "Show more", "replyQuoteTimeStampDate": "Original message from {date}", "replyQuoteTimeStampTime": "Original message from {time}", + "richTextHeading": "Heading", + "richTextBold": "Bold", + "richTextItalic": "Italic", + "richTextStrikethrough": "Strikethrough", + "richTextUnorderedList": "Unordered list", + "richTextOrderedList": "Ordered list", + "richTextCode": "Code", "roleAdmin": "Admin", "roleOwner": "Owner", "rolePartner": "External", @@ -1532,9 +1539,11 @@ "tooltipConversationCall": "Call", "tooltipConversationDetailsAddPeople": "Add participants to conversation ({shortcut})", "tooltipConversationDetailsRename": "Change conversation name", + "tooltipConversationEmoji": "Select emoji", "tooltipConversationEphemeral": "Self-deleting message", "tooltipConversationEphemeralAriaLabel": "Type a self-deleting message, currently set to {time}", "tooltipConversationFile": "Add file", + "tooltipConversationHideFormatting": "Hide formatting", "tooltipConversationInfo": "Conversation info", "tooltipConversationInputMoreThanTwoUserTyping": "{user1} and {count} more people are typing", "tooltipConversationInputOneUserTyping": "{user1} is typing", @@ -1545,6 +1554,7 @@ "tooltipConversationPing": "Ping", "tooltipConversationSearch": "Search", "tooltipConversationSendMessage": "Send message", + "tooltipConversationShowFormatting": "Show formatting", "tooltipConversationVideoCall": "Video Call", "tooltipConversationsArchive": "Archive ({shortcut})", "tooltipConversationsArchived": "Show archive ({number})", diff --git a/src/script/components/MessagesList/Message/ContentMessage/MessageActions/MessageReactions/EmojiPicker.tsx b/src/script/components/EmojiPicker/EmojiPicker.tsx similarity index 93% rename from src/script/components/MessagesList/Message/ContentMessage/MessageActions/MessageReactions/EmojiPicker.tsx rename to src/script/components/EmojiPicker/EmojiPicker.tsx index 62f012fa6a3..6aa84b23862 100644 --- a/src/script/components/MessagesList/Message/ContentMessage/MessageActions/MessageReactions/EmojiPicker.tsx +++ b/src/script/components/EmojiPicker/EmojiPicker.tsx @@ -17,16 +17,16 @@ * */ -import {useState, useEffect, useRef, FC, RefObject} from 'react'; +import {useState, useEffect, useRef, RefObject} from 'react'; -import EmojiPicker, {EmojiClickData, EmojiStyle, SkinTones} from 'emoji-picker-react'; +import EmojiPickerReact, {EmojiClickData, EmojiStyle, SkinTones} from 'emoji-picker-react'; import {createPortal} from 'react-dom'; import {useClickOutside} from 'src/script/hooks/useClickOutside'; import {isEnterKey, isEscapeKey} from 'Util/KeyboardUtil'; import {t} from 'Util/LocalizerUtil'; -interface EmojiPickerContainerProps { +interface EmojiPickerProps { posX: number; posY: number; onKeyPress: () => void; @@ -35,14 +35,14 @@ interface EmojiPickerContainerProps { handleReactionClick: (emoji: string) => void; } -const EmojiPickerContainer: FC = ({ +export const EmojiPicker = ({ posX, posY, onKeyPress, resetActionMenuStates, wrapperRef, handleReactionClick, -}) => { +}: EmojiPickerProps) => { const emojiRef = useRef(null); useClickOutside(emojiRef, resetActionMenuStates, wrapperRef); const [style, setStyle] = useState({ @@ -128,7 +128,7 @@ const EmojiPickerContainer: FC = ({ event.stopPropagation(); }} > - = ({ ); }; - -export {EmojiPickerContainer}; diff --git a/src/script/components/InputBar/InputBar.tsx b/src/script/components/InputBar/InputBar.tsx index 60193a3f025..ecd73797558 100644 --- a/src/script/components/InputBar/InputBar.tsx +++ b/src/script/components/InputBar/InputBar.tsx @@ -21,15 +21,15 @@ import {useCallback, useEffect, useRef, useState} from 'react'; import {amplify} from 'amplify'; import cx from 'classnames'; -import {CLEAR_EDITOR_COMMAND, LexicalEditor} from 'lexical'; +import {CLEAR_EDITOR_COMMAND, LexicalEditor, $createTextNode, $insertNodes} from 'lexical'; import {container} from 'tsyringe'; -import {useMatchMedia} from '@wireapp/react-ui-kit'; import {WebAppEvents} from '@wireapp/webapp-events'; import {Avatar, AVATAR_SIZE} from 'Components/Avatar'; import {ConversationClassifiedBar} from 'Components/ClassifiedBar/ClassifiedBar'; import {checkFileSharingPermission} from 'Components/Conversation/utils/checkFileSharingPermission'; +import {EmojiPicker} from 'Components/EmojiPicker/EmojiPicker'; import {PrimaryModal} from 'Components/Modals/PrimaryModal'; import {showWarningModal} from 'Components/Modals/utils/showWarningModal'; import {RichTextContent, RichTextEditor} from 'Components/RichTextEditor'; @@ -38,6 +38,7 @@ import {ConversationRepository} from 'src/script/conversation/ConversationReposi import {useUserPropertyValue} from 'src/script/hooks/useUserProperty'; import {PropertiesRepository} from 'src/script/properties/PropertiesRepository'; import {PROPERTIES_TYPE} from 'src/script/properties/PropertiesType'; +import {EventName} from 'src/script/tracking/EventName'; import {CONVERSATION_TYPING_INDICATOR_MODE} from 'src/script/user/TypingIndicatorMode'; import {useKoSubscribableChildren} from 'Util/ComponentUtil'; import {KEY} from 'Util/KeyboardUtil'; @@ -46,12 +47,13 @@ import {formatLocale, TIME_IN_MILLIS} from 'Util/TimeUtil'; import {getFileExtension} from 'Util/util'; import {ControlButtons} from './components/InputBarControls/ControlButtons'; -import {GiphyButton} from './components/InputBarControls/GiphyButton'; import {PastedFileControls} from './components/PastedFileControls'; import {ReplyBar} from './components/ReplyBar'; import {TypingIndicator} from './components/TypingIndicator/TypingIndicator'; -import {useFilePaste} from './hooks/useFilePaste'; -import {useTypingIndicator} from './hooks/useTypingIndicator'; +import {useEmojiPicker} from './hooks/useEmojiPicker/useEmojiPicker'; +import {useFilePaste} from './hooks/useFilePaste/useFilePaste'; +import {useFormatToolbar} from './hooks/useFormatToolbar/useFormatToolbar'; +import {useTypingIndicator} from './hooks/useTypingIndicator/useTypingIndicator'; import {handleClickOutsideOfInputBar, IgnoreOutsideClickWrapper} from './util/clickHandlers'; import {loadDraftState, saveDraftState} from './util/DraftStateUtil'; @@ -74,9 +76,6 @@ import {TeamState} from '../../team/TeamState'; const CONFIG = { ...Config.getConfig(), PING_TIMEOUT: TIME_IN_MILLIS.SECOND * 2, -}; - -const config = { GIPHY_TEXT_LENGTH: 256, }; @@ -133,6 +132,8 @@ export const InputBar = ({ 'isIncomingRequest', ]); + const wrapperRef = useRef(null); + // Lexical const editorRef = useRef(null); @@ -147,6 +148,18 @@ export const InputBar = ({ const [replyMessageEntity, setReplyMessageEntity] = useState(null); const textValue = messageContent.text; + const formatToolbar = useFormatToolbar(); + + const emojiPicker = useEmojiPicker({ + wrapperRef, + onEmojiPicked: emoji => { + editorRef.current?.update(() => { + $insertNodes([$createTextNode(emoji)]); + }); + amplify.publish(WebAppEvents.ANALYTICS.EVENT, EventName.INPUT.EMOJI_MODAL.EMOJI_PICKED); + }, + }); + // Files const [pastedFile, setPastedFile] = useState(null); @@ -167,10 +180,11 @@ export const InputBar = ({ const hasLocalEphemeralTimer = isSelfDeletingMessagesEnabled && !!localMessageTimer && !hasGlobalMessageTimer; const isTypingRef = useRef(false); - // To be changed when design chooses a breakpoint, the conditional can be integrated to the ui-kit directly - const isScaledDown = useMatchMedia('max-width: 768px'); + const messageFormatButtonsEnabled = CONFIG.FEATURE.ENABLE_MESSAGE_FORMAT_BUTTONS; - const showGiphyButton = textValue.length > 0 && textValue.length <= config.GIPHY_TEXT_LENGTH; + const showGiphyButton = messageFormatButtonsEnabled + ? textValue.length > 0 + : textValue.length > 0 && textValue.length <= CONFIG.GIPHY_TEXT_LENGTH; const shouldReplaceEmoji = useUserPropertyValue( () => propertiesRepository.getPreference(PROPERTIES_TYPE.EMOJI.REPLACE_INLINE), @@ -534,107 +548,113 @@ export const InputBar = ({ disablePing: pingDisabled, input: textValue, isEditing: isEditing, - isScaledDown: isScaledDown, onCancelEditing: () => cancelMessageEditing(true), onClickPing: onPingClick, onGifClick: onGifClick, onSelectFiles: uploadFiles, onSelectImages: uploadImages, showGiphyButton: showGiphyButton, + isFormatActive: formatToolbar.open, + onFormatClick: formatToolbar.handleClick, + isEmojiActive: emojiPicker.open, + onEmojiClick: emojiPicker.handleToggle, }; const enableSending = textValue.length > 0; + const showAvatar = messageFormatButtonsEnabled || !!textValue.length; + return ( - - {isTypingIndicatorEnabled && } - - {classifiedDomains && !isConnectionRequest && ( - - )} - - {isReplying && !isEditing && } - -
+ - {!isOutgoingRequest && ( - <> -
- {!!textValue.length && ( - - )} -
- - {!isSelfUserRemoved && !pastedFile && ( - { - editorRef.current = lexical; - }} - editedMessage={editedMessage} - onEscape={() => { - if (editedMessage) { - cancelMessageEditing(true); - } else if (replyMessageEntity) { - cancelMessageReply(); - } - }} - onArrowUp={() => { - if (textValue.length === 0) { - editMessage(conversation.getLastEditableMessage()); - } - }} - getMentionCandidates={getMentionCandidates} - replaceEmojis={shouldReplaceEmoji} - placeholder={inputPlaceholder} - onUpdate={setMessageContent} - hasLocalEphemeralTimer={hasLocalEphemeralTimer} - saveDraftState={saveDraft} - loadDraftState={loadDraft} - onShiftTab={onShiftTab} - onSend={handleSendMessage} - onBlur={() => isTypingRef.current && conversationRepository.sendTypingStop(conversation)} - > - {isScaledDown ? ( - <> -
    - {showGiphyButton && } - -
-
    - -
- - ) : ( - <> -
    - - -
- - )} -
- )} - + {isTypingIndicatorEnabled && } + + {classifiedDomains && !isConnectionRequest && ( + )} - {pastedFile && } -
-
+ {isReplying && !isEditing && } + +
+ {!isOutgoingRequest && ( + <> +
+ {showAvatar && ( + + )} +
+ {!isSelfUserRemoved && !pastedFile && ( + { + editorRef.current = lexical; + }} + editedMessage={editedMessage} + onEscape={() => { + if (editedMessage) { + cancelMessageEditing(true); + } else if (replyMessageEntity) { + cancelMessageReply(); + } + }} + onArrowUp={() => { + if (textValue.length === 0) { + editMessage(conversation.getLastEditableMessage()); + } + }} + getMentionCandidates={getMentionCandidates} + replaceEmojis={shouldReplaceEmoji} + placeholder={inputPlaceholder} + onUpdate={setMessageContent} + hasLocalEphemeralTimer={hasLocalEphemeralTimer} + showFormatToolbar={formatToolbar.open} + saveDraftState={saveDraft} + loadDraftState={loadDraft} + onShiftTab={onShiftTab} + onSend={handleSendMessage} + onBlur={() => isTypingRef.current && conversationRepository.sendTypingStop(conversation)} + > +
    + + +
+
+ )} + + )} + + {pastedFile && ( + + )} +
+ + {emojiPicker.open ? ( + + ) : null} + ); }; diff --git a/src/script/components/InputBar/components/ImageUploadButton/ImageUploadButton.test.tsx b/src/script/components/InputBar/components/ImageUploadButton/ImageUploadButton.test.tsx index 76a73835919..e4fcf0d6692 100644 --- a/src/script/components/InputBar/components/ImageUploadButton/ImageUploadButton.test.tsx +++ b/src/script/components/InputBar/components/ImageUploadButton/ImageUploadButton.test.tsx @@ -30,7 +30,7 @@ describe('ImageUploadButton', () => { const onSelectImages = jest.fn(); const {container} = render( - , + , ); const fileInput = container.querySelector('input[type="file"]') as HTMLInputElement; @@ -45,7 +45,7 @@ describe('ImageUploadButton', () => { const onSelectImages = jest.fn(); const {container} = render( - , + , ); const form = container.querySelector('form'); diff --git a/src/script/components/InputBar/components/ImageUploadButton/ImageUploadButton.tsx b/src/script/components/InputBar/components/ImageUploadButton/ImageUploadButton.tsx index 00e1af98f1d..3a69535ef48 100644 --- a/src/script/components/InputBar/components/ImageUploadButton/ImageUploadButton.tsx +++ b/src/script/components/InputBar/components/ImageUploadButton/ImageUploadButton.tsx @@ -20,6 +20,7 @@ import {useRef} from 'react'; import {TabIndex} from '@wireapp/react-ui-kit/lib/types/enums'; +import cx from 'classnames'; import * as Icon from 'Components/Icon'; import {t} from 'Util/LocalizerUtil'; @@ -27,9 +28,10 @@ import {t} from 'Util/LocalizerUtil'; interface ImageUploadButtonProps { onSelectImages: (files: File[]) => void; acceptedImageTypes: ReadonlyArray; + hasRoundedCorners: boolean; } -export const ImageUploadButton = ({onSelectImages, acceptedImageTypes}: ImageUploadButtonProps) => { +export const ImageUploadButton = ({onSelectImages, acceptedImageTypes, hasRoundedCorners}: ImageUploadButtonProps) => { const imageRef = useRef(null); const formRef = useRef(null); @@ -50,7 +52,9 @@ export const ImageUploadButton = ({onSelectImages, acceptedImageTypes}: ImageUpl type="button" aria-label={t('tooltipConversationAddImage')} title={t('tooltipConversationAddImage')} - className="conversation-button controls-right-button no-radius file-button" + className={cx('conversation-button controls-right-button no-radius file-button', { + 'buttons-group-button-left': hasRoundedCorners, + })} onClick={() => imageRef.current?.click()} data-uie-name="do-share-image" > diff --git a/src/script/components/RichTextEditor/plugins/TextChangePlugin.tsx b/src/script/components/InputBar/components/InputBarControls/CancelEditButton/CancelEditButton.tsx similarity index 55% rename from src/script/components/RichTextEditor/plugins/TextChangePlugin.tsx rename to src/script/components/InputBar/components/InputBarControls/CancelEditButton/CancelEditButton.tsx index 08851251bed..ef672f853e8 100644 --- a/src/script/components/RichTextEditor/plugins/TextChangePlugin.tsx +++ b/src/script/components/InputBar/components/InputBarControls/CancelEditButton/CancelEditButton.tsx @@ -1,6 +1,6 @@ /* * Wire - * Copyright (C) 2023 Wire Swiss GmbH + * Copyright (C) 2024 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 @@ -17,23 +17,23 @@ * */ -import {useEffect} from 'react'; +import * as Icon from 'Components/Icon'; +import {t} from 'Util/LocalizerUtil'; -import {useLexicalComposerContext} from '@lexical/react/LexicalComposerContext'; -import {LexicalEditor} from 'lexical'; +interface CancelEditButtonProps { + onClick: () => void; +} -type Props = { - onUpdate: (editor: LexicalEditor, text: string) => void; +export const CancelEditButton = ({onClick}: CancelEditButtonProps) => { + return ( + + ); }; - -export function TextChangePlugin({onUpdate}: Props): null { - const [editor] = useLexicalComposerContext(); - - useEffect(() => { - return editor.registerTextContentListener(textContent => { - onUpdate(editor, textContent); - }); - }, [editor, onUpdate]); - - return null; -} diff --git a/src/script/components/InputBar/components/InputBarControls/ControlButtons.test.tsx b/src/script/components/InputBar/components/InputBarControls/ControlButtons.test.tsx index ca5d7ec0488..10036cc0faf 100644 --- a/src/script/components/InputBar/components/InputBarControls/ControlButtons.test.tsx +++ b/src/script/components/InputBar/components/InputBarControls/ControlButtons.test.tsx @@ -34,6 +34,10 @@ const defaultParams: PropsType = { onSelectFiles: jest.fn(), onSelectImages: jest.fn(), showGiphyButton: true, + isFormatActive: true, + isEmojiActive: true, + onFormatClick: jest.fn(), + onEmojiClick: jest.fn(), }; const allButtonTitles = [ diff --git a/src/script/components/InputBar/components/InputBarControls/ControlButtons.tsx b/src/script/components/InputBar/components/InputBarControls/ControlButtons.tsx index 668417146b5..1786fe42381 100644 --- a/src/script/components/InputBar/components/InputBarControls/ControlButtons.tsx +++ b/src/script/components/InputBar/components/InputBarControls/ControlButtons.tsx @@ -17,14 +17,16 @@ * */ -import React from 'react'; +import React, {MouseEvent} from 'react'; -import * as Icon from 'Components/Icon'; import {Config} from 'src/script/Config'; import {Conversation} from 'src/script/entity/Conversation'; -import {t} from 'Util/LocalizerUtil'; -import {GiphyButton} from './GiphyButton'; +import {CancelEditButton} from './CancelEditButton/CancelEditButton'; +import {EmojiButton} from './EmojiButton/EmojiButton'; +import {FormatTextButton} from './FormatTextButton/FormatTextButton'; +import {GiphyButton} from './GiphyButton/GiphyButton'; +import {PingButton} from './PingButton/PingButton'; import {AssetUploadButton} from '../AssetUploadButton'; import {ImageUploadButton} from '../ImageUploadButton'; @@ -36,13 +38,16 @@ export type ControlButtonsProps = { disablePing?: boolean; disableFilesharing?: boolean; isEditing?: boolean; - isScaledDown?: boolean; + isFormatActive: boolean; + isEmojiActive: boolean; showGiphyButton?: boolean; onClickPing: () => void; onSelectFiles: (files: File[]) => void; onSelectImages: (files: File[]) => void; onCancelEditing: () => void; onGifClick: () => void; + onFormatClick: () => void; + onEmojiClick: (event: MouseEvent) => void; }; const ControlButtons: React.FC = ({ @@ -51,59 +56,50 @@ const ControlButtons: React.FC = ({ disableFilesharing, input, isEditing, - isScaledDown, + isFormatActive, + isEmojiActive, showGiphyButton, onClickPing, onSelectFiles, onSelectImages, onCancelEditing, onGifClick, + onFormatClick, + onEmojiClick, }) => { - const pingTooltip = t('tooltipConversationPing'); + const messageFormatButtonsEnabled = Config.getConfig().FEATURE.ENABLE_MESSAGE_FORMAT_BUTTONS; if (isEditing) { return (
  • - +
  • ); } - if (input.length === 0 || isScaledDown) { - const scaledDownClass = isScaledDown && 'controls-right-button_responsive'; - + if (input.length === 0) { return ( <> -
  • - -
  • + {messageFormatButtonsEnabled && ( + <> +
  • + +
  • +
  • + +
  • + + )} + {!disableFilesharing && ( <>
  • -
  • = ({
  • )} - +
  • + +
  • @@ -120,7 +118,25 @@ const ControlButtons: React.FC = ({ ); } - return <>{showGiphyButton && !disableFilesharing && }; + return ( + <> + {showGiphyButton && !disableFilesharing && ( + <> + {messageFormatButtonsEnabled && ( + <> +
  • + +
  • +
  • + +
  • + + )} + + + )} + + ); }; export {ControlButtons}; diff --git a/src/script/components/InputBar/components/InputBarControls/EmojiButton/EmojiButton.tsx b/src/script/components/InputBar/components/InputBarControls/EmojiButton/EmojiButton.tsx new file mode 100644 index 00000000000..1ede271747c --- /dev/null +++ b/src/script/components/InputBar/components/InputBarControls/EmojiButton/EmojiButton.tsx @@ -0,0 +1,48 @@ +/* + * Wire + * Copyright (C) 2024 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 type {MouseEvent} from 'react'; + +import cx from 'classnames'; + +import {EmojiIcon} from '@wireapp/react-ui-kit'; + +import {t} from 'Util/LocalizerUtil'; + +interface EmojiButtonProps { + isActive: boolean; + onClick: (event: MouseEvent) => void; +} + +export const EmojiButton = ({isActive, onClick}: EmojiButtonProps) => { + return ( + + ); +}; diff --git a/src/script/components/InputBar/components/InputBarControls/FormatTextButton/FormatTextButton.tsx b/src/script/components/InputBar/components/InputBarControls/FormatTextButton/FormatTextButton.tsx new file mode 100644 index 00000000000..f30a3ef640a --- /dev/null +++ b/src/script/components/InputBar/components/InputBarControls/FormatTextButton/FormatTextButton.tsx @@ -0,0 +1,47 @@ +/* + * Wire + * Copyright (C) 2024 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 type {MouseEvent} from 'react'; + +import cx from 'classnames'; + +import * as Icon from 'Components/Icon'; +import {t} from 'Util/LocalizerUtil'; + +interface FormatTextButtonProps { + isActive: boolean; + onClick: (event: MouseEvent) => void; +} + +export const FormatTextButton = ({isActive, onClick}: FormatTextButtonProps) => { + return ( + + ); +}; diff --git a/src/script/components/InputBar/components/InputBarControls/GiphyButton.tsx b/src/script/components/InputBar/components/InputBarControls/GiphyButton/GiphyButton.tsx similarity index 83% rename from src/script/components/InputBar/components/InputBarControls/GiphyButton.tsx rename to src/script/components/InputBar/components/InputBarControls/GiphyButton/GiphyButton.tsx index ce0686a7e95..e60a6bbf1e4 100644 --- a/src/script/components/InputBar/components/InputBarControls/GiphyButton.tsx +++ b/src/script/components/InputBar/components/InputBarControls/GiphyButton/GiphyButton.tsx @@ -19,20 +19,26 @@ import React from 'react'; +import cx from 'classnames'; + import {IconButton} from '@wireapp/react-ui-kit'; import * as Icon from 'Components/Icon'; import {t} from 'Util/LocalizerUtil'; export type GiphyButtonProps = { + hasRoundedLeftCorner: boolean; onGifClick: () => void; }; -const GiphyButton: React.FC = ({onGifClick}) => { +const GiphyButton: React.FC = ({onGifClick, hasRoundedLeftCorner}) => { return ( <>
  • void; + isDisabled: boolean; +} + +export const PingButton = ({isDisabled, onClick}: PingButtonProps) => { + return ( + + ); +}; diff --git a/src/script/components/InputBar/hooks/useEmojiPicker/useEmojiPicker.ts b/src/script/components/InputBar/hooks/useEmojiPicker/useEmojiPicker.ts new file mode 100644 index 00000000000..c6c002634e0 --- /dev/null +++ b/src/script/components/InputBar/hooks/useEmojiPicker/useEmojiPicker.ts @@ -0,0 +1,60 @@ +/* + * Wire + * Copyright (C) 2024 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 {MouseEvent, useRef, useState} from 'react'; + +import {useClickOutside} from 'Hooks/useClickOutside'; + +interface EmojiPickerParams { + wrapperRef: React.RefObject; + onEmojiPicked: (emoji: string) => void; +} + +export const useEmojiPicker = ({wrapperRef, onEmojiPicked}: EmojiPickerParams) => { + const [open, setOpen] = useState(false); + + const emojiWrapperRef = useRef(null); + + // eslint-disable-next-line id-length + const emojiPickerPosition = useRef<{x: number; y: number}>({x: 0, y: 0}); + + const handleClose = () => { + if (open) { + setOpen(false); + } + }; + + const handleToggle = (event: MouseEvent) => { + const rect = event.currentTarget.getBoundingClientRect(); + // eslint-disable-next-line id-length + emojiPickerPosition.current = {x: rect.x, y: rect.y}; + setOpen(prev => !prev); + }; + + useClickOutside(wrapperRef, handleClose); + + return { + open, + position: emojiPickerPosition.current, + handleToggle, + handleClose, + handlePick: onEmojiPicked, + ref: emojiWrapperRef, + }; +}; diff --git a/src/script/components/InputBar/hooks/useFilePaste.test.ts b/src/script/components/InputBar/hooks/useFilePaste/useFilePaste.test.ts similarity index 100% rename from src/script/components/InputBar/hooks/useFilePaste.test.ts rename to src/script/components/InputBar/hooks/useFilePaste/useFilePaste.test.ts diff --git a/src/script/components/InputBar/hooks/useFilePaste.ts b/src/script/components/InputBar/hooks/useFilePaste/useFilePaste.ts similarity index 100% rename from src/script/components/InputBar/hooks/useFilePaste.ts rename to src/script/components/InputBar/hooks/useFilePaste/useFilePaste.ts diff --git a/src/script/components/InputBar/hooks/useFormatToolbar/useFormatToolbar.ts b/src/script/components/InputBar/hooks/useFormatToolbar/useFormatToolbar.ts new file mode 100644 index 00000000000..23902e2fcf0 --- /dev/null +++ b/src/script/components/InputBar/hooks/useFormatToolbar/useFormatToolbar.ts @@ -0,0 +1,54 @@ +/* + * Wire + * Copyright (C) 2024 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 {useState} from 'react'; + +import {amplify} from 'amplify'; + +import {WebAppEvents} from '@wireapp/webapp-events'; + +import {Config} from 'src/script/Config'; +import {StorageKey} from 'src/script/storage'; +import {EventName} from 'src/script/tracking/EventName'; +import {loadValue, storeValue} from 'Util/StorageUtil'; + +export const useFormatToolbar = () => { + const [open, setOpen] = useState(() => { + const messageFormatButtonsEnabled = Config.getConfig().FEATURE.ENABLE_MESSAGE_FORMAT_BUTTONS; + const storageValue = loadValue(StorageKey.INPUT.SHOW_FORMATTING); + + if (storageValue && messageFormatButtonsEnabled) { + return storageValue; + } + + return false; + }); + + const handleClick = () => { + const nextValue = !open; + amplify.publish(WebAppEvents.ANALYTICS.EVENT, EventName.INPUT.FORMAT_TEXT[nextValue ? 'ENABLED' : 'DISABLED']); + storeValue(StorageKey.INPUT.SHOW_FORMATTING, nextValue); + setOpen(nextValue); + }; + + return { + open, + handleClick, + }; +}; diff --git a/src/script/components/InputBar/hooks/useTypingIndicator.test.ts b/src/script/components/InputBar/hooks/useTypingIndicator/useTypingIndicator.test.ts similarity index 98% rename from src/script/components/InputBar/hooks/useTypingIndicator.test.ts rename to src/script/components/InputBar/hooks/useTypingIndicator/useTypingIndicator.test.ts index 0d77d059543..99f728ed871 100644 --- a/src/script/components/InputBar/hooks/useTypingIndicator.test.ts +++ b/src/script/components/InputBar/hooks/useTypingIndicator/useTypingIndicator.test.ts @@ -21,7 +21,7 @@ import {fireEvent, renderHook} from '@testing-library/react'; import {useTypingIndicator} from './useTypingIndicator'; -import {TYPING_TIMEOUT} from '../components/TypingIndicator'; +import {TYPING_TIMEOUT} from '../../components/TypingIndicator'; describe('useTypingIndicator', () => { beforeAll(() => { diff --git a/src/script/components/InputBar/hooks/useTypingIndicator.ts b/src/script/components/InputBar/hooks/useTypingIndicator/useTypingIndicator.ts similarity index 97% rename from src/script/components/InputBar/hooks/useTypingIndicator.ts rename to src/script/components/InputBar/hooks/useTypingIndicator/useTypingIndicator.ts index 193887933ed..4130ff59140 100644 --- a/src/script/components/InputBar/hooks/useTypingIndicator.ts +++ b/src/script/components/InputBar/hooks/useTypingIndicator/useTypingIndicator.ts @@ -19,7 +19,7 @@ import {useCallback, useEffect, useRef} from 'react'; -import {TYPING_TIMEOUT} from '../components/TypingIndicator'; +import {TYPING_TIMEOUT} from '../../components/TypingIndicator'; type TypingIndicatorProps = { text: string; diff --git a/src/script/components/MessagesList/Message/ContentMessage/MessageActions/MessageReactions/MessageReactions.tsx b/src/script/components/MessagesList/Message/ContentMessage/MessageActions/MessageReactions/MessageReactions.tsx index f03a867c9f2..87add553258 100644 --- a/src/script/components/MessagesList/Message/ContentMessage/MessageActions/MessageReactions/MessageReactions.tsx +++ b/src/script/components/MessagesList/Message/ContentMessage/MessageActions/MessageReactions/MessageReactions.tsx @@ -19,12 +19,12 @@ import {useState, RefObject, FC, useRef} from 'react'; +import {EmojiPicker} from 'Components/EmojiPicker/EmojiPicker'; import {isSpaceOrEnterKey} from 'Util/KeyboardUtil'; import {t} from 'Util/LocalizerUtil'; import {EmojiChar} from './EmojiChar'; import {reactionImgSize} from './EmojiChar.styles'; -import {EmojiPickerContainer} from './EmojiPicker'; import {MessageActionsId} from '../MessageActions'; import {useMessageActionsState} from '../MessageActions.state'; @@ -218,7 +218,7 @@ const MessageReactions: FC = ({ {showEmojis ? ( - User[]; saveDraftState: (editor: string) => void; loadDraftState: () => Promise; @@ -112,12 +137,33 @@ const parseMentions = (editor: LexicalEditor, textValue: string, mentions: User[ }); }; +const editorConfig: InitialConfigType = { + namespace: 'WireLexicalEditor', + theme, + onError(error: unknown) { + logger.error(error); + }, + nodes: [ + MentionNode, + EmojiNode, + ListItemNode, + ListNode, + HeadingNode, + HorizontalRuleNode, + QuoteNode, + CodeNode, + CodeHighlightNode, + LinkNode, + ], +}; + export const RichTextEditor = ({ placeholder, children, hasLocalEphemeralTimer, replaceEmojis, editedMessage, + showFormatToolbar, onUpdate, saveDraftState, loadDraftState, @@ -129,48 +175,51 @@ export const RichTextEditor = ({ onSend, onSetup = () => {}, }: RichTextEditorProps) => { - // Emojis + const editorRef = useRef(null); const emojiPickerOpen = useRef(true); const mentionsOpen = useRef(true); - const editorConfig: InitialConfigType = { - namespace: 'WireLexicalEditor', - theme, - onError(error: unknown) { - logger.error(error); - }, - nodes: [MentionNode, EmojiNode], - }; - - const saveDraft = (editorState: EditorState) => { + const handleChange = (editorState: EditorState) => { saveDraftState(JSON.stringify(editorState.toJSON())); - }; - const parseUpdatedText = (editor: LexicalEditor, textValue: string) => { - onUpdate({ - text: replaceEmojis ? findAndTransformEmoji(textValue) : textValue, - mentions: parseMentions(editor, textValue, getMentionCandidates()), + editorState.read(() => { + if (!editorRef.current) { + return; + } + + const markdown = $convertToMarkdownString(TRANSFORMERS); + + onUpdate({ + text: replaceEmojis ? findAndTransformEmoji(markdown) : markdown, + mentions: parseMentions(editorRef.current!, markdown, getMentionCandidates()), + }); }); }; return ( -
    +
    - + { + editorRef.current = editor; + onSetup(editor!); + }} + /> - + {replaceEmojis && } + - } placeholder={} ErrorBoundary={LexicalErrorBoundary} @@ -182,8 +231,8 @@ export const RichTextEditor = ({ openStateRef={mentionsOpen} /> - - + + { if (!mentionsOpen.current && !emojiPickerOpen.current) { @@ -193,7 +242,7 @@ export const RichTextEditor = ({ />
    - + {showFormatToolbar && } {children} ); diff --git a/src/script/components/RichTextEditor/components/FormatToolbar/FormatButton/FormatButton.styles.ts b/src/script/components/RichTextEditor/components/FormatToolbar/FormatButton/FormatButton.styles.ts new file mode 100644 index 00000000000..030c5fa71e0 --- /dev/null +++ b/src/script/components/RichTextEditor/components/FormatToolbar/FormatButton/FormatButton.styles.ts @@ -0,0 +1,69 @@ +/* + * Wire + * Copyright (C) 2024 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 {CSSObject} from '@emotion/react'; + +export const buttonStyles: CSSObject = { + fontSize: 'var(--font-size-small)', + fontWeight: 'var(--font-weight-medium)', + lineHeight: 'var(--line-height-sm)', + display: 'flex', + alignItems: 'center', + justifyContent: 'center', + padding: '8px 12px', + border: '1px solid var(--button-tertiary-border)', + background: 'var(--button-tertiary-bg)', + color: 'var(--main-color)', + height: '32px', + + '&:focus-visible': { + border: '1px solid var(--accent-color-focus)', + borderRadius: 0, + backgroundColor: 'var(--button-tertiary-hover-bg)', + outline: 'none', + }, + + '&:hover': { + backgroundColor: 'var(--button-tertiary-hover-bg)', + border: '1px solid var(--button-tertiary-hover-border)', + }, + + '&:first-of-type': { + borderRadius: '12px 0 0 12px', + }, + + '&:last-of-type': { + borderRadius: '0 12px 12px 0', + }, +}; + +export const buttonActiveStyles: CSSObject = { + border: '1px solid var(--accent-color-300)', + background: 'var(--accent-color-highlight)', + color: 'var(--accent-color)', + + '&:hover': { + background: 'var(--accent-color-highlight)', + border: '1px solid var(--accent-color-focus)', + }, + + '& > svg': { + fill: 'var(--accent-color)', + }, +}; diff --git a/src/script/components/RichTextEditor/components/FormatToolbar/FormatButton/FormatButton.tsx b/src/script/components/RichTextEditor/components/FormatToolbar/FormatButton/FormatButton.tsx new file mode 100644 index 00000000000..8c21b29d018 --- /dev/null +++ b/src/script/components/RichTextEditor/components/FormatToolbar/FormatButton/FormatButton.tsx @@ -0,0 +1,48 @@ +/* + * Wire + * Copyright (C) 2024 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 {ElementType} from 'react'; + +import {css} from '@emotion/react'; + +import {buttonActiveStyles, buttonStyles} from './FormatButton.styles'; + +interface FormatButtonProps { + label: string; + icon: ElementType; + active: boolean; + onClick: () => void; +} + +export const FormatButton = ({label, icon: Icon, active, onClick}: FormatButtonProps) => { + return ( + + ); +}; diff --git a/src/script/components/RichTextEditor/components/FormatToolbar/FormatToolbar.styles.ts b/src/script/components/RichTextEditor/components/FormatToolbar/FormatToolbar.styles.ts new file mode 100644 index 00000000000..d090a351563 --- /dev/null +++ b/src/script/components/RichTextEditor/components/FormatToolbar/FormatToolbar.styles.ts @@ -0,0 +1,27 @@ +/* + * Wire + * Copyright (C) 2024 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 {CSSObject} from '@emotion/react'; + +export const wrapperStyles: CSSObject = { + display: 'flex', + alignItems: 'center', + margin: '8px 0 8px auto', + gridArea: 'toolbar', +}; diff --git a/src/script/components/RichTextEditor/components/FormatToolbar/FormatToolbar.tsx b/src/script/components/RichTextEditor/components/FormatToolbar/FormatToolbar.tsx new file mode 100644 index 00000000000..78cc63660d5 --- /dev/null +++ b/src/script/components/RichTextEditor/components/FormatToolbar/FormatToolbar.tsx @@ -0,0 +1,100 @@ +/* + * Wire + * Copyright (C) 2024 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 {useLexicalComposerContext} from '@lexical/react/LexicalComposerContext'; +import {FORMAT_TEXT_COMMAND, TextFormatType} from 'lexical'; + +import { + BoldIcon, + BulletListIcon, + CodeIcon, + HeadingIcon, + ItalicIcon, + NumberedListIcon, + StrikethroughIcon, +} from '@wireapp/react-ui-kit'; + +import {t} from 'Util/LocalizerUtil'; + +import {FormatButton} from './FormatButton/FormatButton'; +import {wrapperStyles} from './FormatToolbar.styles'; +import {useHeadingState} from './useHeadingState/useHeadingState'; +import {useListState} from './useListState/useListState'; +import {useToolbarState} from './useToolbarState/useToolbarState'; + +export const FormatToolbar = () => { + const [editor] = useLexicalComposerContext(); + + const {activeFormats} = useToolbarState(); + + const {toggleHeading} = useHeadingState(); + + const {toggleList} = useListState(); + + const formatText = (format: Extract) => { + editor.dispatchCommand(FORMAT_TEXT_COMMAND, format); + }; + + return ( +
    + + formatText('bold')} + /> + formatText('italic')} + /> + formatText('strikethrough')} + /> + toggleList('unordered')} + /> + toggleList('ordered')} + /> + formatText('code')} + /> +
    + ); +}; diff --git a/src/script/components/RichTextEditor/components/FormatToolbar/common/isNodeHeading/isNodeHeading.test.ts b/src/script/components/RichTextEditor/components/FormatToolbar/common/isNodeHeading/isNodeHeading.test.ts new file mode 100644 index 00000000000..23bc1b891ac --- /dev/null +++ b/src/script/components/RichTextEditor/components/FormatToolbar/common/isNodeHeading/isNodeHeading.test.ts @@ -0,0 +1,97 @@ +/* + * Wire + * Copyright (C) 2024 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 {ElementNode, TextNode} from 'lexical'; + +import {isNodeHeading} from './isNodeHeading'; + +const createMockElementNode = (type: string, tag: string, parent: ElementNode | null = null): ElementNode => { + return { + getType: () => type, + getTag: () => tag, + getParent: () => parent, + } as unknown as ElementNode; +}; + +const createMockTextNode = (parent: ElementNode | null = null): TextNode => { + return { + getType: () => 'text', + getTag: () => '', + getParent: () => parent, + } as unknown as TextNode; +}; + +describe('isNodeHeading', () => { + it('returns false when node is null', () => { + expect(isNodeHeading(null)).toBe(false); + }); + + it('returns false for a non-heading node', () => { + const textNode = createMockTextNode(); + + expect(isNodeHeading(textNode)).toBe(false); + }); + + it('returns true for a heading node with the default tag', () => { + const headingNode = createMockElementNode('heading', 'h1'); + + expect(isNodeHeading(headingNode)).toBe(true); + }); + + it('returns false for a heading node with a different tag', () => { + const headingNode = createMockElementNode('heading', 'h2'); + + expect(isNodeHeading(headingNode)).toBe(false); + }); + + it('returns true for a heading node with a specified matching tag', () => { + const headingNode = createMockElementNode('heading', 'h3'); + + expect(isNodeHeading(headingNode, 'h3')).toBe(true); + }); + + it('returns true when a parent node is a matching heading', () => { + const parentHeadingNode = createMockElementNode('heading', 'h2'); + const childNode = createMockTextNode(parentHeadingNode); + + expect(isNodeHeading(childNode, 'h2')).toBe(true); + }); + + it('returns false when no parent node matches the heading tag', () => { + const parentHeadingNode = createMockElementNode('heading', 'h3'); + const childNode = createMockTextNode(parentHeadingNode); + + expect(isNodeHeading(childNode, 'h1')).toBe(false); + }); + + it('handles deeply nested parent heading nodes', () => { + const grandparentHeadingNode = createMockElementNode('heading', 'h4'); + const parentNode = createMockElementNode('heading', 'h3', grandparentHeadingNode); + const childNode = createMockTextNode(parentNode); + + expect(isNodeHeading(childNode, 'h4')).toBe(true); + }); + + it('returns false when no heading is in the ancestor chain', () => { + const nonHeadingParent = createMockElementNode('div', ''); + const childNode = createMockTextNode(nonHeadingParent); + + expect(isNodeHeading(childNode, 'h1')).toBe(false); + }); +}); diff --git a/src/script/components/RichTextEditor/components/FormatToolbar/common/isNodeHeading/isNodeHeading.ts b/src/script/components/RichTextEditor/components/FormatToolbar/common/isNodeHeading/isNodeHeading.ts new file mode 100644 index 00000000000..4f201806381 --- /dev/null +++ b/src/script/components/RichTextEditor/components/FormatToolbar/common/isNodeHeading/isNodeHeading.ts @@ -0,0 +1,35 @@ +/* + * Wire + * Copyright (C) 2024 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 {ElementNode, TextNode} from 'lexical'; + +type HeadingTag = 'h1' | 'h2' | 'h3' | 'h4' | 'h5' | 'h6'; + +export const isNodeHeading = (node: TextNode | ElementNode | null, headingTag: HeadingTag = 'h1'): boolean => { + if (!node) { + return false; + } + + // @ts-expect-error: `getTag` is not specified in the type definition, but it exists + if (node.getType() === 'heading' && node.getTag() === headingTag) { + return true; + } + + return isNodeHeading(node.getParent(), headingTag); +}; diff --git a/src/script/components/RichTextEditor/components/FormatToolbar/common/isNodeList/isNodeList.test.ts b/src/script/components/RichTextEditor/components/FormatToolbar/common/isNodeList/isNodeList.test.ts new file mode 100644 index 00000000000..5f62dab2239 --- /dev/null +++ b/src/script/components/RichTextEditor/components/FormatToolbar/common/isNodeList/isNodeList.test.ts @@ -0,0 +1,102 @@ +/* + * Wire + * Copyright (C) 2024 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 {ElementNode, TextNode} from 'lexical'; + +import {isNodeList} from './isNodeList'; + +const createMockElementNode = (type: string, tag: string, parent: ElementNode | null = null): ElementNode => { + return { + getType: () => type, + getTag: () => tag, + getParent: () => parent, + } as unknown as ElementNode; +}; + +const createMockTextNode = (parent: ElementNode | null = null): TextNode => { + return { + getType: () => 'text', + getTag: () => '', + getParent: () => parent, + } as unknown as TextNode; +}; + +describe('isNodeList', () => { + it('returns false when node is null', () => { + expect(isNodeList(null, 'ordered')).toBe(false); + }); + + it('returns false for a non-list node', () => { + const textNode = createMockTextNode(); + + expect(isNodeList(textNode, 'ordered')).toBe(false); + }); + + it('returns true for an ordered list node', () => { + const orderedListNode = createMockElementNode('list', 'ol'); + + expect(isNodeList(orderedListNode, 'ordered')).toBe(true); + }); + + it('returns false for an unordered list node when looking for ordered', () => { + const unorderedListNode = createMockElementNode('list', 'ul'); + + expect(isNodeList(unorderedListNode, 'ordered')).toBe(false); + }); + + it('returns true for an unordered list node', () => { + const unorderedListNode = createMockElementNode('list', 'ul'); + + expect(isNodeList(unorderedListNode, 'unordered')).toBe(true); + }); + + it('returns false for an ordered list node when looking for unordered', () => { + const orderedListNode = createMockElementNode('list', 'ol'); + + expect(isNodeList(orderedListNode, 'unordered')).toBe(false); + }); + + it('returns true when a parent node is an ordered list', () => { + const parentOrderedListNode = createMockElementNode('list', 'ol'); + const childNode = createMockTextNode(parentOrderedListNode); + + expect(isNodeList(childNode, 'ordered')).toBe(true); + }); + + it('returns true when a parent node is an unordered list', () => { + const parentUnorderedListNode = createMockElementNode('list', 'ul'); + const childNode = createMockTextNode(parentUnorderedListNode); + + expect(isNodeList(childNode, 'unordered')).toBe(true); + }); + + it('returns false when no parent node matches the list type', () => { + const childNode = createMockTextNode(); + + expect(isNodeList(childNode, 'ordered')).toBe(false); + }); + + it('handles deeply nested parent list nodes', () => { + const grandparentOrderedListNode = createMockElementNode('list', 'ol'); + const parentUnorderedListNode = createMockElementNode('list', 'ul', grandparentOrderedListNode); + const childNode = createMockTextNode(parentUnorderedListNode); + + expect(isNodeList(childNode, 'ordered')).toBe(true); + }); +}); diff --git a/src/script/components/RichTextEditor/components/FormatToolbar/common/isNodeList/isNodeList.ts b/src/script/components/RichTextEditor/components/FormatToolbar/common/isNodeList/isNodeList.ts new file mode 100644 index 00000000000..90d3928f027 --- /dev/null +++ b/src/script/components/RichTextEditor/components/FormatToolbar/common/isNodeList/isNodeList.ts @@ -0,0 +1,35 @@ +/* + * Wire + * Copyright (C) 2024 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 {ElementNode, TextNode} from 'lexical'; + +export const isNodeList = (node: TextNode | ElementNode | null, listType: 'ordered' | 'unordered'): boolean => { + if (!node) { + return false; + } + + const tag = listType === 'ordered' ? 'ol' : 'ul'; + + // @ts-expect-error: `getTag` is not specified in the type definition, but it exists + if (node.getType() === 'list' && node.getTag() === tag) { + return true; + } + + return isNodeList(node.getParent(), listType); +}; diff --git a/src/script/components/RichTextEditor/components/FormatToolbar/useHeadingState/headingCommand.ts b/src/script/components/RichTextEditor/components/FormatToolbar/useHeadingState/headingCommand.ts new file mode 100644 index 00000000000..148a5c09606 --- /dev/null +++ b/src/script/components/RichTextEditor/components/FormatToolbar/useHeadingState/headingCommand.ts @@ -0,0 +1,43 @@ +/* + * Wire + * Copyright (C) 2024 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 {$createHeadingNode} from '@lexical/rich-text'; +import {$getSelection, $isRangeSelection} from 'lexical'; + +export const headingCommand = () => { + const selection = $getSelection(); + + if ($isRangeSelection(selection)) { + // Leaving "h1" instead of dynamic heading level selection + // As long as we don't support other types (via rich text editor buttons), this is fine + const headingNode = $createHeadingNode('h1'); + + const node = selection.anchor.getNode(); + const parent = node.getParent(); + + if (!parent || parent.getType() === 'root') { + return false; + } + + parent.replace(headingNode); + headingNode.append(...selection.extract()); + } + + return true; +}; diff --git a/src/script/components/RichTextEditor/components/FormatToolbar/useHeadingState/useHeadingState.ts b/src/script/components/RichTextEditor/components/FormatToolbar/useHeadingState/useHeadingState.ts new file mode 100644 index 00000000000..3e2b7b277b3 --- /dev/null +++ b/src/script/components/RichTextEditor/components/FormatToolbar/useHeadingState/useHeadingState.ts @@ -0,0 +1,67 @@ +/* + * Wire + * Copyright (C) 2024 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 {$getSelection, $isRangeSelection, $createParagraphNode, createCommand} from 'lexical'; + +import {headingCommand} from './headingCommand'; + +import {isNodeHeading} from '../common/isNodeHeading/isNodeHeading'; + +const INSERT_HEADING_COMMAND = createCommand(); + +export const useHeadingState = () => { + const [editor] = useLexicalComposerContext(); + + useEffect(() => { + return editor.registerCommand(INSERT_HEADING_COMMAND, headingCommand, 0); + }, [editor]); + + const toggleHeading = () => { + editor.update(() => { + const selection = $getSelection(); + + if (!$isRangeSelection(selection)) { + return; + } + + const anchorNode = selection.anchor.getNode(); + const isHeading = isNodeHeading(anchorNode); + + if (!isHeading) { + editor.dispatchCommand(INSERT_HEADING_COMMAND, {}); + return; + } + + const paragraphNode = $createParagraphNode(); + const headingNode = anchorNode.getParent(); + + if (!headingNode) { + return; + } + + headingNode.replace(paragraphNode); + paragraphNode.append(...headingNode.getChildren()); + }); + }; + + return {toggleHeading}; +}; diff --git a/src/script/components/RichTextEditor/components/FormatToolbar/useListState/useListState.ts b/src/script/components/RichTextEditor/components/FormatToolbar/useListState/useListState.ts new file mode 100644 index 00000000000..cdc525e0049 --- /dev/null +++ b/src/script/components/RichTextEditor/components/FormatToolbar/useListState/useListState.ts @@ -0,0 +1,47 @@ +/* + * Wire + * Copyright (C) 2024 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 {INSERT_ORDERED_LIST_COMMAND, INSERT_UNORDERED_LIST_COMMAND, REMOVE_LIST_COMMAND} from '@lexical/list'; +import {useLexicalComposerContext} from '@lexical/react/LexicalComposerContext'; +import {$getSelection, $isRangeSelection} from 'lexical'; + +import {isNodeList} from '../common/isNodeList/isNodeList'; + +export const useListState = () => { + const [editor] = useLexicalComposerContext(); + + const toggleList = (listType: 'unordered' | 'ordered') => { + editor.update(() => { + const selection = $getSelection(); + + if (!$isRangeSelection(selection)) { + return; + } + + const node = selection.anchor.getNode(); + const isActive = isNodeList(node, listType); + + const command = listType === 'unordered' ? INSERT_UNORDERED_LIST_COMMAND : INSERT_ORDERED_LIST_COMMAND; + + editor.dispatchCommand(isActive ? REMOVE_LIST_COMMAND : command, undefined); + }); + }; + + return {toggleList}; +}; diff --git a/src/script/components/RichTextEditor/components/FormatToolbar/useToolbarState/useToolbarState.ts b/src/script/components/RichTextEditor/components/FormatToolbar/useToolbarState/useToolbarState.ts new file mode 100644 index 00000000000..43cf1e4dea0 --- /dev/null +++ b/src/script/components/RichTextEditor/components/FormatToolbar/useToolbarState/useToolbarState.ts @@ -0,0 +1,69 @@ +/* + * Wire + * Copyright (C) 2024 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 {useCallback, useEffect, useState} from 'react'; + +import {useLexicalComposerContext} from '@lexical/react/LexicalComposerContext'; +import {$getSelection, $isRangeSelection} from 'lexical'; + +import {isNodeHeading} from '../common/isNodeHeading/isNodeHeading'; +import {isNodeList} from '../common/isNodeList/isNodeList'; + +type FormatTypes = 'bold' | 'italic' | 'strikethrough' | 'code' | 'unorderedList' | 'orderedList' | 'heading'; + +export const useToolbarState = () => { + const [editor] = useLexicalComposerContext(); + + const [activeFormats, setActiveFormats] = useState([]); + + const updateToolbar = useCallback(() => { + editor.update(() => { + const selection = $getSelection(); + if (!$isRangeSelection(selection)) { + return; + } + + const node = selection.anchor.getNode(); + + const formatChecks: Array<{format: FormatTypes; check: () => boolean}> = [ + {format: 'bold', check: () => selection.hasFormat('bold')}, + {format: 'italic', check: () => selection.hasFormat('italic')}, + {format: 'strikethrough', check: () => selection.hasFormat('strikethrough')}, + {format: 'code', check: () => selection.hasFormat('code')}, + {format: 'unorderedList', check: () => isNodeList(node, 'unordered')}, + {format: 'orderedList', check: () => isNodeList(node, 'ordered')}, + {format: 'heading', check: () => isNodeHeading(node)}, + ]; + + const activeFormats = formatChecks.filter(({check}) => check()).map(({format}) => format); + + setActiveFormats(activeFormats); + }); + }, [editor]); + + useEffect(() => { + if (!editor) { + return undefined; + } + + return editor.registerUpdateListener(updateToolbar); + }, [editor, updateToolbar]); + + return {activeFormats}; +}; diff --git a/src/script/components/RichTextEditor/plugins/EditedMessagePlugin.tsx b/src/script/components/RichTextEditor/plugins/EditedMessagePlugin/EditedMessagePlugin.tsx similarity index 53% rename from src/script/components/RichTextEditor/plugins/EditedMessagePlugin.tsx rename to src/script/components/RichTextEditor/plugins/EditedMessagePlugin/EditedMessagePlugin.tsx index ac79ece37c8..5ad120b038f 100644 --- a/src/script/components/RichTextEditor/plugins/EditedMessagePlugin.tsx +++ b/src/script/components/RichTextEditor/plugins/EditedMessagePlugin/EditedMessagePlugin.tsx @@ -19,16 +19,19 @@ import {useEffect} from 'react'; +import {$convertFromMarkdownString, TRANSFORMERS} from '@lexical/markdown'; import {useLexicalComposerContext} from '@lexical/react/LexicalComposerContext'; import {$getRoot, $setSelection} from 'lexical'; import {ContentMessage} from 'src/script/entity/message/ContentMessage'; -import {toEditorNodes} from '../utils/messageToEditorNodes'; +import {getMentionMarkdownTransformer} from './getMentionMarkdownTransformer/getMentionMarkdownTransformer'; +import {getMentionNodesFromMessage} from './getMentionNodesFromMessage/getMentionNodesFromMessage'; type Props = { message?: ContentMessage; }; + export function EditedMessagePlugin({message}: Props): null { const [editor] = useLexicalComposerContext(); @@ -41,8 +44,22 @@ export function EditedMessagePlugin({message}: Props): null { root.clear(); // This behaviour is needed to clear selection, if we not clear selection will be on beginning. $setSelection(null); - // Replace the current root with the content of the message being edited - root.append(toEditorNodes(message)); + + const messageContent = message.getFirstAsset().text; + + const mentionNodes = getMentionNodesFromMessage(message); + + const allowedMentions = mentionNodes.map(node => node.getTextContent()); + + const mentionMarkdownTransformer = getMentionMarkdownTransformer(allowedMentions); + + // Text comes from the message is in the raw markdown format, we need to convert it to the editor format (preview), display **bold** as bold, etc. + // The below function do that by getting the text, and transofrming it to the desired format. + // During the transformation, we have to tell the editor to transofrm mentions as well. + // We can't do that by diretcly updating the $root (e.g. $root.appent(...MentionNodes)), because this function will overwrite the result. + // One way of overcoming this issue is to use a custom transformer (quite a hacky way). Transformers are responisble for converting the text to the desired format (e.g. **bold** to bold). + $convertFromMarkdownString(messageContent, [...TRANSFORMERS, mentionMarkdownTransformer]); + editor.focus(); }); }); diff --git a/src/script/components/RichTextEditor/plugins/EditedMessagePlugin/getMentionMarkdownTransformer/getMentionMarkdownTransformer.ts b/src/script/components/RichTextEditor/plugins/EditedMessagePlugin/getMentionMarkdownTransformer/getMentionMarkdownTransformer.ts new file mode 100644 index 00000000000..75d6b920981 --- /dev/null +++ b/src/script/components/RichTextEditor/plugins/EditedMessagePlugin/getMentionMarkdownTransformer/getMentionMarkdownTransformer.ts @@ -0,0 +1,51 @@ +/* + * Wire + * Copyright (C) 2024 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 {TextMatchTransformer} from '@lexical/markdown'; + +import {$createMentionNode, $isMentionNode, MentionNode} from 'Components/RichTextEditor/nodes/MentionNode'; + +// Cutom transformer for handling mentions when converting markdown to editor format. +// Based on https://github.com/facebook/lexical/blob/main/packages/lexical-markdown/src/MarkdownTransformers.ts#L489 +export const getMentionMarkdownTransformer = (allowedMentions: Array): TextMatchTransformer => { + return { + dependencies: [MentionNode], + export: node => { + if (!$isMentionNode(node)) { + return null; + } + return `@${node.getTextContent()}`; + }, + importRegExp: /(@\w+)/, + regExp: /(@\w+)$/, + replace: (textNode, match) => { + const [mentionText] = match; + + if (!allowedMentions.includes(mentionText)) { + return; + } + + const mentionNode = $createMentionNode('@', mentionText.slice(1)); + + textNode.replace(mentionNode); + }, + trigger: ' ', + type: 'text-match', + }; +}; diff --git a/src/script/components/RichTextEditor/utils/messageToEditorNodes.ts b/src/script/components/RichTextEditor/plugins/EditedMessagePlugin/getMentionNodesFromMessage/getMentionNodesFromMessage.ts similarity index 62% rename from src/script/components/RichTextEditor/utils/messageToEditorNodes.ts rename to src/script/components/RichTextEditor/plugins/EditedMessagePlugin/getMentionNodesFromMessage/getMentionNodesFromMessage.ts index 1ef110926e0..4765f4ceb3e 100644 --- a/src/script/components/RichTextEditor/utils/messageToEditorNodes.ts +++ b/src/script/components/RichTextEditor/plugins/EditedMessagePlugin/getMentionNodesFromMessage/getMentionNodesFromMessage.ts @@ -17,29 +17,16 @@ * */ -import {$createParagraphNode, $createTextNode} from 'lexical'; - import {ContentMessage} from 'src/script/entity/message/ContentMessage'; -import {createNodes} from './generateNodes'; - -import {Text} from '../../../entity/message/Text'; -import {$createMentionNode} from '../nodes/MentionNode'; +import {Text} from '../../../../../entity/message/Text'; +import {$createMentionNode, MentionNode} from '../../../nodes/MentionNode'; +import {createNodes} from '../../../utils/generateNodes'; -export function toEditorNodes(message: ContentMessage) { +export const getMentionNodesFromMessage = (message: ContentMessage): MentionNode[] => { const firstAsset = message.getFirstAsset() as Text; const newMentions = firstAsset.mentions().slice(); const nodes = createNodes(newMentions, firstAsset.text); - const paragraphs = nodes.map(node => { - if (node.type === 'Mention') { - return $createMentionNode('@', node.data.slice(1)); - } - - return $createTextNode(node.data); - }); - - const paragraphNode = $createParagraphNode(); - paragraphNode.append(...paragraphs); - return paragraphNode; -} + return nodes.filter(node => node.type === 'Mention').map(node => $createMentionNode('@', node.data.slice(1))); +}; diff --git a/src/script/components/RichTextEditor/plugins/SendPlugin.tsx b/src/script/components/RichTextEditor/plugins/SendPlugin.tsx index 200b9a44065..3d2e7fc0718 100644 --- a/src/script/components/RichTextEditor/plugins/SendPlugin.tsx +++ b/src/script/components/RichTextEditor/plugins/SendPlugin.tsx @@ -20,7 +20,9 @@ import {useEffect} from 'react'; import {useLexicalComposerContext} from '@lexical/react/LexicalComposerContext'; -import {COMMAND_PRIORITY_LOW, KEY_ENTER_COMMAND} from 'lexical'; +import {COMMAND_PRIORITY_LOW, INSERT_PARAGRAPH_COMMAND, KEY_ENTER_COMMAND} from 'lexical'; + +import {Config} from 'src/script/Config'; type Props = { onSend: () => void; @@ -37,7 +39,16 @@ export function SendPlugin({onSend}: Props): null { return false; } + // Mimic the "Enter" behavior when a user press "Shift + Enter" + // It's useful for the rich text editor, especially when creating lists if (event.shiftKey) { + const messageFormatButtonsEnabled = Config.getConfig().FEATURE.ENABLE_MESSAGE_FORMAT_BUTTONS; + + if (messageFormatButtonsEnabled) { + event.preventDefault(); + return editor.dispatchCommand(INSERT_PARAGRAPH_COMMAND, undefined); + } + return true; } diff --git a/src/script/storage/StorageKey.ts b/src/script/storage/StorageKey.ts index 9af20481134..cad51b2090d 100644 --- a/src/script/storage/StorageKey.ts +++ b/src/script/storage/StorageKey.ts @@ -39,6 +39,9 @@ export const StorageKey = { SEARCH: { SUGGESTED_SEARCH_ETS: 'z.storage.StorageKey.SEARCH.SUGGESTED_SEARCH_ETS', }, + INPUT: { + SHOW_FORMATTING: 'z.storage.StorageKey.INPUT.SHOW_FORMATTING', + }, }; export const ROOT_FONT_SIZE_KEY = 'root-font-size'; diff --git a/src/script/tracking/EventName.ts b/src/script/tracking/EventName.ts index 83e35752d08..a0498c5b20e 100644 --- a/src/script/tracking/EventName.ts +++ b/src/script/tracking/EventName.ts @@ -64,4 +64,13 @@ export const EventName = { BACKUP_CREATED: 'history.backup_created', BACKUP_CANCELLED: 'history.backup_cancelled', }, + INPUT: { + FORMAT_TEXT: { + ENABLED: 'input.rich_text_editor.enabled', + DISABLED: 'input.rich_text_editor.disabled', + }, + EMOJI_MODAL: { + EMOJI_PICKED: 'input.emoji_modal.emoji_picked', + }, + }, }; diff --git a/src/script/util/messageRenderer.ts b/src/script/util/messageRenderer.ts index 5a9097351b9..cb30d4fb118 100644 --- a/src/script/util/messageRenderer.ts +++ b/src/script/util/messageRenderer.ts @@ -43,7 +43,20 @@ const markdownit = new MarkdownIt('zero', { html: false, langPrefix: 'lang-', linkify: true, -}).enable(['autolink', 'backticks', 'code', 'emphasis', 'escape', 'fence', 'heading', 'link', 'linkify', 'newline']); +}).enable([ + 'autolink', + 'backticks', + 'code', + 'emphasis', + 'escape', + 'fence', + 'heading', + 'link', + 'linkify', + 'newline', + 'list', + 'strikethrough', +]); const originalFenceRule = markdownit.renderer.rules.fence!; diff --git a/src/style/components/lexical-input.less b/src/style/components/lexical-input.less index 903278def17..f0b2b4d8501 100644 --- a/src/style/components/lexical-input.less +++ b/src/style/components/lexical-input.less @@ -9,6 +9,7 @@ position: relative; display: flex; width: 100%; + min-width: 160px; align-items: center; } @@ -43,7 +44,7 @@ .editor-placeholder { position: absolute; top: 50%; - left: 10px; + left: 0; display: inline-block; overflow: hidden; @@ -168,3 +169,51 @@ flex-grow: 1; line-height: 20px; } + +.editor-bold { + font-weight: bold; +} + +.editor-italic { + font-style: italic; +} + +.editor-strikethrough { + text-decoration: line-through; +} + +.editor-code { + display: inline-block; + padding: 2px 4px; + border-radius: 4px; + background: var(--foreground-fade-8); +} + +.editor-list { + padding-left: 24px; + margin: 0; + + &--unordered { + list-style-type: disc; + } + + &--ordered { + list-style-type: decimal; + } +} + +.editor-heading { + margin: 8px 0; + + &--1 { + font-size: var(--font-size-xlarge); + } + + &--2 { + font-size: var(--font-size-large); + } + + &--3 { + font-size: var(--font-size-base); + } +} diff --git a/src/style/content/conversation/input-bar.less b/src/style/content/conversation/input-bar.less index 0e418e09e4a..5c44226187b 100644 --- a/src/style/content/conversation/input-bar.less +++ b/src/style/content/conversation/input-bar.less @@ -106,9 +106,10 @@ .list-unstyled; display: flex; - align-items: center; + align-items: flex-end; justify-content: flex-end; margin: 0; + margin-bottom: 8px; &.controls-right-shrinked { min-width: auto; @@ -125,8 +126,7 @@ display: flex; @media (max-width: 768px) { - width: 75px; - margin-bottom: 5px; + margin-bottom: 0px; } body.theme-dark &:focus-visible, @@ -143,9 +143,10 @@ body.theme-dark &--send { width: 40px; height: 40px; - align-self: center; + align-self: flex-end; border: 0; border-radius: 50%; + margin-bottom: 6px; margin-left: 10px; background-color: var(--accent-color-500); color: var(--white); @@ -158,6 +159,10 @@ display: flex; } + @media (min-width: 900px) { + margin-bottom: 8px; + } + &[disabled]:not([disabled='false']) { background-color: var(--gray-70); color: var(--white); @@ -335,10 +340,26 @@ margin: 0; text-overflow: ellipsis; } + + &:not(pre) > code { + padding: 2px 4px; + border-radius: 4px; + background: var(--foreground-fade-8); + } + + ul { + padding: 0 16px; + margin: 0; + } + } + + .md-heading { + .text-medium; } .message-mention { .text-medium; + color: var(--accent-color); } &__image img { @@ -380,3 +401,71 @@ transform: translateY(0); } } + +.input-bar-container { + --input-bar-avatar-size: 24px; + --input-bar-avatar-spacing: 20px; + + display: grid; + padding-left: var(--input-bar-avatar-spacing); + grid-template-areas: + 'avatar input' + 'buttons buttons'; + grid-template-columns: calc(var(--input-bar-avatar-size) + var(--input-bar-avatar-spacing)) 1fr; + row-gap: 8px; + + @media (min-width: 900px) { + grid-template-areas: 'avatar input buttons'; + } + + &--with-toolbar { + grid-template-areas: + 'toolbar toolbar' + 'avatar input' + 'buttons buttons'; + grid-template-columns: calc(var(--input-bar-avatar-size) + var(--input-bar-avatar-spacing)) 1fr; + grid-template-rows: auto auto; + row-gap: 0; + + @media (min-width: 1050px) { + grid-template-areas: + 'avatar input' + 'toolbar buttons'; + } + } +} + +.input-bar-field { + display: flex; + align-items: flex-start; + justify-content: center; + gap: var(--input-bar-avatar-spacing); + grid-area: input; +} + +.input-bar-avatar { + display: flex; + width: var(--input-bar-avatar-size); + height: var(--input-bar-avatar-size); + margin-top: 16px; + grid-area: avatar; +} + +.input-bar-buttons { + width: 100%; + height: 100%; + max-height: var(--conversation-input-min-height); + align-items: center; + margin-top: auto; + margin-bottom: 0; + margin-left: auto !important; + grid-area: buttons; + + @media (min-width: 900px) { + width: auto; + } + + @media (min-width: 1050px) { + margin-left: 0; + } +} diff --git a/src/style/content/conversation/message-quote.less b/src/style/content/conversation/message-quote.less index bd8029880c1..92f6d2e5fbe 100644 --- a/src/style/content/conversation/message-quote.less +++ b/src/style/content/conversation/message-quote.less @@ -69,6 +69,12 @@ max-height: unset; white-space: normal; + &:not(pre) > code { + padding: 2px 4px; + border-radius: 4px; + background: var(--foreground-fade-8); + } + pre { overflow: auto; max-width: var(--conversation-message-asset-width); diff --git a/src/types/i18n.d.ts b/src/types/i18n.d.ts index fc1304e9e4b..8c4c6fedabd 100644 --- a/src/types/i18n.d.ts +++ b/src/types/i18n.d.ts @@ -1380,6 +1380,13 @@ declare module 'I18n/en-US.json' { 'replyQuoteShowMore': `Show more`; 'replyQuoteTimeStampDate': `Original message from {date}`; 'replyQuoteTimeStampTime': `Original message from {time}`; + 'richTextHeading': `Heading`; + 'richTextBold': `Bold`; + 'richTextItalic': `Italic`; + 'richTextStrikethrough': `Strikethrough`; + 'richTextUnorderedList': `Unordered list`; + 'richTextOrderedList': `Ordered list`; + 'richTextCode': `Code`; 'roleAdmin': `Admin`; 'roleOwner': `Owner`; 'rolePartner': `External`; @@ -1536,9 +1543,11 @@ declare module 'I18n/en-US.json' { 'tooltipConversationCall': `Call`; 'tooltipConversationDetailsAddPeople': `Add participants to conversation ({shortcut})`; 'tooltipConversationDetailsRename': `Change conversation name`; + 'tooltipConversationEmoji': `Select emoji`; 'tooltipConversationEphemeral': `Self-deleting message`; 'tooltipConversationEphemeralAriaLabel': `Type a self-deleting message, currently set to {time}`; 'tooltipConversationFile': `Add file`; + 'tooltipConversationHideFormatting': `Hide formatting`; 'tooltipConversationInfo': `Conversation info`; 'tooltipConversationInputMoreThanTwoUserTyping': `{user1} and {count} more people are typing`; 'tooltipConversationInputOneUserTyping': `{user1} is typing`; @@ -1549,6 +1558,7 @@ declare module 'I18n/en-US.json' { 'tooltipConversationPing': `Ping`; 'tooltipConversationSearch': `Search`; 'tooltipConversationSendMessage': `Send message`; + 'tooltipConversationShowFormatting': `Show formatting`; 'tooltipConversationVideoCall': `Video Call`; 'tooltipConversationsArchive': `Archive ({shortcut})`; 'tooltipConversationsArchived': `Show archive ({number})`; diff --git a/yarn.lock b/yarn.lock index 4c181cfb5fb..3fcff759458 100644 --- a/yarn.lock +++ b/yarn.lock @@ -4261,256 +4261,256 @@ __metadata: languageName: node linkType: hard -"@lexical/clipboard@npm:0.21.0": - version: 0.21.0 - resolution: "@lexical/clipboard@npm:0.21.0" +"@lexical/clipboard@npm:0.20.2": + version: 0.20.2 + resolution: "@lexical/clipboard@npm:0.20.2" dependencies: - "@lexical/html": "npm:0.21.0" - "@lexical/list": "npm:0.21.0" - "@lexical/selection": "npm:0.21.0" - "@lexical/utils": "npm:0.21.0" - lexical: "npm:0.21.0" - checksum: 10/053c1b27d938ec32a9b55c06a4748a2cbd6133fa97f055bbaf7293c8f129ce3e6c0ad039bef383f98402cee6a0713b31e8e7e9ad52e8d065e4ab8d78dd43a30c + "@lexical/html": "npm:0.20.2" + "@lexical/list": "npm:0.20.2" + "@lexical/selection": "npm:0.20.2" + "@lexical/utils": "npm:0.20.2" + lexical: "npm:0.20.2" + checksum: 10/38e5e4b1f2cbd4d6c094bc54d1e6dccfd18641ac4eefbaf35f18caf5a1dbf0ee76749922aa5cf7b31f2b30e44459da27653a44995f92d855e653ad6ae4a1ca03 languageName: node linkType: hard -"@lexical/code@npm:0.21.0": - version: 0.21.0 - resolution: "@lexical/code@npm:0.21.0" +"@lexical/code@npm:0.20.2": + version: 0.20.2 + resolution: "@lexical/code@npm:0.20.2" dependencies: - "@lexical/utils": "npm:0.21.0" - lexical: "npm:0.21.0" + "@lexical/utils": "npm:0.20.2" + lexical: "npm:0.20.2" prismjs: "npm:^1.27.0" - checksum: 10/c89ee3b0ca5c186b8cb6ee47814c3b4f97802126e0f4f6a54e7f18b5ec4af41169d57b38f96be0ad85822e43ddd19fa73c51f2287825fa87bd179450d45645bb + checksum: 10/6fb6cc1bcb2beb2af69f97f5c13817d214b8d0b977e122977bff998fcb58265824dad0f2f5c606ad6c6e44a67db4ac9b59d3f65c1dc56e6c52065ccf851dd7b3 languageName: node linkType: hard -"@lexical/devtools-core@npm:0.21.0": - version: 0.21.0 - resolution: "@lexical/devtools-core@npm:0.21.0" +"@lexical/devtools-core@npm:0.20.2": + version: 0.20.2 + resolution: "@lexical/devtools-core@npm:0.20.2" dependencies: - "@lexical/html": "npm:0.21.0" - "@lexical/link": "npm:0.21.0" - "@lexical/mark": "npm:0.21.0" - "@lexical/table": "npm:0.21.0" - "@lexical/utils": "npm:0.21.0" - lexical: "npm:0.21.0" + "@lexical/html": "npm:0.20.2" + "@lexical/link": "npm:0.20.2" + "@lexical/mark": "npm:0.20.2" + "@lexical/table": "npm:0.20.2" + "@lexical/utils": "npm:0.20.2" + lexical: "npm:0.20.2" peerDependencies: react: ">=17.x" react-dom: ">=17.x" - checksum: 10/b62e64b9d45c9e7860b7901aeeb67d481330bcfaf11e744c74488415e9a0d509fb4dba1cd3cc33f74090274a9b2a880b5c857f9faeb0f1c67fde515dc1b53b4d + checksum: 10/422059e26905c14c884009dbe5b52a53722e1817701d43316845ad5b5b3c5220367b323ab29ac8e4cdeedb25f1d51b9b9664604c14acd4b7636db07d7ecba608 languageName: node linkType: hard -"@lexical/dragon@npm:0.21.0": - version: 0.21.0 - resolution: "@lexical/dragon@npm:0.21.0" +"@lexical/dragon@npm:0.20.2": + version: 0.20.2 + resolution: "@lexical/dragon@npm:0.20.2" dependencies: - lexical: "npm:0.21.0" - checksum: 10/42a5cacb885b37affbb459ae103c03ce93274b3a550dec2134c4a799219aff7747b7c06909fb950da77dfe1682211bd6e9214a7076ac93efe3c66c7e1a2c51af + lexical: "npm:0.20.2" + checksum: 10/cf86588ec7612036c85dbfb1f720fcf0e6825d488fb8fcabedf44d8a69c6ed4be50987ab9a83339692c725bc440130a4ec92f82acc317cdf57ee408c53daf0d1 languageName: node linkType: hard -"@lexical/hashtag@npm:0.21.0": - version: 0.21.0 - resolution: "@lexical/hashtag@npm:0.21.0" +"@lexical/hashtag@npm:0.20.2": + version: 0.20.2 + resolution: "@lexical/hashtag@npm:0.20.2" dependencies: - "@lexical/utils": "npm:0.21.0" - lexical: "npm:0.21.0" - checksum: 10/3a67dd523276be67d7a631f1da6dfd084e3169407427d9cf2738ce37bd4eec3c8f56a0ae1dea81abfb78f7adeeed0548de33d7f026c31388201bc82f2d5a6a11 + "@lexical/utils": "npm:0.20.2" + lexical: "npm:0.20.2" + checksum: 10/ea07ed0a9a89309e576740f2058535c0fb90f581412fe6da0061b2bb7adbe717f43084d135f7e68b9c4a198d76bbb50c5c8122e6ed7d9a7158056cb7104fd89f languageName: node linkType: hard -"@lexical/history@npm:0.21.0": - version: 0.21.0 - resolution: "@lexical/history@npm:0.21.0" +"@lexical/history@npm:0.20.2": + version: 0.20.2 + resolution: "@lexical/history@npm:0.20.2" dependencies: - "@lexical/utils": "npm:0.21.0" - lexical: "npm:0.21.0" - checksum: 10/36d118ec972a216ccee2368035c05897f74c44553e48331b3587c502ff6e9608973c3a2c607ef622e41034a488ee95e7db182d43ead840aa8cfcf237923641e5 + "@lexical/utils": "npm:0.20.2" + lexical: "npm:0.20.2" + checksum: 10/6670ea8373daee1f5bb1b9480f09a88b47e6abf8b2908f19b7a6107ab0de039959ac264a48d1e3952eafde88ecf55813a65a117c97982713e9cdafcd1355677c languageName: node linkType: hard -"@lexical/html@npm:0.21.0": - version: 0.21.0 - resolution: "@lexical/html@npm:0.21.0" +"@lexical/html@npm:0.20.2": + version: 0.20.2 + resolution: "@lexical/html@npm:0.20.2" dependencies: - "@lexical/selection": "npm:0.21.0" - "@lexical/utils": "npm:0.21.0" - lexical: "npm:0.21.0" - checksum: 10/aa1bc7c0cd35d988e1bc1cac7907bd5305a9c4b9da012b0347c424a0f153e65a1d035d773a091f79d909e3130187c4a60eab9eee0b21d87b87a6fdbe5a5ebf4e + "@lexical/selection": "npm:0.20.2" + "@lexical/utils": "npm:0.20.2" + lexical: "npm:0.20.2" + checksum: 10/45cfd11c2e7fbd16df420edd415453655bcc2f69144ca0ea97c542422f5310ad861f911b86ebfb5a507da6cad8df3718d22c3d1499d678099bf8db8e223c5dbb languageName: node linkType: hard -"@lexical/link@npm:0.21.0": - version: 0.21.0 - resolution: "@lexical/link@npm:0.21.0" +"@lexical/link@npm:0.20.2": + version: 0.20.2 + resolution: "@lexical/link@npm:0.20.2" dependencies: - "@lexical/utils": "npm:0.21.0" - lexical: "npm:0.21.0" - checksum: 10/bcebc2fbbcb0a2758b653fb4d90361dfde169c6dd6b1ea49742b6126e2b78da43d32689e6bd9899d5cd59a3f593908714468de55a4ad1f0e88d03f1437f6fede + "@lexical/utils": "npm:0.20.2" + lexical: "npm:0.20.2" + checksum: 10/cf80d0aa181c871f5e021a615744105ff3803f2aa15694a862091f22154e97ac51167ebc4706d0e04e599663b5e226bc95ae1ffbdb280995a43a8abe7c5e6c2b languageName: node linkType: hard -"@lexical/list@npm:0.21.0": - version: 0.21.0 - resolution: "@lexical/list@npm:0.21.0" +"@lexical/list@npm:0.20.2": + version: 0.20.2 + resolution: "@lexical/list@npm:0.20.2" dependencies: - "@lexical/utils": "npm:0.21.0" - lexical: "npm:0.21.0" - checksum: 10/85279e37126472c0694777709435467d5daf0fd9783596c1874edd74d1abc0a3f266c670ff1a6ff04bca38d44cc58d12c3a3dba51d72dbc26e2f2155a97b105b + "@lexical/utils": "npm:0.20.2" + lexical: "npm:0.20.2" + checksum: 10/5d32fa1da1357b67d8ae3316d661ad9d9c315da233b02769f1b0b7c93088ad6c1b43e593e888b991580b933f8f05195c9222b0cf8ed469955eef7da8f0b2ff63 languageName: node linkType: hard -"@lexical/mark@npm:0.21.0": - version: 0.21.0 - resolution: "@lexical/mark@npm:0.21.0" +"@lexical/mark@npm:0.20.2": + version: 0.20.2 + resolution: "@lexical/mark@npm:0.20.2" dependencies: - "@lexical/utils": "npm:0.21.0" - lexical: "npm:0.21.0" - checksum: 10/cb045aa1be9345227a989acf343115c1bc3eaf7efc6c7f86f373257d0d9621068856de89016ba89c62092683b5595e7c1a254269d947058ec522d3bfddf278cc + "@lexical/utils": "npm:0.20.2" + lexical: "npm:0.20.2" + checksum: 10/510b8893f2942c93528f4aadfb68b45b6ca1197d14cc909bf863e16d5e226dac6cb238277cab3ff87a5dc6316c4daaec06701ec034c8f92ba75136be9cce11af languageName: node linkType: hard -"@lexical/markdown@npm:0.21.0": - version: 0.21.0 - resolution: "@lexical/markdown@npm:0.21.0" +"@lexical/markdown@npm:0.20.2": + version: 0.20.2 + resolution: "@lexical/markdown@npm:0.20.2" dependencies: - "@lexical/code": "npm:0.21.0" - "@lexical/link": "npm:0.21.0" - "@lexical/list": "npm:0.21.0" - "@lexical/rich-text": "npm:0.21.0" - "@lexical/text": "npm:0.21.0" - "@lexical/utils": "npm:0.21.0" - lexical: "npm:0.21.0" - checksum: 10/23d4a90686b8285bfd1d30c31dc25541d6f30f6a151cdd5f069088e9428110e0c046ba9f99bfb65c1a522f3ea8fb127d6a18c40fc9b94a69d75d0e8b54526b74 + "@lexical/code": "npm:0.20.2" + "@lexical/link": "npm:0.20.2" + "@lexical/list": "npm:0.20.2" + "@lexical/rich-text": "npm:0.20.2" + "@lexical/text": "npm:0.20.2" + "@lexical/utils": "npm:0.20.2" + lexical: "npm:0.20.2" + checksum: 10/ff92cf294ce6671e5e6b2cec0a95dba6a7909d958001e554600e6751d983ebc598ff933b219f3edc60df0b75b609c78d74ae4d16af9084c162e8f83ebb611485 languageName: node linkType: hard -"@lexical/offset@npm:0.21.0": - version: 0.21.0 - resolution: "@lexical/offset@npm:0.21.0" +"@lexical/offset@npm:0.20.2": + version: 0.20.2 + resolution: "@lexical/offset@npm:0.20.2" dependencies: - lexical: "npm:0.21.0" - checksum: 10/bcd8bf64bbe6c2b66f36219f5cd97be934e0f57c2c3a2dfccac06ec9b8b4542fdacb3843cdbcc644a272d5867786a46d5ba873175b3aa0ae97d845320f43d622 + lexical: "npm:0.20.2" + checksum: 10/55e744438451ba00073b251128a95f214a3d3a0ad4bf6d9c855689d3458c53582c7d2d4f04548327a8861b9e9b07f8517ec46d1b386c4b4438de41ddab5fa856 languageName: node linkType: hard -"@lexical/overflow@npm:0.21.0": - version: 0.21.0 - resolution: "@lexical/overflow@npm:0.21.0" +"@lexical/overflow@npm:0.20.2": + version: 0.20.2 + resolution: "@lexical/overflow@npm:0.20.2" dependencies: - lexical: "npm:0.21.0" - checksum: 10/96c0384e3714f3e5d6bd3cb58285627b2c0d6935e03bb2c5d511fb972286e57801be7ea635c1a3f647602dced28a9e1a74ad2701c186d7e9e6b9717c249d9187 + lexical: "npm:0.20.2" + checksum: 10/5f1577d5bfe9ab93aed220d0a9b6d17db6a3b722dc1ebf53062cdc2b2eb357a880aa4a779b40f6f844a7d4eed357a65f46e8061e9da8fa4f3ffc16dba736a44d languageName: node linkType: hard -"@lexical/plain-text@npm:0.21.0": - version: 0.21.0 - resolution: "@lexical/plain-text@npm:0.21.0" +"@lexical/plain-text@npm:0.20.2": + version: 0.20.2 + resolution: "@lexical/plain-text@npm:0.20.2" dependencies: - "@lexical/clipboard": "npm:0.21.0" - "@lexical/selection": "npm:0.21.0" - "@lexical/utils": "npm:0.21.0" - lexical: "npm:0.21.0" - checksum: 10/2d51f406f23a613cdb8c533ee120384263a2caf7d3c7713ba692757594c726b3e36f9dbf6957553e158d25c97f82712a6aff504548eae9ac94d9e61b8f1c8e18 + "@lexical/clipboard": "npm:0.20.2" + "@lexical/selection": "npm:0.20.2" + "@lexical/utils": "npm:0.20.2" + lexical: "npm:0.20.2" + checksum: 10/728d0ae5e3742eade87c639ce56fe41d5fab0cab1a15a4bae93be1074fed4c8fcc7f7534dd43139fbe7a4e68dc6030be3b3d22462b7096dab679f9d43acdfa48 languageName: node linkType: hard -"@lexical/react@npm:0.21.0": - version: 0.21.0 - resolution: "@lexical/react@npm:0.21.0" - dependencies: - "@lexical/clipboard": "npm:0.21.0" - "@lexical/code": "npm:0.21.0" - "@lexical/devtools-core": "npm:0.21.0" - "@lexical/dragon": "npm:0.21.0" - "@lexical/hashtag": "npm:0.21.0" - "@lexical/history": "npm:0.21.0" - "@lexical/link": "npm:0.21.0" - "@lexical/list": "npm:0.21.0" - "@lexical/mark": "npm:0.21.0" - "@lexical/markdown": "npm:0.21.0" - "@lexical/overflow": "npm:0.21.0" - "@lexical/plain-text": "npm:0.21.0" - "@lexical/rich-text": "npm:0.21.0" - "@lexical/selection": "npm:0.21.0" - "@lexical/table": "npm:0.21.0" - "@lexical/text": "npm:0.21.0" - "@lexical/utils": "npm:0.21.0" - "@lexical/yjs": "npm:0.21.0" - lexical: "npm:0.21.0" +"@lexical/react@npm:0.20.2": + version: 0.20.2 + resolution: "@lexical/react@npm:0.20.2" + dependencies: + "@lexical/clipboard": "npm:0.20.2" + "@lexical/code": "npm:0.20.2" + "@lexical/devtools-core": "npm:0.20.2" + "@lexical/dragon": "npm:0.20.2" + "@lexical/hashtag": "npm:0.20.2" + "@lexical/history": "npm:0.20.2" + "@lexical/link": "npm:0.20.2" + "@lexical/list": "npm:0.20.2" + "@lexical/mark": "npm:0.20.2" + "@lexical/markdown": "npm:0.20.2" + "@lexical/overflow": "npm:0.20.2" + "@lexical/plain-text": "npm:0.20.2" + "@lexical/rich-text": "npm:0.20.2" + "@lexical/selection": "npm:0.20.2" + "@lexical/table": "npm:0.20.2" + "@lexical/text": "npm:0.20.2" + "@lexical/utils": "npm:0.20.2" + "@lexical/yjs": "npm:0.20.2" + lexical: "npm:0.20.2" react-error-boundary: "npm:^3.1.4" peerDependencies: react: ">=17.x" react-dom: ">=17.x" - checksum: 10/e2605ba9dd8550da13c71e7747020c27b0a72e937107c06a57b77e407b0c205136a523f80079bf60213534d398e7a69d976be2ec2479e7701035b1eb3687374f + checksum: 10/f2af963ee156d9126ccd1e59934a43e976f746d2724a86805dddd29ae0a3dc0fc4fa83c176b969d6f919bb467c69b1965e50b619ee599c235b9aaa740c58626d languageName: node linkType: hard -"@lexical/rich-text@npm:0.21.0": - version: 0.21.0 - resolution: "@lexical/rich-text@npm:0.21.0" +"@lexical/rich-text@npm:0.20.2": + version: 0.20.2 + resolution: "@lexical/rich-text@npm:0.20.2" dependencies: - "@lexical/clipboard": "npm:0.21.0" - "@lexical/selection": "npm:0.21.0" - "@lexical/utils": "npm:0.21.0" - lexical: "npm:0.21.0" - checksum: 10/d4aa5793e51ad4146140807244e2739786805a274615bc4c322e67cd8e80079424d3b0d5f3d4471d6fa3aacde71b1a75a7e2f5087039fc766a151a44e9f37702 + "@lexical/clipboard": "npm:0.20.2" + "@lexical/selection": "npm:0.20.2" + "@lexical/utils": "npm:0.20.2" + lexical: "npm:0.20.2" + checksum: 10/e9d2dc05c67c41deec6014dc43e80ebfbb6c1ea4f033e5b685e37dc7c0ed8fff79af03a374396a54d8f4bed76e243f1570ceb6fa524932556d119868eb5f2ce0 languageName: node linkType: hard -"@lexical/selection@npm:0.21.0": - version: 0.21.0 - resolution: "@lexical/selection@npm:0.21.0" +"@lexical/selection@npm:0.20.2": + version: 0.20.2 + resolution: "@lexical/selection@npm:0.20.2" dependencies: - lexical: "npm:0.21.0" - checksum: 10/cf3870537ffee3eb8ca405726e6efea33a141354aba93081d02ddb3124b1b7971d5f3e2c4bcc30ceca4b381b09b9016343d6e405c337e2561fa09daa18f29476 + lexical: "npm:0.20.2" + checksum: 10/e1cfb02610c5ab5beaad62d6cec9ba3cc09238b4fa7b3fe8e4197b87027d4f4a6fdafff7bd9af7482e7581081947ee62a8ffa6132ce88cc75101eed48304fde9 languageName: node linkType: hard -"@lexical/table@npm:0.21.0": - version: 0.21.0 - resolution: "@lexical/table@npm:0.21.0" +"@lexical/table@npm:0.20.2": + version: 0.20.2 + resolution: "@lexical/table@npm:0.20.2" dependencies: - "@lexical/clipboard": "npm:0.21.0" - "@lexical/utils": "npm:0.21.0" - lexical: "npm:0.21.0" - checksum: 10/3cec1dbfcc6aa9b47ccad7c877485881a2d6754ad01349ddad1a364a60befb36ef4e6a2cadfde555f35a97db3266a507aeb73cf0044f6952d86585bd0445c5a7 + "@lexical/clipboard": "npm:0.20.2" + "@lexical/utils": "npm:0.20.2" + lexical: "npm:0.20.2" + checksum: 10/aeb749e3a7fd1e521a2a777cfef4348b2ee8c479490dfa025f7835cb4a9ccbd5ead672692d1367d9dfa90fc133b9b5778485ee6f660eb22275e7412ad784bf2e languageName: node linkType: hard -"@lexical/text@npm:0.21.0": - version: 0.21.0 - resolution: "@lexical/text@npm:0.21.0" +"@lexical/text@npm:0.20.2": + version: 0.20.2 + resolution: "@lexical/text@npm:0.20.2" dependencies: - lexical: "npm:0.21.0" - checksum: 10/ebee3e9490fd4704b1755d4dd290fd57aceeb81705d29c7cda38453b0cf6518a8fe4471c78c604e2010422bfb838a5c9a6aaef9b34b4e799883dfdb3b5bcbfec + lexical: "npm:0.20.2" + checksum: 10/9f3f6347e66646f9a3e4f786d26547d0192980376177ea92170bf14ac3ee5f90b52b0ad613ec92e7b405ef312e7592c3354413ba12305cbf2fe8ccfae6623d29 languageName: node linkType: hard -"@lexical/utils@npm:0.21.0": - version: 0.21.0 - resolution: "@lexical/utils@npm:0.21.0" +"@lexical/utils@npm:0.20.2": + version: 0.20.2 + resolution: "@lexical/utils@npm:0.20.2" dependencies: - "@lexical/list": "npm:0.21.0" - "@lexical/selection": "npm:0.21.0" - "@lexical/table": "npm:0.21.0" - lexical: "npm:0.21.0" - checksum: 10/f25819c09521545df638f64a0976a52efe81c12fb48accde8046c66fb685beffe6c3342bb067ec7aa1d938edc6575a10d48ea176a657484b9865d2704de1c2a4 + "@lexical/list": "npm:0.20.2" + "@lexical/selection": "npm:0.20.2" + "@lexical/table": "npm:0.20.2" + lexical: "npm:0.20.2" + checksum: 10/a4830730e71ca6851a97fdf7b2c73853e1d87b21559d2911fb181a4b9966cb85daf2dba8d4d210aeaed1dd9854fd856272d44fd8fe1ad38f1f70a31700d1d792 languageName: node linkType: hard -"@lexical/yjs@npm:0.21.0": - version: 0.21.0 - resolution: "@lexical/yjs@npm:0.21.0" +"@lexical/yjs@npm:0.20.2": + version: 0.20.2 + resolution: "@lexical/yjs@npm:0.20.2" dependencies: - "@lexical/offset": "npm:0.21.0" - "@lexical/selection": "npm:0.21.0" - lexical: "npm:0.21.0" + "@lexical/offset": "npm:0.20.2" + "@lexical/selection": "npm:0.20.2" + lexical: "npm:0.20.2" peerDependencies: yjs: ">=13.5.22" - checksum: 10/dce970aa7913f47f4844573f6beb810f795c59ef5c26a7e1edc8f72de2844a5ba910ccc303c6b7854d5be7fdd39e9c7c06666c3772387a6ab20e078159a747d6 + checksum: 10/e62dd2d5a94338804304f22bc396c80b294aa300ccc5935d018a61fce79569d3e72a5dd0a0c827502b73522fa570bf865b8668de74bee9cf91dec2319017baac languageName: node linkType: hard @@ -12797,10 +12797,10 @@ __metadata: languageName: node linkType: hard -"lexical@npm:0.21.0": - version: 0.21.0 - resolution: "lexical@npm:0.21.0" - checksum: 10/1f758b0cc02c76970aa912f7d0c32944d3f81b2208e730273b1dc88868cac29d06fe079196acba61fe15beff64aefc7e75faea1b3d6ec3323e142c79a0731ba8 +"lexical@npm:0.20.2": + version: 0.20.2 + resolution: "lexical@npm:0.20.2" + checksum: 10/718f42d0008168cb91e0f7c1475ac37a884cf54c10d1a26866f155157e86eeed9ea3993ec807b6719d32bdfe08ac96f6af09bbb5719e0cfcbfb353a4d5869804 languageName: node linkType: hard @@ -18747,8 +18747,12 @@ __metadata: "@emotion/react": "npm:11.11.4" "@faker-js/faker": "npm:9.3.0" "@formatjs/cli": "npm:6.3.14" - "@lexical/history": "npm:0.21.0" - "@lexical/react": "npm:0.21.0" + "@lexical/code": "npm:0.20.2" + "@lexical/history": "npm:0.20.2" + "@lexical/list": "npm:0.20.2" + "@lexical/markdown": "npm:0.20.2" + "@lexical/react": "npm:0.20.2" + "@lexical/rich-text": "npm:0.20.2" "@mediapipe/tasks-vision": "npm:0.10.20" "@roamhq/wrtc": "npm:0.8.0" "@testing-library/dom": "npm:^10.4.0" @@ -18833,7 +18837,7 @@ __metadata: knockout: "npm:3.5.1" less: "npm:4.2.1" less-loader: "npm:12.2.0" - lexical: "npm:0.21.0" + lexical: "npm:0.20.2" libsodium-wrappers: "npm:0.7.15" linkify-it: "npm:5.0.0" lint-staged: "npm:15.2.11"