MM-62383 Replace React Bootstrap with Floating UI in Emoji Picker (#29835)

* Convert EmojiPickerOverlay to functional component

* Convert EmojiPickerTabs to functional component

* Extract AddReactionButton from ReactionList

This is so that I can make part of it functional without rewriting
the whole thing.

* Convert PostReaction to functional component

* Add general version of useEmojiPicker and use for AddReactionButton

* Rename returned showEmojiPicker to emojiPickerOpen

* Add test for AddReactionButton

* Move showEmojiPicker state out of useEmojiPicker

I hoped to avoid this by just having the hook return the
show state, but unfortunately, too many of the existing places
rely on controlling the state themselves

* Change PostReaction to use useEmojiPicker

I ran into some trouble with this getting the hover state to properly
disappear from the PostComponent when clicking out of the picker. That
seems to be a downside of the browser's mouseenter/mouseleave not
handling cases where the component is covered up. It doesn't work 100%,
but it works at least as well as master by disabling pointer-events to
the FloatingOverlay (which I also think we could probably remove since
it's supposed to just be for darkening the backdrop behind the picker,
but it ended up being helpful for setting the z-index on mobile).

* Change AdvancedTextEditor to use new useEmojiPicker

I renamed its version of useEmojiPicker to useEditorEmojiPicker since it
still contains information about how to position the emoji or gifs in
the post text.

* Convert EditPost to use useEmojiPicker

* Convert CreateModalNameInput to use useEmoijPicker

* Convert CustomStatusModal to use useEmojiPicker

* Remove EmojiPickerOverlay and cleanup related code

* Remove unneeded translation string

* asdf Attempting to fix E2E test

* Improve how useEmojiPicker positions itself to stay on screen more

* Add offset between Emoji Picker and reference

* Add horizontallyWithin middleware and use it to right-align the emoji picker in the post textbox
This commit is contained in:
Harrison Healey 2025-02-14 13:53:30 -05:00 committed by GitHub
parent da7192246e
commit b6118b7701
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
34 changed files with 751 additions and 897 deletions

View File

@ -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');
});

View File

@ -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();

View File

@ -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;

View File

@ -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<HTMLButtonElement>(null);
const [showEmojiPicker, setShowEmojiPicker] = useState(false);
const toggleEmojiPicker = useCallback((e?: React.MouseEvent<HTMLButtonElement, 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 = (
<>
<EmojiPickerOverlay
show={showEmojiPicker}
target={getEmojiPickerRef}
onHide={hideEmojiPicker}
onEmojiClick={handleEmojiClick}
onGifClick={handleGifClick}
enableGifPicker={enableGifPicker}
topOffset={-7}
rightOffset={emojiPickerOffset?.right}
/>
<WithTooltip
title={
<KeyboardShortcutSequence
@ -141,12 +149,13 @@ const useEmojiPicker = (
>
<IconContainer
id={'emojiPickerButton'}
ref={emojiPickerRef}
ref={setReference}
onClick={toggleEmojiPicker}
type='button'
aria-label={intl.formatMessage({id: 'emoji_picker.emojiPicker.button.ariaLabel', defaultMessage: 'select an emoji'})}
disabled={shouldShowPreview}
className={classNames({active: showEmojiPicker})}
{...getReferenceProps()}
>
<EmoticonHappyOutlineIcon
color={'currentColor'}
@ -154,11 +163,12 @@ const useEmojiPicker = (
/>
</IconContainer>
</WithTooltip>
{emojiPicker}
</>
);
}
return {emojiPicker, enableEmojiPicker, toggleEmojiPicker};
return {emojiPicker: emojiPickerControls, enableEmojiPicker, toggleEmojiPicker};
};
export default useEmojiPicker;
export default useEditorEmojiPicker;

View File

@ -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<HTMLButtonElement>(null);
const getTargetRef = () => targetRef.current;
const icon = (
<BookmarkIcon
@ -90,11 +89,6 @@ const CreateModalNameInput = ({
setEmoji('');
};
const handleEmojiClose = () => {
setShowEmojiPicker(false);
refocusEmojiButton();
};
const handleInputChange: ComponentProps<typeof Input>['onChange'] = useCallback((e) => {
setDisplayName(e.currentTarget.value);
}, []);
@ -112,33 +106,34 @@ const CreateModalNameInput = ({
}
};
const {
emojiPicker,
getReferenceProps,
setReference,
} = useEmojiPicker({
showEmojiPicker,
setShowEmojiPicker,
onAddCustomEmojiClick,
onEmojiClick: handleEmojiClick,
});
return (
<>
<NameWrapper>
{showEmojiPicker && (
<EmojiPickerOverlay
target={getTargetRef}
show={showEmojiPicker}
onHide={handleEmojiClose}
onEmojiClick={handleEmojiClick}
placement='right'
onAddCustomEmojiClick={onAddCustomEmojiClick}
/>
)}
<button
ref={targetRef}
ref={setReference}
type='button'
onClick={toggleEmojiPicker}
onKeyDown={handleEmojiKeyDown}
aria-label={formatMessage({id: 'emoji_picker.emojiPicker.button.ariaLabel', defaultMessage: 'select an emoji'})}
aria-expanded={showEmojiPicker ? 'true' : 'false'}
className='channelBookmarksMenuButton emoji-picker__container BookmarkCreateModal__emoji-button'
{...getReferenceProps()}
>
{icon}
<ChevronDownIcon size={'12px'}/>
</button>
{emojiPicker}
<Input
maxLength={maxLength}
type='text'

View File

@ -4,7 +4,7 @@
import classNames from 'classnames';
import type {Moment} from 'moment-timezone';
import moment from 'moment-timezone';
import React, {useCallback, useEffect, useMemo, useRef, useState} from 'react';
import React, {useCallback, useEffect, useMemo, useState} from 'react';
import type {MessageDescriptor} from 'react-intl';
import {FormattedMessage, defineMessage, useIntl} from 'react-intl';
import {useDispatch, useSelector} from 'react-redux';
@ -28,12 +28,11 @@ import CustomStatusSuggestion from 'components/custom_status/custom_status_sugge
import DateTimeInput, {getRoundedTime} from 'components/custom_status/date_time_input';
import ExpiryMenu from 'components/custom_status/expiry_menu';
import RenderEmoji from 'components/emoji/render_emoji';
import EmojiPickerOverlay from 'components/emoji_picker/emoji_picker_overlay';
import useEmojiPicker from 'components/emoji_picker/use_emoji_picker';
import QuickInput, {MaxLengthInput} from 'components/quick_input';
import EmojiIcon from 'components/widgets/icons/emoji_icon';
import {A11yCustomEventTypes, Constants, ModalIdentifiers} from 'utils/constants';
import type {A11yFocusEventDetail} from 'utils/constants';
import {Constants, ModalIdentifiers} from 'utils/constants';
import {isKeyPressed} from 'utils/keyboard';
import {getCurrentMomentForTimezone} from 'utils/timezone';
@ -49,7 +48,6 @@ type Props = {
// This is the same limit set
// https://github.com/mattermost/mattermost-server/pull/16835/files#diff-73c61af5954b16f5e3cb5ee786af9eb698f660eff0d65db5556949be5fb6e60bR15
const CUSTOM_STATUS_TEXT_CHARACTER_LIMIT = 100;
const EMOJI_PICKER_WIDTH_OFFSET = 308;
type DefaultUserCustomStatus = {
emoji: string;
@ -118,8 +116,6 @@ const CustomStatusModal: React.FC<Props> = (props: Props) => {
const currentCustomStatus = useSelector(getCustomStatus);
const customStatusExpired = useSelector((state: GlobalState) => isCustomStatusExpired(state, currentCustomStatus));
const recentCustomStatuses = useSelector(getRecentCustomStatuses);
const customStatusControlRef = useRef<HTMLDivElement>(null);
const emojiButtonRef = useRef<HTMLButtonElement>(null);
const {formatMessage} = useIntl();
const isCurrentCustomStatusSet = !customStatusExpired && (currentCustomStatus?.text || currentCustomStatus?.emoji);
const [showEmojiPicker, setShowEmojiPicker] = useState<boolean>(false);
@ -230,54 +226,10 @@ const CustomStatusModal: React.FC<Props> = (props: Props) => {
const handleClearStatus = isCurrentCustomStatusSet ? () => dispatch(unsetCustomStatus()) : undefined;
const getCustomStatusControlRef = () => customStatusControlRef.current;
const handleEmojiClose = () => {
setShowEmojiPicker(false);
if (emojiButtonRef.current) {
document.dispatchEvent(new CustomEvent<A11yFocusEventDetail>(
A11yCustomEventTypes.FOCUS, {
detail: {
target: emojiButtonRef.current as HTMLElement,
keyboardOnly: true,
},
},
));
}
};
const handleEmojiExited = () => {
if (emojiButtonRef.current) {
document.dispatchEvent(new CustomEvent<A11yFocusEventDetail>(
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<A11yFocusEventDetail>(
A11yCustomEventTypes.FOCUS, {
detail: {
target: emojiButtonRef.current as HTMLElement,
keyboardOnly: true,
},
},
));
}
};
const toggleEmojiPicker = (e?: React.MouseEvent<HTMLButtonElement, MouseEvent>): void => {
e?.stopPropagation();
setShowEmojiPicker((prevShow) => !prevShow);
};
const handleTextChange = (event: React.ChangeEvent<HTMLInputElement>) => setText(event.target.value);
@ -291,6 +243,17 @@ const CustomStatusModal: React.FC<Props> = (props: Props) => {
/>
) : <EmojiIcon className={'icon icon--emoji'}/>;
const {
emojiPicker,
getReferenceProps,
setReference,
} = useEmojiPicker({
showEmojiPicker,
setShowEmojiPicker,
onEmojiClick: handleEmojiClick,
});
const clearHandle = () => {
setEmoji('');
setText('');
@ -303,19 +266,6 @@ const CustomStatusModal: React.FC<Props> = (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 = (
<div id='statusSuggestion__recents'>
<div className='statusSuggestion__title'>
@ -425,34 +375,19 @@ const CustomStatusModal: React.FC<Props> = (props: Props) => {
>
<div className='StatusModal__body'>
<div className='StatusModal__input'>
<div
ref={customStatusControlRef}
className='StatusModal__emoji-container'
>
{showEmojiPicker && (
<EmojiPickerOverlay
target={getCustomStatusControlRef}
show={showEmojiPicker}
onHide={handleEmojiClose}
onEmojiClick={handleEmojiClick}
rightOffset={calculateRightOffSet()}
leftOffset={3}
topOffset={3}
defaultHorizontalPosition='right'
onExited={handleEmojiExited}
/>
)}
<div className='StatusModal__emoji-container'>
<button
type='button'
onClick={toggleEmojiPicker}
ref={emojiButtonRef}
ref={setReference}
aria-label={formatMessage({id: 'emoji_picker.emojiPicker.button.ariaLabel', defaultMessage: 'select an emoji'})}
className={classNames('emoji-picker__container', 'StatusModal__emoji-button', {
'StatusModal__emoji-button--active': showEmojiPicker,
})}
{...getReferenceProps()}
>
{customStatusEmoji}
</button>
{emojiPicker}
</div>
<QuickInput
inputComponent={MaxLengthInput}

View File

@ -67,7 +67,7 @@ type Props = {
isFlagged?: boolean;
handleCommentClick?: React.EventHandler<any>;
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<Props, State> {
};
handleAddReactionMenuItemActivated = () => {
// to be safe, make sure the handler function has been defined
if (this.props.handleAddReactionClick) {
this.props.handleAddReactionClick();
this.props.handleAddReactionClick(true);
}
};

View File

@ -44,14 +44,6 @@ import DotMenu from './dot_menu';
type Props = {
post: Post;
isFlagged?: boolean;
handleCommentClick?: React.EventHandler<React.MouseEvent | React.KeyboardEvent>;
handleCardClick?: (post: Post) => void;
handleDropdownOpened: (open: boolean) => void;
handleAddReactionClick?: () => void;
isMenuOpen: boolean;
isReadOnly?: boolean;
enableEmojiPicker?: boolean;
location?: ComponentProps<typeof DotMenu>['location'];
};

View File

@ -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<boolean>(false);
const textboxRef = useRef<TextboxClass>(null);
const emojiButtonRef = useRef<HTMLButtonElement>(null);
const wrapperRef = useRef<HTMLDivElement>(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 = (
<>
<EmojiPickerOverlay
show={showEmojiPicker}
target={getEmojiTargetRef}
onHide={hideEmojiPicker}
onEmojiClick={handleEmojiClick}
onGifClick={handleGifClick}
enableGifPicker={config.EnableGifPicker === 'true'}
topOffset={TOP_OFFSET}
rightOffset={RIGHT_OFFSET}
/>
<button
aria-label={formatMessage({id: 'emoji_picker.emojiPicker.button.ariaLabel', defaultMessage: 'select an emoji'})}
id='editPostEmoji'
ref={emojiButtonRef}
ref={setReference}
className='style--none post-action'
onClick={toggleEmojiPicker}
{...getReferenceProps()}
>
<EmoticonPlusOutlineIcon
size={18}
color='currentColor'
/>
</button>
{emojiPicker}
</>
);
}
@ -637,7 +629,7 @@ const EditPost = ({editingPost, actions, canEditPost, config, channelId, draft,
useChannelMentions={rest.useChannelMentions}
/>
<div className='post-body__actions'>
{emojiPicker}
{emojiPickerControls}
</div>
{ showMentionHelper ? (
<div className='post-body__info'>

View File

@ -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=""
/>

View File

@ -138,7 +138,7 @@ const EmojiPickerSearch = forwardRef<HTMLInputElement, Props>(({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}
/>
</div>

View File

@ -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<typeof Overlay>['placement'];
topOffset?: number;
rightOffset?: number;
leftOffset?: number;
spaceRequiredAbove?: number;
spaceRequiredBelow?: number;
enableGifPicker?: boolean;
defaultHorizontalPosition?: 'left' | 'right';
}
export default class EmojiPickerOverlay extends React.PureComponent<Props> {
// 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 (
<Overlay
show={show}
placement={this.props.placement ?? placement}
rootClose={!isMobileView}
onHide={this.props.onHide}
target={target}
animation={false}
onExited={this.props?.onExited}
>
<EmojiPickerTabs
enableGifPicker={this.props.enableGifPicker}
onEmojiClose={this.props.onHide}
onEmojiClick={this.props.onEmojiClick}
onGifClick={this.props.onGifClick}
rightOffset={calculatedRightOffset}
topOffset={this.props.topOffset}
leftOffset={this.props.leftOffset}
onAddCustomEmojiClick={this.props.onAddCustomEmojiClick}
/>
</Overlay>
);
}
}

View File

@ -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<typeof connector>;
export default connector(EmojiPickerOverlay);

View File

@ -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<Props, State> {
private rootPickerNodeRef: RefObject<HTMLDivElement>;
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 (
<div
id='emojiGifPicker'
ref={this.rootPickerNodeRef}
style={pickerStyle}
className={classNames('a11y__popup', 'emoji-picker', {
bottom: this.props.placement === 'bottom',
})}
role='dialog'
aria-label={this.state.activeKey === 1 ? intl.formatMessage({id: 'emoji_gif_picker.dialog.emojis', defaultMessage: 'Emoji Picker'}) : intl.formatMessage({id: 'emoji_gif_picker.dialog.gifs', defaultMessage: 'GIF Picker'})
}
aria-modal='true'
>
<Tabs
id='emoji-picker-tabs'
defaultActiveKey={1}
justified={true}
mountOnEnter={true}
unmountOnExit={true}
activeKey={this.state.activeKey}
onSelect={(activeKey) => this.setState({activeKey})}
>
<EmojiPickerHeader handleEmojiPickerClose={this.handleEmojiPickerClose}/>
<Tab
eventKey={1}
title={
<div className={'custom-emoji-tab__icon__text'}>
<EmojiIcon
className='custom-emoji-tab__icon'
aria-hidden={true}
/>
<FormattedMessage
id='emoji_gif_picker.tabs.emojis'
defaultMessage='Emojis'
/>
</div>
}
unmountOnExit={true}
tabClassName={'custom-emoji-tab'}
>
<EmojiPicker
filter={this.state.filter}
onEmojiClick={this.props.onEmojiClick}
handleFilterChange={this.handleFilterChange}
handleEmojiPickerClose={this.handleEmojiPickerClose}
/>
</Tab>
<Tab
eventKey={2}
title={
<div className={'custom-emoji-tab__icon__text'}>
<GifIcon
className='custom-emoji-tab__icon'
aria-hidden={true}
/>
<FormattedMessage
id='emoji_gif_picker.tabs.gifs'
defaultMessage='GIFs'
/>
</div>
}
unmountOnExit={true}
tabClassName={'custom-emoji-tab'}
>
<GifPicker
filter={this.state.filter}
getRootPickerNode={this.getRootPickerNode}
onGifClick={this.props.onGifClick}
handleFilterChange={this.handleFilterChange}
/>
</Tab>
</Tabs>
</div>
);
}
const rootPickerNodeRef = useRef<HTMLDivElement>(null);
const getRootPickerNode = useCallback(() => rootPickerNodeRef.current, []);
if (props.enableGifPicker && typeof props.onGifClick != 'undefined') {
return (
<div
id='emojiPicker'
style={pickerStyle}
className={classNames('a11y__popup', 'emoji-picker', 'emoji-picker--single', {
bottom: this.props.placement === 'bottom',
})}
id='emojiGifPicker'
ref={rootPickerNodeRef}
className='a11y__popup emoji-picker'
role='dialog'
aria-label={
intl.formatMessage({id: 'emoji_gif_picker.dialog.emojis', defaultMessage: 'Emoji Picker'})}
aria-label={activeKey === 1 ? intl.formatMessage({id: 'emoji_gif_picker.dialog.emojis', defaultMessage: 'Emoji Picker'}) : intl.formatMessage({id: 'emoji_gif_picker.dialog.gifs', defaultMessage: 'GIF Picker'})}
aria-modal='true'
>
<EmojiPickerHeader handleEmojiPickerClose={this.handleEmojiPickerClose}/>
<EmojiPicker
filter={this.state.filter}
onEmojiClick={this.props.onEmojiClick}
handleFilterChange={this.handleFilterChange}
handleEmojiPickerClose={this.handleEmojiPickerClose}
onAddCustomEmojiClick={this.props.onAddCustomEmojiClick}
/>
<Tabs
id='emoji-picker-tabs'
defaultActiveKey={1}
justified={true}
mountOnEnter={true}
unmountOnExit={true}
activeKey={activeKey}
onSelect={(activeKey) => setActiveKey(activeKey)}
>
<EmojiPickerHeader handleEmojiPickerClose={props.onEmojiClose}/>
<Tab
eventKey={1}
title={
<div className={'custom-emoji-tab__icon__text'}>
<EmojiIcon
className='custom-emoji-tab__icon'
aria-hidden={true}
/>
<FormattedMessage
id='emoji_gif_picker.tabs.emojis'
defaultMessage='Emojis'
/>
</div>
}
unmountOnExit={true}
tabClassName={'custom-emoji-tab'}
>
<EmojiPicker
filter={filter}
onEmojiClick={props.onEmojiClick}
handleFilterChange={setFilter}
handleEmojiPickerClose={props.onEmojiClose}
/>
</Tab>
<Tab
eventKey={2}
title={
<div className={'custom-emoji-tab__icon__text'}>
<GifIcon
className='custom-emoji-tab__icon'
aria-hidden={true}
/>
<FormattedMessage
id='emoji_gif_picker.tabs.gifs'
defaultMessage='GIFs'
/>
</div>
}
unmountOnExit={true}
tabClassName={'custom-emoji-tab'}
>
<GifPicker
filter={filter}
getRootPickerNode={getRootPickerNode}
onGifClick={props.onGifClick}
handleFilterChange={setFilter}
/>
</Tab>
</Tabs>
</div>
);
}
}
export default injectIntl(EmojiPickerTabs);
return (
<div
id='emojiPicker'
className='a11y__popup emoji-picker emoji-picker--single'
role='dialog'
aria-label={intl.formatMessage({id: 'emoji_gif_picker.dialog.emojis', defaultMessage: 'Emoji Picker'})}
aria-modal='true'
>
<EmojiPickerHeader handleEmojiPickerClose={props.onEmojiClose}/>
<EmojiPicker
filter={filter}
onEmojiClick={props.onEmojiClick}
handleFilterChange={setFilter}
handleEmojiPickerClose={props.onEmojiClose}
onAddCustomEmojiClick={props.onAddCustomEmojiClick}
/>
</div>
);
}

View File

@ -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<typeof useInteractions>['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 = (
<EmojiPickerTabs
enableGifPicker={enableGifPicker}
onAddCustomEmojiClick={onAddCustomEmojiClick}
onEmojiClose={hideEmojiPicker}
onEmojiClick={onEmojiClick}
onGifClick={onGifClick}
/>
);
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 = (
<div ref={refs.setFloating}>
{emojiPicker}
</div>
);
} else {
emojiPicker = (
<div
ref={refs.setFloating}
style={{...floatingStyles}}
{...getFloatingProps()}
>
{emojiPicker}
</div>
);
}
return {
emojiPicker: (
showEmojiPicker && <FloatingPortal id={RootHtmlPortalId}>
<FloatingOverlay className='emoji-picker-overlay'>
<FloatingFocusManager context={floatingContext}>
{emojiPicker}
</FloatingFocusManager>
</FloatingOverlay>
</FloatingPortal>
),
getReferenceProps,
setReference: refs.setReference,
};
}

View File

@ -59,27 +59,35 @@ type Props = {
};
const PostOptions = (props: Props): JSX.Element => {
const dotMenuRef = useRef<HTMLUListElement>(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}
/>
</li>
);
@ -278,7 +283,6 @@ const PostOptions = (props: Props): JSX.Element => {
} else if (!props.isPostBeingEdited) {
options = (
<ul
ref={dotMenuRef}
data-testid={`post-menu-${props.post.id}`}
className={classnames('col post-menu', {'post-menu--position': !hoverLocal && showCommentIcon})}
>

View File

@ -1,41 +0,0 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`components/post_view/PostReaction should match snapshot 1`] = `
<Memo(ChannelPermissionGate)
channelId="current_channel_id"
permissions={
Array [
"add_reaction",
]
}
teamId="current_team_id"
>
<Connect(EmojiPickerOverlay)
onEmojiClick={[Function]}
onHide={[MockFunction]}
show={false}
target={[MockFunction]}
topOffset={-7}
/>
<WithTooltip
title={
Object {
"defaultMessage": "Add Reaction",
"id": "post_info.tooltip.add_reactions",
}
}
>
<button
aria-label="Add Reaction"
className="post-menu__item post-menu__item--reactions"
data-testid="post-reaction-emoji-icon"
id="CENTER_reaction_post_id_1"
onClick={[MockFunction]}
>
<Memo(EmojiIcon)
className="icon icon--small"
/>
</button>
</WithTooltip>
</Memo(ChannelPermissionGate)>
`;

View File

@ -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(<PostReaction {...baseProps}/>);
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(
<PostReaction {...baseProps}/>,
initialState,
);
expect(screen.queryByPlaceholderText('Search emojis')).not.toBeInTheDocument();
await Promise.resolve();
rerender(
<PostReaction
{...baseProps}
showEmojiPicker={true}
/>,
);
expect(screen.queryByPlaceholderText('Search emojis')).toBeInTheDocument();
});
test('should call toggleReaction and toggleEmojiPicker on handleToggleEmoji', () => {
const wrapper = shallowWithIntl(<PostReaction {...baseProps}/>);
const instance = wrapper.instance() as PostReactionComponent;
test('should toggle the reaction and close the emoji picker when an emoji is selected', async () => {
renderWithContext(
<PostReaction
{...baseProps}
showEmojiPicker={true}
/>,
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);
});
});

View File

@ -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<HTMLButtonElement, 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<Props, State> {
public static defaultProps: Partial<Props> = {
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 (
<ChannelPermissionGate
channelId={channelId}
teamId={teamId}
permissions={[Permissions.ADD_REACTION]}
>
<>
<EmojiPickerOverlay
show={showEmojiPicker}
target={this.props.getDotMenuRef}
onHide={this.props.toggleEmojiPicker}
onEmojiClick={this.handleToggleEmoji}
topOffset={TOP_OFFSET}
spaceRequiredAbove={spaceRequiredAbove}
spaceRequiredBelow={spaceRequiredBelow}
/>
<WithTooltip
title={messages.addReaction}
>
<button
data-testid='post-reaction-emoji-icon'
id={`${location}_reaction_${postId}`}
aria-label={intl.formatMessage({id: 'post_info.tooltip.add_reactions', defaultMessage: 'Add Reaction'})}
className={classNames('post-menu__item', 'post-menu__item--reactions', {
'post-menu__item--active': showEmojiPicker,
})}
onClick={this.props.toggleEmojiPicker}
>
<EmojiIcon className='icon icon--small'/>
</button>
</WithTooltip>
</>
</ChannelPermissionGate>
);
}
onEmojiClick: handleEmojiClick,
});
const ariaLabel = intl.formatMessage({id: 'post_info.tooltip.add_reactions', defaultMessage: 'Add Reaction'});
return (
<ChannelPermissionGate
channelId={channelId}
teamId={teamId}
permissions={[Permissions.ADD_REACTION]}
>
<WithTooltip title={ariaLabel}>
<button
ref={setReference}
data-testid='post-reaction-emoji-icon'
id={`${location}_reaction_${postId}`}
aria-label={ariaLabel}
className={classNames('post-menu__item', 'post-menu__item--reactions', {
'post-menu__item--active': showEmojiPicker,
})}
{...getReferenceProps()}
>
<EmojiIcon className='icon icon--small'/>
</button>
</WithTooltip>
{emojiPicker}
</ChannelPermissionGate>
);
}
export default injectIntl(PostReaction);

View File

@ -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 {

View File

@ -48,52 +48,36 @@ exports[`components/ReactionList should render when there are reactions 1`] = `
]
}
/>
<div
className="post-add-reaction"
>
<span
className="emoji-picker__container"
>
<Connect(EmojiPickerOverlay)
onEmojiClick={[Function]}
onHide={[Function]}
rightOffset={15}
show={false}
target={[Function]}
topOffset={-5}
/>
<Memo(ChannelPermissionGate)
channelId=""
permissions={
Array [
"add_reaction",
]
}
teamId="teamId"
>
<WithTooltip
title={
Object {
"defaultMessage": "Add a reaction",
"id": "reaction_list.addReactionTooltip",
}
}
>
<button
aria-label="Add a reaction"
className="Reaction"
onClick={[Function]}
>
<span
className="Reaction__add"
id="addReaction-post_id"
>
<AddReactionIcon />
</span>
</button>
</WithTooltip>
</Memo(ChannelPermissionGate)>
</span>
</div>
<AddReactionButton
onEmojiClick={[Function]}
post={
Object {
"channel_id": "",
"create_at": 0,
"delete_at": 0,
"edit_at": 0,
"hashtags": "",
"id": "post_id",
"is_pinned": false,
"message": "post message",
"metadata": Object {
"embeds": Array [],
"emojis": Array [],
"files": Array [],
"images": Object {},
"reactions": Array [],
},
"original_id": "",
"pending_post_id": "",
"props": Object {},
"reply_count": 0,
"root_id": "",
"type": "system_add_remove",
"update_at": 0,
"user_id": "user_id",
}
}
teamId="teamId"
/>
</div>
`;

View File

@ -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(
<AddReactionButton {...props}/>,
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();
});
});

View File

@ -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 (
<span className='emoji-picker__container'>
<ChannelPermissionGate
channelId={post.channel_id}
teamId={teamId}
permissions={[Permissions.ADD_REACTION]}
>
<WithTooltip title={ariaLabel}>
<button
id={`addReaction-${post.id}`}
ref={setReference}
aria-label={ariaLabel}
className={classNames('Reaction Reaction__add', {
'Reaction__add--open': showEmojiPicker,
})}
{...getReferenceProps()}
>
<AddReactionIcon/>
</button>
</WithTooltip>
</ChannelPermissionGate>
{emojiPicker}
</span>
);
}

View File

@ -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<Props, State> {
private addReactionButtonRef = React.createRef<HTMLButtonElement>();
constructor(props: Props) {
super(props);
this.state = {
emojiNames: [],
showEmojiPicker: false,
};
}
@ -89,25 +71,11 @@ export default class ReactionList extends React.PureComponent<Props, State> {
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<HTMLButtonElement, 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<Props, State> {
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 = (
<span className='emoji-picker__container'>
<EmojiPickerOverlay
show={this.state.showEmojiPicker}
target={this.getTarget}
onHide={this.hideEmojiPicker}
onEmojiClick={this.handleEmojiClick}
rightOffset={rightOffset}
topOffset={-5}
/>
<ChannelPermissionGate
channelId={this.props.post.channel_id}
teamId={this.props.teamId}
permissions={[Permissions.ADD_REACTION]}
>
<WithTooltip
title={messages.addAReaction}
>
<button
aria-label={localizeMessage({id: 'reaction.add.ariaLabel', defaultMessage: 'Add a reaction'})}
className='Reaction'
onClick={this.toggleEmojiPicker}
>
<span
id={`addReaction-${this.props.post.id}`}
className='Reaction__add'
ref={this.addReactionButtonRef}
>
<AddReactionIcon/>
</span>
</button>
</WithTooltip>
</ChannelPermissionGate>
</span>
addReaction = (
<AddReactionButton
post={this.props.post}
teamId={this.props.teamId}
onEmojiClick={this.handleEmojiClick}
/>
);
}
let addReactionClassName = 'post-add-reaction';
if (this.state.showEmojiPicker) {
addReactionClassName += ' post-add-reaction-emoji-picker-open';
}
return (
<div
aria-label={localizeMessage({id: 'reaction.container.ariaLabel', defaultMessage: 'reactions'})}
className='post-reaction-list'
>
{reactions}
<div className={addReactionClassName}>
{emojiPicker}
</div>
{addReaction}
</div>
);
}

View File

@ -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 <link>set a limit</link> to keep things running smoothly on your server. As a system administrator, you can adjust this limit from the <linkAdmin>system console</linkAdmin>.",
"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)",

View File

@ -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;

View File

@ -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;

View File

@ -202,12 +202,6 @@
opacity: 0.7;
}
.emoji-picker {
position: absolute;
top: -361px;
right: 0;
}
form {
padding: 0;
}

View File

@ -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);
}
}

View File

@ -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) {

View File

@ -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: [
{

View File

@ -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 {};
},
});
}

View File

@ -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');
});

View File

@ -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<typeof Overlay>['placement'];
if (targetBounds.top > spaceRequiredAbove) {
placement = 'top';
} else if (innerHeight - targetBounds.bottom > (spaceRequiredBelow || spaceRequiredAbove)) {
placement = 'bottom';
} else {
placement = horizontalPosition || 'left';
}
return placement;
}