diff --git a/e2e-tests/cypress/tests/integration/channels/keyboard_shortcuts/ctrl_cmd_shift_slash/react_to_center_spec.js b/e2e-tests/cypress/tests/integration/channels/keyboard_shortcuts/ctrl_cmd_shift_slash/react_to_center_spec.js index 4c16ed5ef4..ac5e5d92df 100644 --- a/e2e-tests/cypress/tests/integration/channels/keyboard_shortcuts/ctrl_cmd_shift_slash/react_to_center_spec.js +++ b/e2e-tests/cypress/tests/integration/channels/keyboard_shortcuts/ctrl_cmd_shift_slash/react_to_center_spec.js @@ -193,7 +193,7 @@ describe('Keyboard shortcut CTRL/CMD+Shift+\\ for adding reaction to last messag cy.get('#emojiPicker').should('exist'); // # Click anywhere to close emoji picker - cy.get('#channelHeaderInfo').click(); + cy.get('body').click(); cy.get('#emojiPicker').should('not.exist'); }); diff --git a/e2e-tests/cypress/tests/integration/channels/messaging/edit_message_spec.js b/e2e-tests/cypress/tests/integration/channels/messaging/edit_message_spec.js index f1c8f1087a..af345e95a0 100644 --- a/e2e-tests/cypress/tests/integration/channels/messaging/edit_message_spec.js +++ b/e2e-tests/cypress/tests/integration/channels/messaging/edit_message_spec.js @@ -64,6 +64,8 @@ describe('Edit Message', () => { // * Assert channel autocomplete is not visible cy.get('#suggestionList').should('not.exist'); + cy.wait(TIMEOUTS.HALF_SEC); + // # In the modal click the emoji picker icon cy.get('div.post-edit__container button#emojiPickerButton').click(); diff --git a/webapp/channels/src/components/advanced_text_editor/advanced_text_editor.tsx b/webapp/channels/src/components/advanced_text_editor/advanced_text_editor.tsx index c3a6dc9706..72a0a547f5 100644 --- a/webapp/channels/src/components/advanced_text_editor/advanced_text_editor.tsx +++ b/webapp/channels/src/components/advanced_text_editor/advanced_text_editor.tsx @@ -77,7 +77,7 @@ import SendButton from './send_button'; import ShowFormat from './show_formatting'; import TexteditorActions from './texteditor_actions'; import ToggleFormattingBar from './toggle_formatting_bar'; -import useEmojiPicker from './use_emoji_picker'; +import useEditorEmojiPicker from './use_editor_emoji_picker'; import useKeyHandler from './use_key_handler'; import useOrientationHandler from './use_orientation_handler'; import usePluginItems from './use_plugin_items'; @@ -132,6 +132,24 @@ const AdvancedTextEditor = ({ const getDraftSelector = useMemo(makeGetDraft, []); const getDisplayName = useMemo(makeGetDisplayName, []); + let textboxId = 'textbox'; + + switch (location) { + case Locations.CENTER: + textboxId = 'post_textbox'; + break; + case Locations.RHS_COMMENT: + textboxId = 'reply_textbox'; + break; + case Locations.MODAL: + textboxId = 'modal_textbox'; + break; + } + + if (isInEditMode) { + textboxId = 'edit_textbox'; + } + const isRHS = Boolean(postId && !isThreadView); const getFormattingBarPreferenceName = () => { @@ -310,12 +328,20 @@ const AdvancedTextEditor = ({ isInEditMode, ); - const emojiPickerOffset = isInEditMode ? {right: 40} : undefined; const { emojiPicker, enableEmojiPicker, toggleEmojiPicker, - } = useEmojiPicker(isDisabled, draft, caretPosition, setCaretPosition, handleDraftChange, showPreview, focusTextbox, emojiPickerOffset); + } = useEditorEmojiPicker( + textboxId, + isDisabled, + draft, + caretPosition, + setCaretPosition, + handleDraftChange, + showPreview, + focusTextbox, + ); const { labels: priorityLabels, additionalControl: priorityAdditionalControl, @@ -653,24 +679,6 @@ const AdvancedTextEditor = ({ const messageValue = isDisabled ? '' : draft.message_source || draft.message; - let textboxId = 'textbox'; - - switch (location) { - case Locations.CENTER: - textboxId = 'post_textbox'; - break; - case Locations.RHS_COMMENT: - textboxId = 'reply_textbox'; - break; - case Locations.MODAL: - textboxId = 'modal_textbox'; - break; - } - - if (isInEditMode) { - textboxId = 'edit_textbox'; - } - const wasNotifiedOfLogIn = LocalStorageStore.getWasNotifiedOfLogIn(); let loginSuccessfulLabel; diff --git a/webapp/channels/src/components/advanced_text_editor/use_emoji_picker.tsx b/webapp/channels/src/components/advanced_text_editor/use_editor_emoji_picker.tsx similarity index 77% rename from webapp/channels/src/components/advanced_text_editor/use_emoji_picker.tsx rename to webapp/channels/src/components/advanced_text_editor/use_editor_emoji_picker.tsx index 0f617deaab..484beb7bfa 100644 --- a/webapp/channels/src/components/advanced_text_editor/use_emoji_picker.tsx +++ b/webapp/channels/src/components/advanced_text_editor/use_editor_emoji_picker.tsx @@ -1,8 +1,9 @@ // Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. // See LICENSE.txt for license information. +import {flip, offset, shift} from '@floating-ui/react'; import classNames from 'classnames'; -import React, {useCallback, useRef, useState} from 'react'; +import React, {useCallback, useState} from 'react'; import {useIntl} from 'react-intl'; import {useSelector} from 'react-redux'; @@ -13,10 +14,11 @@ import {getConfig} from 'mattermost-redux/selectors/entities/general'; import {getEmojiName} from 'mattermost-redux/utils/emoji_utils'; import useDidUpdate from 'components/common/hooks/useDidUpdate'; -import EmojiPickerOverlay from 'components/emoji_picker/emoji_picker_overlay'; +import useEmojiPicker, {useEmojiPickerOffset} from 'components/emoji_picker/use_emoji_picker'; import KeyboardShortcutSequence, {KEYBOARD_SHORTCUTS} from 'components/keyboard_shortcuts/keyboard_shortcuts_sequence'; import WithTooltip from 'components/with_tooltip'; +import {horizontallyWithin} from 'utils/floating'; import {splitMessageBasedOnCaretPosition} from 'utils/post_utils'; import type {GlobalState} from 'types/store'; @@ -24,7 +26,8 @@ import type {PostDraft} from 'types/store/draft'; import {IconContainer} from './formatting_bar/formatting_icon'; -const useEmojiPicker = ( +const useEditorEmojiPicker = ( + textboxId: string, isDisabled: boolean, draft: PostDraft, caretPosition: number, @@ -32,15 +35,12 @@ const useEmojiPicker = ( handleDraftChange: (draft: PostDraft) => void, shouldShowPreview: boolean, focusTextbox: () => void, - emojiPickerOffset?: {right?: number}, ) => { const intl = useIntl(); const enableEmojiPicker = useSelector((state: GlobalState) => getConfig(state).EnableEmojiPicker === 'true'); const enableGifPicker = useSelector((state: GlobalState) => getConfig(state).EnableGifPicker === 'true'); - const emojiPickerRef = useRef(null); - const [showEmojiPicker, setShowEmojiPicker] = useState(false); const toggleEmojiPicker = useCallback((e?: React.MouseEvent): void => { @@ -48,14 +48,6 @@ const useEmojiPicker = ( setShowEmojiPicker((prev) => !prev); }, []); - const hideEmojiPicker = useCallback(() => { - setShowEmojiPicker(false); - }, []); - - const getEmojiPickerRef = useCallback(() => { - return emojiPickerRef.current; - }, []); - const handleEmojiClick = useCallback((emoji: Emoji) => { const emojiAlias = getEmojiName(emoji); @@ -111,25 +103,41 @@ const useEmojiPicker = ( // Focus textbox when the emoji picker closes useDidUpdate(() => { if (!showEmojiPicker) { - focusTextbox(); + // Wait a frame to let the emoji picker's focus trap disappear before changing focus + requestAnimationFrame(() => { + focusTextbox(); + }); } }, [showEmojiPicker]); - let emojiPicker = null; + const { + emojiPicker, + getReferenceProps, + setReference, + } = useEmojiPicker({ + showEmojiPicker, + setShowEmojiPicker, + enableGifPicker, + onGifClick: handleGifClick, + onEmojiClick: handleEmojiClick, + + overrideMiddleware: [ + offset(useEmojiPickerOffset), + shift(), + horizontallyWithin({ + boundary: document.getElementById(textboxId), + }), + flip({ + fallbackAxisSideDirection: 'end', + }), + ], + }); + + let emojiPickerControls = null; if (enableEmojiPicker && !isDisabled) { - emojiPicker = ( + emojiPickerControls = ( <> - + {emojiPicker} ); } - return {emojiPicker, enableEmojiPicker, toggleEmojiPicker}; + return {emojiPicker: emojiPickerControls, enableEmojiPicker, toggleEmojiPicker}; }; -export default useEmojiPicker; +export default useEditorEmojiPicker; diff --git a/webapp/channels/src/components/channel_bookmarks/create_modal_name_input.tsx b/webapp/channels/src/components/channel_bookmarks/create_modal_name_input.tsx index 8a0913121a..fdfee38862 100644 --- a/webapp/channels/src/components/channel_bookmarks/create_modal_name_input.tsx +++ b/webapp/channels/src/components/channel_bookmarks/create_modal_name_input.tsx @@ -11,7 +11,7 @@ import type {ChannelBookmark} from '@mattermost/types/channel_bookmarks'; import type {Emoji} from '@mattermost/types/emojis'; import type {FileInfo} from '@mattermost/types/files'; -import EmojiPickerOverlay from 'components/emoji_picker/emoji_picker_overlay'; +import useEmojiPicker from 'components/emoji_picker/use_emoji_picker'; import Input from 'components/widgets/inputs/input/input'; import Constants, {A11yCustomEventTypes, type A11yFocusEventDetail} from 'utils/constants'; @@ -50,7 +50,6 @@ const CreateModalNameInput = ({ const {formatMessage} = useIntl(); const targetRef = useRef(null); - const getTargetRef = () => targetRef.current; const icon = ( { - setShowEmojiPicker(false); - refocusEmojiButton(); - }; - const handleInputChange: ComponentProps['onChange'] = useCallback((e) => { setDisplayName(e.currentTarget.value); }, []); @@ -112,33 +106,34 @@ const CreateModalNameInput = ({ } }; + const { + emojiPicker, + getReferenceProps, + setReference, + } = useEmojiPicker({ + showEmojiPicker, + setShowEmojiPicker, + + onAddCustomEmojiClick, + onEmojiClick: handleEmojiClick, + }); + return ( <> - {showEmojiPicker && ( - - - )} + {emojiPicker} = (props: Props) => { const currentCustomStatus = useSelector(getCustomStatus); const customStatusExpired = useSelector((state: GlobalState) => isCustomStatusExpired(state, currentCustomStatus)); const recentCustomStatuses = useSelector(getRecentCustomStatuses); - const customStatusControlRef = useRef(null); - const emojiButtonRef = useRef(null); const {formatMessage} = useIntl(); const isCurrentCustomStatusSet = !customStatusExpired && (currentCustomStatus?.text || currentCustomStatus?.emoji); const [showEmojiPicker, setShowEmojiPicker] = useState(false); @@ -230,54 +226,10 @@ const CustomStatusModal: React.FC = (props: Props) => { const handleClearStatus = isCurrentCustomStatusSet ? () => dispatch(unsetCustomStatus()) : undefined; - const getCustomStatusControlRef = () => customStatusControlRef.current; - - const handleEmojiClose = () => { - setShowEmojiPicker(false); - if (emojiButtonRef.current) { - document.dispatchEvent(new CustomEvent( - A11yCustomEventTypes.FOCUS, { - detail: { - target: emojiButtonRef.current as HTMLElement, - keyboardOnly: true, - }, - }, - )); - } - }; - - const handleEmojiExited = () => { - if (emojiButtonRef.current) { - document.dispatchEvent(new CustomEvent( - A11yCustomEventTypes.FOCUS, { - detail: { - target: emojiButtonRef.current as HTMLElement, - keyboardOnly: true, - }, - }, - )); - } - }; - const handleEmojiClick = (selectedEmoji: Emoji) => { setShowEmojiPicker(false); const emojiName = ('short_name' in selectedEmoji) ? selectedEmoji.short_name : selectedEmoji.name; setEmoji(emojiName); - if (emojiButtonRef.current) { - document.dispatchEvent(new CustomEvent( - A11yCustomEventTypes.FOCUS, { - detail: { - target: emojiButtonRef.current as HTMLElement, - keyboardOnly: true, - }, - }, - )); - } - }; - - const toggleEmojiPicker = (e?: React.MouseEvent): void => { - e?.stopPropagation(); - setShowEmojiPicker((prevShow) => !prevShow); }; const handleTextChange = (event: React.ChangeEvent) => setText(event.target.value); @@ -291,6 +243,17 @@ const CustomStatusModal: React.FC = (props: Props) => { /> ) : ; + const { + emojiPicker, + getReferenceProps, + setReference, + } = useEmojiPicker({ + showEmojiPicker, + setShowEmojiPicker, + + onEmojiClick: handleEmojiClick, + }); + const clearHandle = () => { setEmoji(''); setText(''); @@ -303,19 +266,6 @@ const CustomStatusModal: React.FC = (props: Props) => { setDuration(status.duration || DONT_CLEAR); }; - const calculateRightOffSet = () => { - let rightOffset = Constants.DEFAULT_EMOJI_PICKER_RIGHT_OFFSET; - const target = getCustomStatusControlRef(); - if (target) { - rightOffset = window.innerWidth - target.getBoundingClientRect().left - EMOJI_PICKER_WIDTH_OFFSET; - if (rightOffset < 0) { - rightOffset = Constants.DEFAULT_EMOJI_PICKER_RIGHT_OFFSET; - } - } - - return rightOffset; - }; - const recentStatuses = (
@@ -425,34 +375,19 @@ const CustomStatusModal: React.FC = (props: Props) => { >
-
- {showEmojiPicker && ( - - )} +
+ {emojiPicker}
; handleDropdownOpened: (open: boolean) => void; - handleAddReactionClick?: () => void; + handleAddReactionClick?: (showEmojiPicker: boolean) => void; isMenuOpen?: boolean; isReadOnly?: boolean; isLicensed?: boolean; // TechDebt: Made non-mandatory while converting to typescript @@ -209,9 +209,8 @@ export class DotMenuClass extends React.PureComponent { }; handleAddReactionMenuItemActivated = () => { - // to be safe, make sure the handler function has been defined if (this.props.handleAddReactionClick) { - this.props.handleAddReactionClick(); + this.props.handleAddReactionClick(true); } }; diff --git a/webapp/channels/src/components/dot_menu/index.ts b/webapp/channels/src/components/dot_menu/index.ts index 0fb723253a..0497bce42c 100644 --- a/webapp/channels/src/components/dot_menu/index.ts +++ b/webapp/channels/src/components/dot_menu/index.ts @@ -44,14 +44,6 @@ import DotMenu from './dot_menu'; type Props = { post: Post; - isFlagged?: boolean; - handleCommentClick?: React.EventHandler; - handleCardClick?: (post: Post) => void; - handleDropdownOpened: (open: boolean) => void; - handleAddReactionClick?: () => void; - isMenuOpen: boolean; - isReadOnly?: boolean; - enableEmojiPicker?: boolean; location?: ComponentProps['location']; }; diff --git a/webapp/channels/src/components/edit_scheduled_post/edit_post.tsx b/webapp/channels/src/components/edit_scheduled_post/edit_post.tsx index 4cb722dc65..74625742ab 100644 --- a/webapp/channels/src/components/edit_scheduled_post/edit_post.tsx +++ b/webapp/channels/src/components/edit_scheduled_post/edit_post.tsx @@ -20,9 +20,8 @@ import {openModal} from 'actions/views/modals'; import {getConnectionId} from 'selectors/general'; import DeletePostModal from 'components/delete_post_modal'; -import DeleteScheduledPostModal - from 'components/drafts/draft_actions/schedule_post_actions/delete_scheduled_post_modal'; -import EmojiPickerOverlay from 'components/emoji_picker/emoji_picker_overlay'; +import DeleteScheduledPostModal from 'components/drafts/draft_actions/schedule_post_actions/delete_scheduled_post_modal'; +import useEmojiPicker from 'components/emoji_picker/use_emoji_picker'; import Textbox from 'components/textbox'; import type {TextboxClass, TextboxElement} from 'components/textbox'; @@ -102,9 +101,6 @@ export type State = { const {KeyCodes} = Constants; -const TOP_OFFSET = 0; -const RIGHT_OFFSET = 10; - const EditPost = ({editingPost, actions, canEditPost, config, channelId, draft, scheduledPost, afterSave, onCancel, onDeleteScheduledPost, ...rest}: Props): JSX.Element | null => { const connectionId = useSelector(getConnectionId); const channel = useSelector((state: GlobalState) => getChannel(state, channelId)); @@ -123,7 +119,6 @@ const EditPost = ({editingPost, actions, canEditPost, config, channelId, draft, const [showMentionHelper, setShowMentionHelper] = useState(false); const textboxRef = useRef(null); - const emojiButtonRef = useRef(null); const wrapperRef = useRef(null); // using a ref here makes sure that the unmounting callback (saveDraft) is fired with the correct value. @@ -503,11 +498,6 @@ const EditPost = ({editingPost, actions, canEditPost, config, channelId, draft, } }; - const hideEmojiPicker = () => { - setShowEmojiPicker(false); - textboxRef.current?.focus(); - }; - const handleEmojiClick = (emoji?: Emoji) => { if (!emoji) { return; @@ -570,35 +560,37 @@ const EditPost = ({editingPost, actions, canEditPost, config, channelId, draft, } }; - const getEmojiTargetRef = useCallback(() => emojiButtonRef.current, [emojiButtonRef]); + const { + emojiPicker, + getReferenceProps, + setReference, + } = useEmojiPicker({ + showEmojiPicker, + setShowEmojiPicker, - let emojiPicker = null; + enableGifPicker: config.EnableGifPicker === 'true', + onGifClick: handleGifClick, + onEmojiClick: handleEmojiClick, + }); + let emojiPickerControls = null; if (config.EnableEmojiPicker === 'true') { - emojiPicker = ( + emojiPickerControls = ( <> - + {emojiPicker} ); } @@ -637,7 +629,7 @@ const EditPost = ({editingPost, actions, canEditPost, config, channelId, draft, useChannelMentions={rest.useChannelMentions} />
- {emojiPicker} + {emojiPickerControls}
{ showMentionHelper ? (
diff --git a/webapp/channels/src/components/emoji_picker/__snapshots__/emoji_picker.test.tsx.snap b/webapp/channels/src/components/emoji_picker/__snapshots__/emoji_picker.test.tsx.snap index 2d6c5fd635..4359f2d642 100644 --- a/webapp/channels/src/components/emoji_picker/__snapshots__/emoji_picker.test.tsx.snap +++ b/webapp/channels/src/components/emoji_picker/__snapshots__/emoji_picker.test.tsx.snap @@ -27,7 +27,7 @@ exports[`components/emoji_picker/EmojiPicker should match snapshot 1`] = ` class="emoji-picker__search" data-testid="emojiInputSearch" id="emojiPickerSearch" - placeholder="Search Emoji" + placeholder="Search emojis" type="text" value="" /> diff --git a/webapp/channels/src/components/emoji_picker/components/emoji_picker_search.tsx b/webapp/channels/src/components/emoji_picker/components/emoji_picker_search.tsx index 7842a8eef4..6a1c86724d 100644 --- a/webapp/channels/src/components/emoji_picker/components/emoji_picker_search.tsx +++ b/webapp/channels/src/components/emoji_picker/components/emoji_picker_search.tsx @@ -138,7 +138,7 @@ const EmojiPickerSearch = forwardRef(({value, cursorCat onChange={handleChange} onKeyDown={handleKeyDown} autoComplete='off' - placeholder={formatMessage({id: 'emoji_picker.search', defaultMessage: 'Search Emoji'})} + placeholder={formatMessage({id: 'emoji_picker.search', defaultMessage: 'Search emojis'})} value={value} />
diff --git a/webapp/channels/src/components/emoji_picker/emoji_picker_overlay/emoji_picker_overlay.tsx b/webapp/channels/src/components/emoji_picker/emoji_picker_overlay/emoji_picker_overlay.tsx deleted file mode 100644 index d3a9058f57..0000000000 --- a/webapp/channels/src/components/emoji_picker/emoji_picker_overlay/emoji_picker_overlay.tsx +++ /dev/null @@ -1,113 +0,0 @@ -// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. -// See LICENSE.txt for license information. - -import memoize from 'memoize-one'; -import React from 'react'; -import type {ComponentProps, ReactNode} from 'react'; -import {Overlay} from 'react-bootstrap'; - -import type {Emoji} from '@mattermost/types/emojis'; - -import {Constants} from 'utils/constants'; -import {popOverOverlayPosition} from 'utils/position_utils'; - -import EmojiPickerTabs from '../emoji_picker_tabs'; - -import type {PropsFromRedux} from './index'; - -export interface Props extends PropsFromRedux { - target: () => ReactNode; - onEmojiClick: (emoji: Emoji) => void; - onGifClick?: (gif: string) => void; - onAddCustomEmojiClick?: () => void; - onHide: () => void; - onExited?: () => void; - show: boolean; - placement?: ComponentProps['placement']; - topOffset?: number; - rightOffset?: number; - leftOffset?: number; - spaceRequiredAbove?: number; - spaceRequiredBelow?: number; - enableGifPicker?: boolean; - defaultHorizontalPosition?: 'left' | 'right'; -} - -export default class EmojiPickerOverlay extends React.PureComponent { - // An emoji picker in the center channel is contained within the post list, so it needs space - // above for the channel header and below for the post textbox - static CENTER_SPACE_REQUIRED_ABOVE = 476; - static CENTER_SPACE_REQUIRED_BELOW = 497; - - // An emoji picker in the RHS isn't constrained by the RHS, so it just needs space to fit - // the emoji picker itself - static RHS_SPACE_REQUIRED_ABOVE = 420; - static RHS_SPACE_REQUIRED_BELOW = 420; - - // Reasonable defaults calculated from the center channel - static defaultProps = { - spaceRequiredAbove: EmojiPickerOverlay.CENTER_SPACE_REQUIRED_ABOVE, - spaceRequiredBelow: EmojiPickerOverlay.CENTER_SPACE_REQUIRED_BELOW, - enableGifPicker: false, - }; - - emojiPickerPosition = memoize((emojiTrigger, show) => { - let calculatedRightOffset = Constants.DEFAULT_EMOJI_PICKER_RIGHT_OFFSET; - - if (!show) { - return calculatedRightOffset; - } - - if (emojiTrigger) { - calculatedRightOffset = window.innerWidth - emojiTrigger.getBoundingClientRect().left - Constants.DEFAULT_EMOJI_PICKER_LEFT_OFFSET; - - if (calculatedRightOffset < Constants.DEFAULT_EMOJI_PICKER_RIGHT_OFFSET) { - calculatedRightOffset = Constants.DEFAULT_EMOJI_PICKER_RIGHT_OFFSET; - } - } - - return calculatedRightOffset; - }); - - getPlacement = memoize((target, spaceRequiredAbove, spaceRequiredBelow, defaultHorizontalPosition, show) => { - if (!show) { - return 'top' as const; - } - - if (target) { - const targetBounds = target.getBoundingClientRect(); - return popOverOverlayPosition(targetBounds, window.innerHeight, spaceRequiredAbove, spaceRequiredBelow, defaultHorizontalPosition); - } - - return 'top' as const; - }); - - render() { - const {target, rightOffset, spaceRequiredAbove, spaceRequiredBelow, defaultHorizontalPosition, show, isMobileView} = this.props; - const calculatedRightOffset = typeof rightOffset === 'undefined' ? this.emojiPickerPosition(target(), show) : rightOffset; - const placement = this.getPlacement(target(), spaceRequiredAbove, spaceRequiredBelow, defaultHorizontalPosition, show); - - return ( - - - - ); - } -} diff --git a/webapp/channels/src/components/emoji_picker/emoji_picker_overlay/index.ts b/webapp/channels/src/components/emoji_picker/emoji_picker_overlay/index.ts deleted file mode 100644 index d0ed53ecbb..0000000000 --- a/webapp/channels/src/components/emoji_picker/emoji_picker_overlay/index.ts +++ /dev/null @@ -1,23 +0,0 @@ -// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. -// See LICENSE.txt for license information. - -import {connect} from 'react-redux'; -import type {ConnectedProps} from 'react-redux'; - -import {getIsMobileView} from 'selectors/views/browser'; - -import type {GlobalState} from 'types/store'; - -import EmojiPickerOverlay from './emoji_picker_overlay'; - -function mapStateToProps(state: GlobalState) { - return { - isMobileView: getIsMobileView(state), - }; -} - -const connector = connect(mapStateToProps); - -export type PropsFromRedux = ConnectedProps; - -export default connector(EmojiPickerOverlay); diff --git a/webapp/channels/src/components/emoji_picker/emoji_picker_tabs.tsx b/webapp/channels/src/components/emoji_picker/emoji_picker_tabs.tsx index 1d69377553..5fa05690ef 100644 --- a/webapp/channels/src/components/emoji_picker/emoji_picker_tabs.tsx +++ b/webapp/channels/src/components/emoji_picker/emoji_picker_tabs.tsx @@ -1,12 +1,9 @@ // Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. // See LICENSE.txt for license information. -import classNames from 'classnames'; -import type {CSSProperties, RefObject} from 'react'; -import React, {PureComponent, createRef} from 'react'; +import React, {useCallback, useRef, useState} from 'react'; import {Tab, Tabs} from 'react-bootstrap'; -import type {WrappedComponentProps} from 'react-intl'; -import {injectIntl, FormattedMessage} from 'react-intl'; +import {FormattedMessage, useIntl} from 'react-intl'; import type {Emoji} from '@mattermost/types/emojis'; @@ -18,12 +15,7 @@ import GifIcon from 'components/widgets/icons/giphy_icon'; const GifPicker = makeAsyncComponent('GifPicker', React.lazy(() => import('components/gif_picker/gif_picker'))); -export interface Props extends WrappedComponentProps { - style?: CSSProperties; - rightOffset?: number; - topOffset?: number; - leftOffset?: number; - placement?: ('top' | 'bottom' | 'left' | 'right'); +export interface Props { onEmojiClose: () => void; onEmojiClick: (emoji: Emoji) => void; onGifClick?: (gif: string) => void; @@ -31,173 +23,104 @@ export interface Props extends WrappedComponentProps { enableGifPicker?: boolean; } -type State = { - emojiTabVisible: boolean; - filter: string; - activeKey: number; -} +export default function EmojiPickerTabs(props: Props) { + const intl = useIntl(); -class EmojiPickerTabs extends PureComponent { - private rootPickerNodeRef: RefObject; + const [activeKey, setActiveKey] = useState(1); + const [filter, setFilter] = useState(''); - static defaultProps = { - rightOffset: 0, - topOffset: 0, - leftOffset: 0, - }; - - constructor(props: Props) { - super(props); - - this.state = { - emojiTabVisible: true, - filter: '', - activeKey: 1, - }; - - this.rootPickerNodeRef = createRef(); - } - - handleEmojiPickerClose = () => { - this.props.onEmojiClose(); - }; - - handleFilterChange = (filter: string) => { - this.setState({filter}); - }; - - getRootPickerNode = () => { - return this.rootPickerNodeRef.current; - }; - - render() { - const {intl} = this.props; - let pickerStyle; - - if (this.props.style && !(this.props.style.left === 0 && this.props.style.top === 0)) { - if (this.props.placement === 'top' || this.props.placement === 'bottom') { - // Only take the top/bottom position passed by React Bootstrap since we want to be right-aligned - pickerStyle = { - top: this.props.style.top, - bottom: this.props.style.bottom, - right: this.props?.rightOffset, - }; - } else { - pickerStyle = {...this.props.style}; - } - - if (pickerStyle.top) { - pickerStyle.top = (this.props.topOffset || 0) + (pickerStyle.top as number); - } else { - pickerStyle.top = this.props.topOffset; - } - - if (pickerStyle.left) { - (pickerStyle.left as number) += (this.props.leftOffset || 0); - } - } - - if (this.props.enableGifPicker && typeof this.props.onGifClick != 'undefined') { - return ( - - } - unmountOnExit={true} - tabClassName={'custom-emoji-tab'} - > - - - - - -
- } - unmountOnExit={true} - tabClassName={'custom-emoji-tab'} - > - - - -
- ); - } + const rootPickerNodeRef = useRef(null); + const getRootPickerNode = useCallback(() => rootPickerNodeRef.current, []); + if (props.enableGifPicker && typeof props.onGifClick != 'undefined') { return ( + } + unmountOnExit={true} + tabClassName={'custom-emoji-tab'} + > + + + + + +
+ } + unmountOnExit={true} + tabClassName={'custom-emoji-tab'} + > + + +
); } -} -export default injectIntl(EmojiPickerTabs); + return ( + + ); +} diff --git a/webapp/channels/src/components/emoji_picker/use_emoji_picker.tsx b/webapp/channels/src/components/emoji_picker/use_emoji_picker.tsx new file mode 100644 index 0000000000..db7fd75628 --- /dev/null +++ b/webapp/channels/src/components/emoji_picker/use_emoji_picker.tsx @@ -0,0 +1,145 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import type {UseFloatingOptions, UseFloatingReturn} from '@floating-ui/react'; +import { + flip, + FloatingFocusManager, + FloatingOverlay, + FloatingPortal, + offset, + shift, + useClick, + useDismiss, + useFloating, + useInteractions, + useRole, +} from '@floating-ui/react'; +import React, {useCallback} from 'react'; +import {useSelector} from 'react-redux'; + +import type {Emoji} from '@mattermost/types/emojis'; + +import {getIsMobileView} from 'selectors/views/browser'; + +import {RootHtmlPortalId} from 'utils/constants'; + +import EmojiPickerTabs from './emoji_picker_tabs'; + +export const useEmojiPickerOffset = 4; + +type UseEmojiPickerOptions = { + showEmojiPicker: boolean; + setShowEmojiPicker: (showEmojiPicker: boolean) => void; + + enableGifPicker?: boolean; + onAddCustomEmojiClick?: () => void; + onEmojiClick: (emoji: Emoji) => void; + onGifClick?: (gif: string) => void; + + /** + * Replaces the middleware for positioning the emoji picker in cases where we want it positioned differently. + */ + overrideMiddleware?: UseFloatingOptions['middleware']; +} + +type UseEmojiPickerReturn = { + emojiPicker: React.ReactNode; + getReferenceProps: ReturnType['getReferenceProps']; + setReference: UseFloatingReturn['refs']['setReference']; +} + +export default function useEmojiPicker({ + showEmojiPicker, + setShowEmojiPicker, + + enableGifPicker, + onAddCustomEmojiClick, + onEmojiClick, + onGifClick, + + overrideMiddleware, +}: UseEmojiPickerOptions): UseEmojiPickerReturn { + const isMobileView = useSelector(getIsMobileView); + + const hideEmojiPicker = useCallback(() => setShowEmojiPicker(false), [setShowEmojiPicker]); + + let middleware: UseFloatingOptions['middleware']; + if (isMobileView) { + // Disable middleware in mobile view because we use CSS to make the emoji picker fullscreen + middleware = []; + } else if (overrideMiddleware) { + middleware = overrideMiddleware; + } else { + middleware = [ + offset(useEmojiPickerOffset), + shift(), + flip({ + fallbackAxisSideDirection: 'end', + }), + ]; + } + + // Set up Floating UI + const {context: floatingContext, floatingStyles, refs} = useFloating({ + open: showEmojiPicker, + onOpenChange: setShowEmojiPicker, + + middleware, + placement: 'top', + }); + + const clickInteractions = useClick(floatingContext); + const dismissInteraction = useDismiss(floatingContext); + const role = useRole(floatingContext); + + const {getReferenceProps, getFloatingProps} = useInteractions([ + clickInteractions, + dismissInteraction, + role, + ]); + + let emojiPicker = ( + + ); + + if (isMobileView) { + // On mobile, we use Floating UI to manage the portal and opening/closing the picker, but we don't use its + // position because the picker is fullscreen + emojiPicker = ( +
+ {emojiPicker} +
+ ); + } else { + emojiPicker = ( +
+ {emojiPicker} +
+ ); + } + + return { + emojiPicker: ( + showEmojiPicker && + + + {emojiPicker} + + + + ), + getReferenceProps, + setReference: refs.setReference, + }; +} diff --git a/webapp/channels/src/components/post/post_options.tsx b/webapp/channels/src/components/post/post_options.tsx index d761e635a5..194bc1fccb 100644 --- a/webapp/channels/src/components/post/post_options.tsx +++ b/webapp/channels/src/components/post/post_options.tsx @@ -59,27 +59,35 @@ type Props = { }; const PostOptions = (props: Props): JSX.Element => { - const dotMenuRef = useRef(null); - const [showEmojiPicker, setShowEmojiPicker] = useState(false); const [showDotMenu, setShowDotMenu] = useState(false); const [showActionsMenu, setShowActionsMenu] = useState(false); - const toggleEmojiPicker = useCallback(() => { - setShowEmojiPicker(!showEmojiPicker); - props.handleDropdownOpened!(!showEmojiPicker); - }, [props.handleDropdownOpened, showEmojiPicker]); + const toggleEmojiPicker = useCallback((show: boolean) => { + setShowEmojiPicker(show); + props.handleDropdownOpened!(show); + }, [props.handleDropdownOpened]); + const lastEmittedFrom = useRef(props.shortcutReactToLastPostEmittedFrom); useEffect(() => { + // Confirm that lastEmittedFrom actually changed to avoid toggling the emoji picker when another dependency + // changes without the user pressing the hotkey again + if (lastEmittedFrom.current === props.shortcutReactToLastPostEmittedFrom) { + return; + } + + lastEmittedFrom.current = props.shortcutReactToLastPostEmittedFrom; + const locationToUse = props.location === 'RHS_COMMENT' ? Locations.RHS_ROOT : props.location; + if (props.isLastPost && (props.shortcutReactToLastPostEmittedFrom === locationToUse) && props.isPostHeaderVisible) { - toggleEmojiPicker(); props.actions.emitShortcutReactToLastPostFrom(Locations.NO_WHERE); + toggleEmojiPicker(!showEmojiPicker); } // eslint-disable-next-line react-hooks/exhaustive-deps - }, [props.isLastPost, props.shortcutReactToLastPostEmittedFrom, props.location, props.isPostHeaderVisible]); + }, [props.isLastPost, props.shortcutReactToLastPostEmittedFrom, props.location, props.isPostHeaderVisible, showEmojiPicker]); const { channelIsArchived, @@ -108,8 +116,6 @@ const PostOptions = (props: Props): JSX.Element => { props.handleDropdownOpened!(open); }; - const getDotMenuRef = () => dotMenuRef.current; - const isPostDeleted = post && post.state === Posts.POST_DELETED; const hoverLocal = props.hover || showEmojiPicker || showDotMenu || showActionsMenu; const showCommentIcon = isFromAutoResponder || (!systemMessage && (isMobileView || @@ -160,9 +166,8 @@ const PostOptions = (props: Props): JSX.Element => { location={props.location} postId={post.id} teamId={props.teamId} - getDotMenuRef={getDotMenuRef} showEmojiPicker={showEmojiPicker} - toggleEmojiPicker={toggleEmojiPicker} + setShowEmojiPicker={toggleEmojiPicker} /> ); @@ -278,7 +283,6 @@ const PostOptions = (props: Props): JSX.Element => { } else if (!props.isPostBeingEdited) { options = (
    diff --git a/webapp/channels/src/components/post_view/post_reaction/__snapshots__/post_reaction.test.tsx.snap b/webapp/channels/src/components/post_view/post_reaction/__snapshots__/post_reaction.test.tsx.snap deleted file mode 100644 index ff24080843..0000000000 --- a/webapp/channels/src/components/post_view/post_reaction/__snapshots__/post_reaction.test.tsx.snap +++ /dev/null @@ -1,41 +0,0 @@ -// Jest Snapshot v1, https://goo.gl/fbAQLP - -exports[`components/post_view/PostReaction should match snapshot 1`] = ` - - - - - - -`; diff --git a/webapp/channels/src/components/post_view/post_reaction/post_reaction.test.tsx b/webapp/channels/src/components/post_view/post_reaction/post_reaction.test.tsx index 742aa7034c..7331bd6c89 100644 --- a/webapp/channels/src/components/post_view/post_reaction/post_reaction.test.tsx +++ b/webapp/channels/src/components/post_view/post_reaction/post_reaction.test.tsx @@ -3,10 +3,12 @@ import React from 'react'; -import {shallowWithIntl} from 'tests/helpers/intl-test-helper'; +import {Permissions} from 'mattermost-redux/constants'; + +import {renderWithContext, screen, userEvent} from 'tests/react_testing_utils'; import {TestHelper} from 'utils/test_helper'; -import PostReaction, {type PostReaction as PostReactionComponent} from './post_reaction'; +import PostReaction from './post_reaction'; describe('components/post_view/PostReaction', () => { const baseProps = { @@ -16,24 +18,62 @@ describe('components/post_view/PostReaction', () => { getDotMenuRef: jest.fn(), showIcon: false, showEmojiPicker: false, - toggleEmojiPicker: jest.fn(), + setShowEmojiPicker: jest.fn(), actions: { toggleReaction: jest.fn(), }, }; - test('should match snapshot', () => { - const wrapper = shallowWithIntl(); - expect(wrapper).toMatchSnapshot(); + const userId = 'userId'; + const initialState = { + entities: { + roles: { + roles: { + system_user: TestHelper.getRoleMock({permissions: [Permissions.ADD_REACTION]}), + }, + }, + users: { + currentUserId: userId, + profiles: { + userId: TestHelper.getUserMock({id: userId, roles: 'system_user'}), + }, + }, + }, + }; + + test('should not render the emoji picker initially', async () => { + const {rerender} = renderWithContext( + , + initialState, + ); + + expect(screen.queryByPlaceholderText('Search emojis')).not.toBeInTheDocument(); + + await Promise.resolve(); + + rerender( + , + ); + + expect(screen.queryByPlaceholderText('Search emojis')).toBeInTheDocument(); }); - test('should call toggleReaction and toggleEmojiPicker on handleToggleEmoji', () => { - const wrapper = shallowWithIntl(); - const instance = wrapper.instance() as PostReactionComponent; + test('should toggle the reaction and close the emoji picker when an emoji is selected', async () => { + renderWithContext( + , + initialState, + ); + + userEvent.type(screen.getByPlaceholderText('Search emojis'), '{enter}'); - instance.handleToggleEmoji(TestHelper.getCustomEmojiMock({name: 'smile'})); expect(baseProps.actions.toggleReaction).toHaveBeenCalledTimes(1); - expect(baseProps.actions.toggleReaction).toHaveBeenCalledWith('post_id_1', 'smile'); - expect(baseProps.toggleEmojiPicker).toHaveBeenCalledTimes(1); + expect(baseProps.actions.toggleReaction).toHaveBeenCalledWith('post_id_1', 'grinning'); + expect(baseProps.setShowEmojiPicker).toHaveBeenCalledWith(false); }); }); diff --git a/webapp/channels/src/components/post_view/post_reaction/post_reaction.tsx b/webapp/channels/src/components/post_view/post_reaction/post_reaction.tsx index 354d7492cc..7adb372ce4 100644 --- a/webapp/channels/src/components/post_view/post_reaction/post_reaction.tsx +++ b/webapp/channels/src/components/post_view/post_reaction/post_reaction.tsx @@ -2,114 +2,87 @@ // See LICENSE.txt for license information. import classNames from 'classnames'; -import React from 'react'; -import type {WrappedComponentProps} from 'react-intl'; -import {defineMessages, injectIntl} from 'react-intl'; +import React, {useCallback} from 'react'; +import {useIntl} from 'react-intl'; import type {Emoji} from '@mattermost/types/emojis'; import Permissions from 'mattermost-redux/constants/permissions'; import {getEmojiName} from 'mattermost-redux/utils/emoji_utils'; -import EmojiPickerOverlay from 'components/emoji_picker/emoji_picker_overlay'; +import useEmojiPicker from 'components/emoji_picker/use_emoji_picker'; import ChannelPermissionGate from 'components/permissions_gates/channel_permission_gate'; import EmojiIcon from 'components/widgets/icons/emoji_icon'; import WithTooltip from 'components/with_tooltip'; import {Locations} from 'utils/constants'; -const TOP_OFFSET = -7; - -const messages = defineMessages({ - addReaction: { - id: 'post_info.tooltip.add_reactions', - defaultMessage: 'Add Reaction', - }, -}); - -export type Props = WrappedComponentProps & { +export type Props = { channelId?: string; postId: string; teamId: string; - getDotMenuRef: () => HTMLUListElement | null; location?: keyof typeof Locations; + setShowEmojiPicker: (showEmojiPicker: boolean) => void; showEmojiPicker: boolean; - toggleEmojiPicker: (e?: React.MouseEvent) => void; actions: { toggleReaction: (postId: string, emojiName: string) => void; }; } -type State = { - location: keyof typeof Locations; - showEmojiPicker: boolean; -} +export default function PostReaction({ + channelId, + location = Locations.CENTER, + postId, + teamId, + showEmojiPicker, + setShowEmojiPicker, + actions: { + toggleReaction, + }, +}: Props) { + const intl = useIntl(); -export class PostReaction extends React.PureComponent { - public static defaultProps: Partial = { - location: Locations.CENTER as 'CENTER', - showEmojiPicker: false, - }; - - handleToggleEmoji = (emoji: Emoji): void => { - this.setState({showEmojiPicker: false}); + const handleEmojiClick = useCallback((emoji: Emoji) => { const emojiName = getEmojiName(emoji); - this.props.actions.toggleReaction(this.props.postId, emojiName); - this.props.toggleEmojiPicker(); - }; + toggleReaction(postId, emojiName); - render() { - const { - channelId, - location, - postId, - showEmojiPicker, - teamId, - intl, - } = this.props; + setShowEmojiPicker(false); + }, [postId, setShowEmojiPicker, toggleReaction]); - let spaceRequiredAbove; - let spaceRequiredBelow; - if (location === Locations.RHS_ROOT || location === Locations.RHS_COMMENT) { - spaceRequiredAbove = EmojiPickerOverlay.RHS_SPACE_REQUIRED_ABOVE; - spaceRequiredBelow = EmojiPickerOverlay.RHS_SPACE_REQUIRED_BELOW; - } + const { + emojiPicker, + getReferenceProps, + setReference, + } = useEmojiPicker({ + showEmojiPicker, + setShowEmojiPicker, - return ( - - <> - - - - - - - ); - } + onEmojiClick: handleEmojiClick, + }); + + const ariaLabel = intl.formatMessage({id: 'post_info.tooltip.add_reactions', defaultMessage: 'Add Reaction'}); + + return ( + + + + + {emojiPicker} + + ); } - -export default injectIntl(PostReaction); diff --git a/webapp/channels/src/components/post_view/reaction/reaction.scss b/webapp/channels/src/components/post_view/reaction/reaction.scss index 052718b81f..ab15cbb471 100644 --- a/webapp/channels/src/components/post_view/reaction/reaction.scss +++ b/webapp/channels/src/components/post_view/reaction/reaction.scss @@ -56,11 +56,19 @@ cursor: default; } - &__add { + &.Reaction__add { position: relative; font-size: 20px; line-height: 0; vertical-align: middle; + + &.Reaction__add--open { + background-color: rgba(var(--button-bg-rgb), 0.08); + color: functions.v(button-bg); + fill: functions.v(button-bg); + opacity: 1; + visibility: visible; + } } &__emoji { diff --git a/webapp/channels/src/components/post_view/reaction_list/__snapshots__/reactions_list.test.tsx.snap b/webapp/channels/src/components/post_view/reaction_list/__snapshots__/reactions_list.test.tsx.snap index ebf9f8e543..c8a6012954 100644 --- a/webapp/channels/src/components/post_view/reaction_list/__snapshots__/reactions_list.test.tsx.snap +++ b/webapp/channels/src/components/post_view/reaction_list/__snapshots__/reactions_list.test.tsx.snap @@ -48,52 +48,36 @@ exports[`components/ReactionList should render when there are reactions 1`] = ` ] } /> -
    - - - - - - - - -
    +
`; diff --git a/webapp/channels/src/components/post_view/reaction_list/add_reaction_button.test.tsx b/webapp/channels/src/components/post_view/reaction_list/add_reaction_button.test.tsx new file mode 100644 index 0000000000..4518e8cb41 --- /dev/null +++ b/webapp/channels/src/components/post_view/reaction_list/add_reaction_button.test.tsx @@ -0,0 +1,56 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import React from 'react'; + +import {Permissions} from 'mattermost-redux/constants'; + +import {renderWithContext, screen, userEvent} from 'tests/react_testing_utils'; +import {TestHelper} from 'utils/test_helper'; + +import AddReactionButton from './add_reaction_button'; + +describe('AddReactionButton', () => { + const userId = 'userId'; + + const initialState = { + entities: { + roles: { + roles: { + system_user: TestHelper.getRoleMock({permissions: [Permissions.ADD_REACTION]}), + }, + }, + users: { + currentUserId: userId, + profiles: { + userId: TestHelper.getUserMock({id: userId, roles: 'system_user'}), + }, + }, + }, + }; + + test('should show emoji picker when clicked and then close it when an emoji is selected', async () => { + const props = { + post: TestHelper.getPostMock({user_id: userId, channel_id: 'channelId'}), + teamId: 'teamId', + onEmojiClick: jest.fn(), + }; + + renderWithContext( + , + initialState, + ); + + expect(screen.queryByText('Emoji Picker')).not.toBeInTheDocument(); + + userEvent.click(screen.getByLabelText('Add a reaction')); + + expect(screen.queryByText('Emoji Picker')).toBeVisible(); + + // Search for an emoji instead of clicking on one because the emoji picker doesn't render items when testing + userEvent.type(screen.getByPlaceholderText('Search emojis'), 'banana{enter}'); + + expect(props.onEmojiClick).toHaveBeenCalledWith(expect.objectContaining({short_name: 'banana'})); + expect(screen.queryByText('Emoji Picker')).not.toBeInTheDocument(); + }); +}); diff --git a/webapp/channels/src/components/post_view/reaction_list/add_reaction_button.tsx b/webapp/channels/src/components/post_view/reaction_list/add_reaction_button.tsx new file mode 100644 index 0000000000..3d289a883b --- /dev/null +++ b/webapp/channels/src/components/post_view/reaction_list/add_reaction_button.tsx @@ -0,0 +1,77 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import classNames from 'classnames'; +import React, {useCallback, useState} from 'react'; +import {useIntl} from 'react-intl'; + +import type {Emoji} from '@mattermost/types/emojis'; +import type {Post} from '@mattermost/types/posts'; + +import {Permissions} from 'mattermost-redux/constants'; + +import useEmojiPicker from 'components/emoji_picker/use_emoji_picker'; +import ChannelPermissionGate from 'components/permissions_gates/channel_permission_gate'; +import AddReactionIcon from 'components/widgets/icons/add_reaction_icon'; +import WithTooltip from 'components/with_tooltip'; + +type Props = { + post: Post; + teamId: string; + + onEmojiClick: (emoji: Emoji) => void; +} + +export default function AddReactionButton({ + post, + teamId, + + onEmojiClick, +}: Props) { + const intl = useIntl(); + + const [showEmojiPicker, setShowEmojiPicker] = useState(false); + + const handleEmojiClick = useCallback((emoji: Emoji) => { + onEmojiClick(emoji); + setShowEmojiPicker(false); + }, [onEmojiClick]); + + const { + emojiPicker, + getReferenceProps, + setReference, + } = useEmojiPicker({ + showEmojiPicker, + setShowEmojiPicker, + + onEmojiClick: handleEmojiClick, + }); + + const ariaLabel = intl.formatMessage({id: 'reaction.add.ariaLabel', defaultMessage: 'Add a reaction'}); + + return ( + + + + + + + {emojiPicker} + + ); +} diff --git a/webapp/channels/src/components/post_view/reaction_list/reaction_list.tsx b/webapp/channels/src/components/post_view/reaction_list/reaction_list.tsx index 8e4d3a8cf5..2c9b587273 100644 --- a/webapp/channels/src/components/post_view/reaction_list/reaction_list.tsx +++ b/webapp/channels/src/components/post_view/reaction_list/reaction_list.tsx @@ -2,32 +2,18 @@ // See LICENSE.txt for license information. import React from 'react'; -import {defineMessages} from 'react-intl'; import type {Emoji} from '@mattermost/types/emojis'; import type {Post} from '@mattermost/types/posts'; import type {Reaction as ReactionType} from '@mattermost/types/reactions'; -import Permissions from 'mattermost-redux/constants/permissions'; import {getEmojiName} from 'mattermost-redux/utils/emoji_utils'; -import EmojiPickerOverlay from 'components/emoji_picker/emoji_picker_overlay'; -import ChannelPermissionGate from 'components/permissions_gates/channel_permission_gate'; import Reaction from 'components/post_view/reaction'; -import AddReactionIcon from 'components/widgets/icons/add_reaction_icon'; -import WithTooltip from 'components/with_tooltip'; import {localizeMessage} from 'utils/utils'; -const DEFAULT_EMOJI_PICKER_RIGHT_OFFSET = 15; -const EMOJI_PICKER_WIDTH_OFFSET = 260; - -const messages = defineMessages({ - addAReaction: { - id: 'reaction_list.addReactionTooltip', - defaultMessage: 'Add a reaction', - }, -}); +import AddReactionButton from './add_reaction_button'; type Props = { @@ -62,18 +48,14 @@ type Props = { type State = { emojiNames: string[]; - showEmojiPicker: boolean; }; export default class ReactionList extends React.PureComponent { - private addReactionButtonRef = React.createRef(); - constructor(props: Props) { super(props); this.state = { emojiNames: [], - showEmojiPicker: false, }; } @@ -89,25 +71,11 @@ export default class ReactionList extends React.PureComponent { return (emojiNames === state.emojiNames) ? null : {emojiNames}; } - getTarget = (): HTMLButtonElement | null => { - return this.addReactionButtonRef.current; - }; - handleEmojiClick = (emoji: Emoji): void => { - this.setState({showEmojiPicker: false}); const emojiName = getEmojiName(emoji); this.props.actions.toggleReaction(this.props.post.id, emojiName); }; - hideEmojiPicker = (): void => { - this.setState({showEmojiPicker: false}); - }; - - toggleEmojiPicker = (e?: React.MouseEvent): void => { - e?.stopPropagation(); - this.setState({showEmojiPicker: !this.state.showEmojiPicker}); - }; - render(): React.ReactNode { const reactionsByName = new Map(); @@ -141,69 +109,24 @@ export default class ReactionList extends React.PureComponent { return null; }); - const addReactionButton = this.getTarget(); - let rightOffset = DEFAULT_EMOJI_PICKER_RIGHT_OFFSET; - if (addReactionButton) { - rightOffset = window.innerWidth - addReactionButton.getBoundingClientRect().right - EMOJI_PICKER_WIDTH_OFFSET; - - if (rightOffset < 0) { - rightOffset = DEFAULT_EMOJI_PICKER_RIGHT_OFFSET; - } - } - - let emojiPicker = null; + let addReaction = null; if (this.props.canAddReactions) { - emojiPicker = ( - - - - - - - - + addReaction = ( + ); } - let addReactionClassName = 'post-add-reaction'; - if (this.state.showEmojiPicker) { - addReactionClassName += ' post-add-reaction-emoji-picker-open'; - } - return (
{reactions} -
- {emojiPicker} -
+ {addReaction}
); } diff --git a/webapp/channels/src/i18n/en.json b/webapp/channels/src/i18n/en.json index bab9930e8b..be24590eda 100644 --- a/webapp/channels/src/i18n/en.json +++ b/webapp/channels/src/i18n/en.json @@ -4835,7 +4835,6 @@ "reaction_limit_reached_modal.body": "Oops! It looks like we've hit a ceiling on emoji reactions for this message. Please contact your system administrator for any adjustments to this limit.", "reaction_limit_reached_modal.body.admin": "Oops! It looks like we've hit a ceiling on emoji reactions for this message. We've set a limit to keep things running smoothly on your server. As a system administrator, you can adjust this limit from the system console.", "reaction_limit_reached_modal.title": "You've reached the reaction limit", - "reaction_list.addReactionTooltip": "Add a reaction", "reaction.add.ariaLabel": "Add a reaction", "reaction.clickToAdd": "(click to add)", "reaction.clickToRemove": "(click to remove)", diff --git a/webapp/channels/src/sass/components/_emoji-picker-tabs.scss b/webapp/channels/src/sass/components/_emoji-picker-tabs.scss index 13d0f592e5..3cf8416b91 100644 --- a/webapp/channels/src/sass/components/_emoji-picker-tabs.scss +++ b/webapp/channels/src/sass/components/_emoji-picker-tabs.scss @@ -1,3 +1,5 @@ +@use "utils/variables"; + .emoji-picker { pointer-events: auto; @@ -49,6 +51,11 @@ } } +.emoji-picker-overlay { + z-index: variables.$z-index-popover; + pointer-events: none; +} + .custom-emoji-tab__icon__text { display: flex; flex-direction: row; diff --git a/webapp/channels/src/sass/components/_emoticons.scss b/webapp/channels/src/sass/components/_emoticons.scss index 0a1fc3737f..149a760f53 100644 --- a/webapp/channels/src/sass/components/_emoticons.scss +++ b/webapp/channels/src/sass/components/_emoticons.scss @@ -86,11 +86,11 @@ } .emoji-picker { - position: absolute; - z-index: 1100; - display: flex; + // position: relative; + // z-index: 1100; + // display: flex; width: 350px; - flex-direction: column; + // flex-direction: column; border: 1px solid; border-radius: var(--radius-s); margin-right: 3px; @@ -118,10 +118,6 @@ margin-right: 0; } - &.bottom { - margin-top: 10px; - } - .nav-tabs { display: flex; flex: 0 0 34px; diff --git a/webapp/channels/src/sass/components/_post-right.scss b/webapp/channels/src/sass/components/_post-right.scss index 190bcec662..d9e0dd6239 100644 --- a/webapp/channels/src/sass/components/_post-right.scss +++ b/webapp/channels/src/sass/components/_post-right.scss @@ -202,12 +202,6 @@ opacity: 0.7; } - .emoji-picker { - position: absolute; - top: -361px; - right: 0; - } - form { padding: 0; } diff --git a/webapp/channels/src/sass/components/_post.scss b/webapp/channels/src/sass/components/_post.scss index caf7416452..fc0d6520bf 100644 --- a/webapp/channels/src/sass/components/_post.scss +++ b/webapp/channels/src/sass/components/_post.scss @@ -343,12 +343,6 @@ } } - .emoji-picker { - position: absolute; - top: -361px; - right: 0; - } - .scroll { .custom-textarea { overflow: auto; @@ -658,11 +652,9 @@ visibility: visible; } - .post-add-reaction { - .Reaction { - opacity: 1; - visibility: visible; - } + .Reaction__add { + opacity: 1; + visibility: visible; } } @@ -1373,13 +1365,10 @@ } } - .post-add-reaction { + .Reaction__add { display: inline-block; - - .Reaction { - opacity: 0; - visibility: hidden; - } + opacity: 0; + visibility: hidden; } .post__body { @@ -1488,21 +1477,8 @@ align-items: center; padding: 4px 0 0; - .post-add-reaction-emoji-picker-open { + .Reaction__add { display: inline-block; - - .Reaction { - opacity: 1; - visibility: visible; - } - } - } - - .post-add-reaction-emoji-picker-open { - .Reaction { - background-color: rgba(var(--button-bg-rgb), 0.08); - color: functions.v(button-bg); - fill: functions.v(button-bg); } } diff --git a/webapp/channels/src/sass/responsive/_mobile.scss b/webapp/channels/src/sass/responsive/_mobile.scss index 7e5af04a33..1b302a265f 100644 --- a/webapp/channels/src/sass/responsive/_mobile.scss +++ b/webapp/channels/src/sass/responsive/_mobile.scss @@ -467,7 +467,6 @@ } .emoji-picker { - z-index: 1070; // !important is used to overide inline styles // used on larger screens top: 0 !important; @@ -482,10 +481,6 @@ margin-top: 66px; } - &.bottom { - margin-top: 0; - } - .search-grid-container { height: calc(100vh - 170px); } @@ -861,11 +856,9 @@ } } - .post-add-reaction { - .Reaction { - opacity: 1; - visibility: visible; - } + .Reaction__add { + opacity: 1; + visibility: visible; } .browser--ie & { @@ -2082,19 +2075,6 @@ transform: translate3d(0, 0, 0); } } - - // overides edit modal body element's css position - // to allow emoji picker to fill the screen on mobile screens < 480 - .edit-modal-body--add-reaction { - position: static; - - .emoji-picker { - top: -1px !important; - left: -1px !important; - width: calc(100% + 2px); - height: calc(100% + 2px); - } - } } @media screen and (max-height: 640px) { diff --git a/webapp/channels/src/utils/constants.tsx b/webapp/channels/src/utils/constants.tsx index 8397c1ef86..8f04cfdc00 100644 --- a/webapp/channels/src/utils/constants.tsx +++ b/webapp/channels/src/utils/constants.tsx @@ -1657,10 +1657,6 @@ export const Constants = { OPEN_TEAM: 'O', THREADS: 'threads', MAX_POST_LEN: 4000, - EMOJI_SIZE: 16, - DEFAULT_EMOJI_PICKER_LEFT_OFFSET: 87, - DEFAULT_EMOJI_PICKER_RIGHT_OFFSET: 15, - EMOJI_PICKER_WIDTH_OFFSET: 295, SIDEBAR_MINIMUM_WIDTH: 640, THEME_ELEMENTS: [ { diff --git a/webapp/channels/src/utils/floating.ts b/webapp/channels/src/utils/floating.ts new file mode 100644 index 0000000000..49a3efb1e4 --- /dev/null +++ b/webapp/channels/src/utils/floating.ts @@ -0,0 +1,57 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import {detectOverflow} from '@floating-ui/react'; +import type {Boundary, MiddlewareState} from '@floating-ui/react-dom'; + +export type HorizontallyWithinOptions = { + + /** + * An element or Rect that the floating element should be aligned with. Often, this will be the result of calling + * document.getElementById with the ID of a parent element (like the post textbox for the emoji picker). + * + * See Floating UI's documentation on detectOverflow for more details. + */ + boundary?: Boundary | null; +} + +/** + * horizontallyWithin is a middleware for useFloating which shifts the floating element left or right to try to keep + * it within the horizontal boundaries of the given boundary element. + * + * If the floating element is wider than the boundary, it'll be positioned right aligned with the boundary. + */ +export function horizontallyWithin(options: HorizontallyWithinOptions = {}) { + return ({ + name: 'horizontallyWithin', + options, + async fn(state: MiddlewareState) { + const {boundary} = options; + + if (!boundary) { + return {}; + } + + const overflow = await detectOverflow(state, { + boundary, + }); + + if (overflow.right > 0) { + // The floating element is overflowing on the right, so shift left + return { + x: state.x - overflow.right, + y: state.y, + }; + } else if (overflow.left > 0) { + // The floating element is overflowing on the left, so shift right + return { + x: state.x + overflow.left, + y: state.y, + }; + } + + // The floating element is horizontally within the boundary, so do nothing + return {}; + }, + }); +} diff --git a/webapp/channels/src/utils/position_utils.test.tsx b/webapp/channels/src/utils/position_utils.test.tsx deleted file mode 100644 index 70d4c44741..0000000000 --- a/webapp/channels/src/utils/position_utils.test.tsx +++ /dev/null @@ -1,16 +0,0 @@ -// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. -// See LICENSE.txt for license information. - -import {popOverOverlayPosition} from 'utils/position_utils'; - -test('Should return placement position for overlay based on bounds, space required and innerHeight', () => { - const targetBounds = { - top: 400, - bottom: 500, - }; - - expect(popOverOverlayPosition(targetBounds as DOMRect, 1000, 300)).toEqual('top'); - expect(popOverOverlayPosition(targetBounds as DOMRect, 1000, 500, 300)).toEqual('bottom'); - expect(popOverOverlayPosition(targetBounds as DOMRect, 1000, 450)).toEqual('bottom'); - expect(popOverOverlayPosition(targetBounds as DOMRect, 1000, 600)).toEqual('left'); -}); diff --git a/webapp/channels/src/utils/position_utils.tsx b/webapp/channels/src/utils/position_utils.tsx deleted file mode 100644 index da16b7f592..0000000000 --- a/webapp/channels/src/utils/position_utils.tsx +++ /dev/null @@ -1,24 +0,0 @@ -// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. -// See LICENSE.txt for license information. - -import type {ComponentProps} from 'react'; -import type {Overlay} from 'react-bootstrap'; - -export function popOverOverlayPosition( - targetBounds: DOMRect, - innerHeight: number, - spaceRequiredAbove: number, - spaceRequiredBelow?: number, - horizontalPosition?: 'left' | 'right', -) { - let placement: ComponentProps['placement']; - - if (targetBounds.top > spaceRequiredAbove) { - placement = 'top'; - } else if (innerHeight - targetBounds.bottom > (spaceRequiredBelow || spaceRequiredAbove)) { - placement = 'bottom'; - } else { - placement = horizontalPosition || 'left'; - } - return placement; -}