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
Этот коммит содержится в:
Harrison Healey
2025-02-14 13:53:30 -05:00
коммит произвёл GitHub
родитель da7192246e
Коммит b6118b7701
34 изменённых файлов: 751 добавлений и 897 удалений

Просмотреть файл

@@ -193,7 +193,7 @@ describe('Keyboard shortcut CTRL/CMD+Shift+\\ for adding reaction to last messag
cy.get('#emojiPicker').should('exist'); cy.get('#emojiPicker').should('exist');
// # Click anywhere to close emoji picker // # Click anywhere to close emoji picker
cy.get('#channelHeaderInfo').click(); cy.get('body').click();
cy.get('#emojiPicker').should('not.exist'); cy.get('#emojiPicker').should('not.exist');
}); });

Просмотреть файл

@@ -64,6 +64,8 @@ describe('Edit Message', () => {
// * Assert channel autocomplete is not visible // * Assert channel autocomplete is not visible
cy.get('#suggestionList').should('not.exist'); cy.get('#suggestionList').should('not.exist');
cy.wait(TIMEOUTS.HALF_SEC);
// # In the modal click the emoji picker icon // # In the modal click the emoji picker icon
cy.get('div.post-edit__container button#emojiPickerButton').click(); cy.get('div.post-edit__container button#emojiPickerButton').click();

Просмотреть файл

@@ -77,7 +77,7 @@ import SendButton from './send_button';
import ShowFormat from './show_formatting'; import ShowFormat from './show_formatting';
import TexteditorActions from './texteditor_actions'; import TexteditorActions from './texteditor_actions';
import ToggleFormattingBar from './toggle_formatting_bar'; 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 useKeyHandler from './use_key_handler';
import useOrientationHandler from './use_orientation_handler'; import useOrientationHandler from './use_orientation_handler';
import usePluginItems from './use_plugin_items'; import usePluginItems from './use_plugin_items';
@@ -132,6 +132,24 @@ const AdvancedTextEditor = ({
const getDraftSelector = useMemo(makeGetDraft, []); const getDraftSelector = useMemo(makeGetDraft, []);
const getDisplayName = useMemo(makeGetDisplayName, []); 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 isRHS = Boolean(postId && !isThreadView);
const getFormattingBarPreferenceName = () => { const getFormattingBarPreferenceName = () => {
@@ -310,12 +328,20 @@ const AdvancedTextEditor = ({
isInEditMode, isInEditMode,
); );
const emojiPickerOffset = isInEditMode ? {right: 40} : undefined;
const { const {
emojiPicker, emojiPicker,
enableEmojiPicker, enableEmojiPicker,
toggleEmojiPicker, toggleEmojiPicker,
} = useEmojiPicker(isDisabled, draft, caretPosition, setCaretPosition, handleDraftChange, showPreview, focusTextbox, emojiPickerOffset); } = useEditorEmojiPicker(
textboxId,
isDisabled,
draft,
caretPosition,
setCaretPosition,
handleDraftChange,
showPreview,
focusTextbox,
);
const { const {
labels: priorityLabels, labels: priorityLabels,
additionalControl: priorityAdditionalControl, additionalControl: priorityAdditionalControl,
@@ -653,24 +679,6 @@ const AdvancedTextEditor = ({
const messageValue = isDisabled ? '' : draft.message_source || draft.message; 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(); const wasNotifiedOfLogIn = LocalStorageStore.getWasNotifiedOfLogIn();
let loginSuccessfulLabel; let loginSuccessfulLabel;

Просмотреть файл

@@ -1,8 +1,9 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. // Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information. // See LICENSE.txt for license information.
import {flip, offset, shift} from '@floating-ui/react';
import classNames from 'classnames'; import classNames from 'classnames';
import React, {useCallback, useRef, useState} from 'react'; import React, {useCallback, useState} from 'react';
import {useIntl} from 'react-intl'; import {useIntl} from 'react-intl';
import {useSelector} from 'react-redux'; 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 {getEmojiName} from 'mattermost-redux/utils/emoji_utils';
import useDidUpdate from 'components/common/hooks/useDidUpdate'; 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 KeyboardShortcutSequence, {KEYBOARD_SHORTCUTS} from 'components/keyboard_shortcuts/keyboard_shortcuts_sequence';
import WithTooltip from 'components/with_tooltip'; import WithTooltip from 'components/with_tooltip';
import {horizontallyWithin} from 'utils/floating';
import {splitMessageBasedOnCaretPosition} from 'utils/post_utils'; import {splitMessageBasedOnCaretPosition} from 'utils/post_utils';
import type {GlobalState} from 'types/store'; import type {GlobalState} from 'types/store';
@@ -24,7 +26,8 @@ import type {PostDraft} from 'types/store/draft';
import {IconContainer} from './formatting_bar/formatting_icon'; import {IconContainer} from './formatting_bar/formatting_icon';
const useEmojiPicker = ( const useEditorEmojiPicker = (
textboxId: string,
isDisabled: boolean, isDisabled: boolean,
draft: PostDraft, draft: PostDraft,
caretPosition: number, caretPosition: number,
@@ -32,15 +35,12 @@ const useEmojiPicker = (
handleDraftChange: (draft: PostDraft) => void, handleDraftChange: (draft: PostDraft) => void,
shouldShowPreview: boolean, shouldShowPreview: boolean,
focusTextbox: () => void, focusTextbox: () => void,
emojiPickerOffset?: {right?: number},
) => { ) => {
const intl = useIntl(); const intl = useIntl();
const enableEmojiPicker = useSelector((state: GlobalState) => getConfig(state).EnableEmojiPicker === 'true'); const enableEmojiPicker = useSelector((state: GlobalState) => getConfig(state).EnableEmojiPicker === 'true');
const enableGifPicker = useSelector((state: GlobalState) => getConfig(state).EnableGifPicker === 'true'); const enableGifPicker = useSelector((state: GlobalState) => getConfig(state).EnableGifPicker === 'true');
const emojiPickerRef = useRef<HTMLButtonElement>(null);
const [showEmojiPicker, setShowEmojiPicker] = useState(false); const [showEmojiPicker, setShowEmojiPicker] = useState(false);
const toggleEmojiPicker = useCallback((e?: React.MouseEvent<HTMLButtonElement, MouseEvent>): void => { const toggleEmojiPicker = useCallback((e?: React.MouseEvent<HTMLButtonElement, MouseEvent>): void => {
@@ -48,14 +48,6 @@ const useEmojiPicker = (
setShowEmojiPicker((prev) => !prev); setShowEmojiPicker((prev) => !prev);
}, []); }, []);
const hideEmojiPicker = useCallback(() => {
setShowEmojiPicker(false);
}, []);
const getEmojiPickerRef = useCallback(() => {
return emojiPickerRef.current;
}, []);
const handleEmojiClick = useCallback((emoji: Emoji) => { const handleEmojiClick = useCallback((emoji: Emoji) => {
const emojiAlias = getEmojiName(emoji); const emojiAlias = getEmojiName(emoji);
@@ -111,25 +103,41 @@ const useEmojiPicker = (
// Focus textbox when the emoji picker closes // Focus textbox when the emoji picker closes
useDidUpdate(() => { useDidUpdate(() => {
if (!showEmojiPicker) { if (!showEmojiPicker) {
focusTextbox(); // Wait a frame to let the emoji picker's focus trap disappear before changing focus
requestAnimationFrame(() => {
focusTextbox();
});
} }
}, [showEmojiPicker]); }, [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) { if (enableEmojiPicker && !isDisabled) {
emojiPicker = ( emojiPickerControls = (
<> <>
<EmojiPickerOverlay
show={showEmojiPicker}
target={getEmojiPickerRef}
onHide={hideEmojiPicker}
onEmojiClick={handleEmojiClick}
onGifClick={handleGifClick}
enableGifPicker={enableGifPicker}
topOffset={-7}
rightOffset={emojiPickerOffset?.right}
/>
<WithTooltip <WithTooltip
title={ title={
<KeyboardShortcutSequence <KeyboardShortcutSequence
@@ -141,12 +149,13 @@ const useEmojiPicker = (
> >
<IconContainer <IconContainer
id={'emojiPickerButton'} id={'emojiPickerButton'}
ref={emojiPickerRef} ref={setReference}
onClick={toggleEmojiPicker} onClick={toggleEmojiPicker}
type='button' type='button'
aria-label={intl.formatMessage({id: 'emoji_picker.emojiPicker.button.ariaLabel', defaultMessage: 'select an emoji'})} aria-label={intl.formatMessage({id: 'emoji_picker.emojiPicker.button.ariaLabel', defaultMessage: 'select an emoji'})}
disabled={shouldShowPreview} disabled={shouldShowPreview}
className={classNames({active: showEmojiPicker})} className={classNames({active: showEmojiPicker})}
{...getReferenceProps()}
> >
<EmoticonHappyOutlineIcon <EmoticonHappyOutlineIcon
color={'currentColor'} color={'currentColor'}
@@ -154,11 +163,12 @@ const useEmojiPicker = (
/> />
</IconContainer> </IconContainer>
</WithTooltip> </WithTooltip>
{emojiPicker}
</> </>
); );
} }
return {emojiPicker, enableEmojiPicker, toggleEmojiPicker}; return {emojiPicker: emojiPickerControls, enableEmojiPicker, toggleEmojiPicker};
}; };
export default useEmojiPicker; export default useEditorEmojiPicker;

Просмотреть файл

@@ -11,7 +11,7 @@ import type {ChannelBookmark} from '@mattermost/types/channel_bookmarks';
import type {Emoji} from '@mattermost/types/emojis'; import type {Emoji} from '@mattermost/types/emojis';
import type {FileInfo} from '@mattermost/types/files'; 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 Input from 'components/widgets/inputs/input/input';
import Constants, {A11yCustomEventTypes, type A11yFocusEventDetail} from 'utils/constants'; import Constants, {A11yCustomEventTypes, type A11yFocusEventDetail} from 'utils/constants';
@@ -50,7 +50,6 @@ const CreateModalNameInput = ({
const {formatMessage} = useIntl(); const {formatMessage} = useIntl();
const targetRef = useRef<HTMLButtonElement>(null); const targetRef = useRef<HTMLButtonElement>(null);
const getTargetRef = () => targetRef.current;
const icon = ( const icon = (
<BookmarkIcon <BookmarkIcon
@@ -90,11 +89,6 @@ const CreateModalNameInput = ({
setEmoji(''); setEmoji('');
}; };
const handleEmojiClose = () => {
setShowEmojiPicker(false);
refocusEmojiButton();
};
const handleInputChange: ComponentProps<typeof Input>['onChange'] = useCallback((e) => { const handleInputChange: ComponentProps<typeof Input>['onChange'] = useCallback((e) => {
setDisplayName(e.currentTarget.value); setDisplayName(e.currentTarget.value);
}, []); }, []);
@@ -112,33 +106,34 @@ const CreateModalNameInput = ({
} }
}; };
const {
emojiPicker,
getReferenceProps,
setReference,
} = useEmojiPicker({
showEmojiPicker,
setShowEmojiPicker,
onAddCustomEmojiClick,
onEmojiClick: handleEmojiClick,
});
return ( return (
<> <>
<NameWrapper> <NameWrapper>
{showEmojiPicker && (
<EmojiPickerOverlay
target={getTargetRef}
show={showEmojiPicker}
onHide={handleEmojiClose}
onEmojiClick={handleEmojiClick}
placement='right'
onAddCustomEmojiClick={onAddCustomEmojiClick}
/>
)}
<button <button
ref={targetRef} ref={setReference}
type='button' type='button'
onClick={toggleEmojiPicker} onClick={toggleEmojiPicker}
onKeyDown={handleEmojiKeyDown} onKeyDown={handleEmojiKeyDown}
aria-label={formatMessage({id: 'emoji_picker.emojiPicker.button.ariaLabel', defaultMessage: 'select an emoji'})} 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' className='channelBookmarksMenuButton emoji-picker__container BookmarkCreateModal__emoji-button'
{...getReferenceProps()}
> >
{icon} {icon}
<ChevronDownIcon size={'12px'}/> <ChevronDownIcon size={'12px'}/>
</button> </button>
{emojiPicker}
<Input <Input
maxLength={maxLength} maxLength={maxLength}
type='text' type='text'

Просмотреть файл

@@ -4,7 +4,7 @@
import classNames from 'classnames'; import classNames from 'classnames';
import type {Moment} from 'moment-timezone'; import type {Moment} from 'moment-timezone';
import 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 type {MessageDescriptor} from 'react-intl';
import {FormattedMessage, defineMessage, useIntl} from 'react-intl'; import {FormattedMessage, defineMessage, useIntl} from 'react-intl';
import {useDispatch, useSelector} from 'react-redux'; 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 DateTimeInput, {getRoundedTime} from 'components/custom_status/date_time_input';
import ExpiryMenu from 'components/custom_status/expiry_menu'; import ExpiryMenu from 'components/custom_status/expiry_menu';
import RenderEmoji from 'components/emoji/render_emoji'; 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 QuickInput, {MaxLengthInput} from 'components/quick_input';
import EmojiIcon from 'components/widgets/icons/emoji_icon'; import EmojiIcon from 'components/widgets/icons/emoji_icon';
import {A11yCustomEventTypes, Constants, ModalIdentifiers} from 'utils/constants'; import {Constants, ModalIdentifiers} from 'utils/constants';
import type {A11yFocusEventDetail} from 'utils/constants';
import {isKeyPressed} from 'utils/keyboard'; import {isKeyPressed} from 'utils/keyboard';
import {getCurrentMomentForTimezone} from 'utils/timezone'; import {getCurrentMomentForTimezone} from 'utils/timezone';
@@ -49,7 +48,6 @@ type Props = {
// This is the same limit set // This is the same limit set
// https://github.com/mattermost/mattermost-server/pull/16835/files#diff-73c61af5954b16f5e3cb5ee786af9eb698f660eff0d65db5556949be5fb6e60bR15 // https://github.com/mattermost/mattermost-server/pull/16835/files#diff-73c61af5954b16f5e3cb5ee786af9eb698f660eff0d65db5556949be5fb6e60bR15
const CUSTOM_STATUS_TEXT_CHARACTER_LIMIT = 100; const CUSTOM_STATUS_TEXT_CHARACTER_LIMIT = 100;
const EMOJI_PICKER_WIDTH_OFFSET = 308;
type DefaultUserCustomStatus = { type DefaultUserCustomStatus = {
emoji: string; emoji: string;
@@ -118,8 +116,6 @@ const CustomStatusModal: React.FC<Props> = (props: Props) => {
const currentCustomStatus = useSelector(getCustomStatus); const currentCustomStatus = useSelector(getCustomStatus);
const customStatusExpired = useSelector((state: GlobalState) => isCustomStatusExpired(state, currentCustomStatus)); const customStatusExpired = useSelector((state: GlobalState) => isCustomStatusExpired(state, currentCustomStatus));
const recentCustomStatuses = useSelector(getRecentCustomStatuses); const recentCustomStatuses = useSelector(getRecentCustomStatuses);
const customStatusControlRef = useRef<HTMLDivElement>(null);
const emojiButtonRef = useRef<HTMLButtonElement>(null);
const {formatMessage} = useIntl(); const {formatMessage} = useIntl();
const isCurrentCustomStatusSet = !customStatusExpired && (currentCustomStatus?.text || currentCustomStatus?.emoji); const isCurrentCustomStatusSet = !customStatusExpired && (currentCustomStatus?.text || currentCustomStatus?.emoji);
const [showEmojiPicker, setShowEmojiPicker] = useState<boolean>(false); const [showEmojiPicker, setShowEmojiPicker] = useState<boolean>(false);
@@ -230,54 +226,10 @@ const CustomStatusModal: React.FC<Props> = (props: Props) => {
const handleClearStatus = isCurrentCustomStatusSet ? () => dispatch(unsetCustomStatus()) : undefined; 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) => { const handleEmojiClick = (selectedEmoji: Emoji) => {
setShowEmojiPicker(false); setShowEmojiPicker(false);
const emojiName = ('short_name' in selectedEmoji) ? selectedEmoji.short_name : selectedEmoji.name; const emojiName = ('short_name' in selectedEmoji) ? selectedEmoji.short_name : selectedEmoji.name;
setEmoji(emojiName); 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); 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'}/>; ) : <EmojiIcon className={'icon icon--emoji'}/>;
const {
emojiPicker,
getReferenceProps,
setReference,
} = useEmojiPicker({
showEmojiPicker,
setShowEmojiPicker,
onEmojiClick: handleEmojiClick,
});
const clearHandle = () => { const clearHandle = () => {
setEmoji(''); setEmoji('');
setText(''); setText('');
@@ -303,19 +266,6 @@ const CustomStatusModal: React.FC<Props> = (props: Props) => {
setDuration(status.duration || DONT_CLEAR); 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 = ( const recentStatuses = (
<div id='statusSuggestion__recents'> <div id='statusSuggestion__recents'>
<div className='statusSuggestion__title'> <div className='statusSuggestion__title'>
@@ -425,34 +375,19 @@ const CustomStatusModal: React.FC<Props> = (props: Props) => {
> >
<div className='StatusModal__body'> <div className='StatusModal__body'>
<div className='StatusModal__input'> <div className='StatusModal__input'>
<div <div className='StatusModal__emoji-container'>
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}
/>
)}
<button <button
type='button' type='button'
onClick={toggleEmojiPicker} ref={setReference}
ref={emojiButtonRef}
aria-label={formatMessage({id: 'emoji_picker.emojiPicker.button.ariaLabel', defaultMessage: 'select an emoji'})} aria-label={formatMessage({id: 'emoji_picker.emojiPicker.button.ariaLabel', defaultMessage: 'select an emoji'})}
className={classNames('emoji-picker__container', 'StatusModal__emoji-button', { className={classNames('emoji-picker__container', 'StatusModal__emoji-button', {
'StatusModal__emoji-button--active': showEmojiPicker, 'StatusModal__emoji-button--active': showEmojiPicker,
})} })}
{...getReferenceProps()}
> >
{customStatusEmoji} {customStatusEmoji}
</button> </button>
{emojiPicker}
</div> </div>
<QuickInput <QuickInput
inputComponent={MaxLengthInput} inputComponent={MaxLengthInput}

Просмотреть файл

@@ -67,7 +67,7 @@ type Props = {
isFlagged?: boolean; isFlagged?: boolean;
handleCommentClick?: React.EventHandler<any>; handleCommentClick?: React.EventHandler<any>;
handleDropdownOpened: (open: boolean) => void; handleDropdownOpened: (open: boolean) => void;
handleAddReactionClick?: () => void; handleAddReactionClick?: (showEmojiPicker: boolean) => void;
isMenuOpen?: boolean; isMenuOpen?: boolean;
isReadOnly?: boolean; isReadOnly?: boolean;
isLicensed?: boolean; // TechDebt: Made non-mandatory while converting to typescript isLicensed?: boolean; // TechDebt: Made non-mandatory while converting to typescript
@@ -209,9 +209,8 @@ export class DotMenuClass extends React.PureComponent<Props, State> {
}; };
handleAddReactionMenuItemActivated = () => { handleAddReactionMenuItemActivated = () => {
// to be safe, make sure the handler function has been defined
if (this.props.handleAddReactionClick) { if (this.props.handleAddReactionClick) {
this.props.handleAddReactionClick(); this.props.handleAddReactionClick(true);
} }
}; };

Просмотреть файл

@@ -44,14 +44,6 @@ import DotMenu from './dot_menu';
type Props = { type Props = {
post: Post; 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']; location?: ComponentProps<typeof DotMenu>['location'];
}; };

Просмотреть файл

@@ -20,9 +20,8 @@ import {openModal} from 'actions/views/modals';
import {getConnectionId} from 'selectors/general'; import {getConnectionId} from 'selectors/general';
import DeletePostModal from 'components/delete_post_modal'; import DeletePostModal from 'components/delete_post_modal';
import DeleteScheduledPostModal import DeleteScheduledPostModal from 'components/drafts/draft_actions/schedule_post_actions/delete_scheduled_post_modal';
from 'components/drafts/draft_actions/schedule_post_actions/delete_scheduled_post_modal'; import useEmojiPicker from 'components/emoji_picker/use_emoji_picker';
import EmojiPickerOverlay from 'components/emoji_picker/emoji_picker_overlay';
import Textbox from 'components/textbox'; import Textbox from 'components/textbox';
import type {TextboxClass, TextboxElement} from 'components/textbox'; import type {TextboxClass, TextboxElement} from 'components/textbox';
@@ -102,9 +101,6 @@ export type State = {
const {KeyCodes} = Constants; 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 EditPost = ({editingPost, actions, canEditPost, config, channelId, draft, scheduledPost, afterSave, onCancel, onDeleteScheduledPost, ...rest}: Props): JSX.Element | null => {
const connectionId = useSelector(getConnectionId); const connectionId = useSelector(getConnectionId);
const channel = useSelector((state: GlobalState) => getChannel(state, channelId)); 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 [showMentionHelper, setShowMentionHelper] = useState<boolean>(false);
const textboxRef = useRef<TextboxClass>(null); const textboxRef = useRef<TextboxClass>(null);
const emojiButtonRef = useRef<HTMLButtonElement>(null);
const wrapperRef = useRef<HTMLDivElement>(null); const wrapperRef = useRef<HTMLDivElement>(null);
// using a ref here makes sure that the unmounting callback (saveDraft) is fired with the correct value. // 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) => { const handleEmojiClick = (emoji?: Emoji) => {
if (!emoji) { if (!emoji) {
return; 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') { 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 <button
aria-label={formatMessage({id: 'emoji_picker.emojiPicker.button.ariaLabel', defaultMessage: 'select an emoji'})} aria-label={formatMessage({id: 'emoji_picker.emojiPicker.button.ariaLabel', defaultMessage: 'select an emoji'})}
id='editPostEmoji' id='editPostEmoji'
ref={emojiButtonRef} ref={setReference}
className='style--none post-action' className='style--none post-action'
onClick={toggleEmojiPicker} onClick={toggleEmojiPicker}
{...getReferenceProps()}
> >
<EmoticonPlusOutlineIcon <EmoticonPlusOutlineIcon
size={18} size={18}
color='currentColor' color='currentColor'
/> />
</button> </button>
{emojiPicker}
</> </>
); );
} }
@@ -637,7 +629,7 @@ const EditPost = ({editingPost, actions, canEditPost, config, channelId, draft,
useChannelMentions={rest.useChannelMentions} useChannelMentions={rest.useChannelMentions}
/> />
<div className='post-body__actions'> <div className='post-body__actions'>
{emojiPicker} {emojiPickerControls}
</div> </div>
{ showMentionHelper ? ( { showMentionHelper ? (
<div className='post-body__info'> <div className='post-body__info'>

Просмотреть файл

@@ -27,7 +27,7 @@ exports[`components/emoji_picker/EmojiPicker should match snapshot 1`] = `
class="emoji-picker__search" class="emoji-picker__search"
data-testid="emojiInputSearch" data-testid="emojiInputSearch"
id="emojiPickerSearch" id="emojiPickerSearch"
placeholder="Search Emoji" placeholder="Search emojis"
type="text" type="text"
value="" value=""
/> />

Просмотреть файл

@@ -138,7 +138,7 @@ const EmojiPickerSearch = forwardRef<HTMLInputElement, Props>(({value, cursorCat
onChange={handleChange} onChange={handleChange}
onKeyDown={handleKeyDown} onKeyDown={handleKeyDown}
autoComplete='off' autoComplete='off'
placeholder={formatMessage({id: 'emoji_picker.search', defaultMessage: 'Search Emoji'})} placeholder={formatMessage({id: 'emoji_picker.search', defaultMessage: 'Search emojis'})}
value={value} value={value}
/> />
</div> </div>

Просмотреть файл

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

Просмотреть файл

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

Просмотреть файл

@@ -1,12 +1,9 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. // Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information. // See LICENSE.txt for license information.
import classNames from 'classnames'; import React, {useCallback, useRef, useState} from 'react';
import type {CSSProperties, RefObject} from 'react';
import React, {PureComponent, createRef} from 'react';
import {Tab, Tabs} from 'react-bootstrap'; import {Tab, Tabs} from 'react-bootstrap';
import type {WrappedComponentProps} from 'react-intl'; import {FormattedMessage, useIntl} from 'react-intl';
import {injectIntl, FormattedMessage} from 'react-intl';
import type {Emoji} from '@mattermost/types/emojis'; 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'))); const GifPicker = makeAsyncComponent('GifPicker', React.lazy(() => import('components/gif_picker/gif_picker')));
export interface Props extends WrappedComponentProps { export interface Props {
style?: CSSProperties;
rightOffset?: number;
topOffset?: number;
leftOffset?: number;
placement?: ('top' | 'bottom' | 'left' | 'right');
onEmojiClose: () => void; onEmojiClose: () => void;
onEmojiClick: (emoji: Emoji) => void; onEmojiClick: (emoji: Emoji) => void;
onGifClick?: (gif: string) => void; onGifClick?: (gif: string) => void;
@@ -31,173 +23,104 @@ export interface Props extends WrappedComponentProps {
enableGifPicker?: boolean; enableGifPicker?: boolean;
} }
type State = { export default function EmojiPickerTabs(props: Props) {
emojiTabVisible: boolean; const intl = useIntl();
filter: string;
activeKey: number;
}
class EmojiPickerTabs extends PureComponent<Props, State> { const [activeKey, setActiveKey] = useState(1);
private rootPickerNodeRef: RefObject<HTMLDivElement>; const [filter, setFilter] = useState('');
static defaultProps = { const rootPickerNodeRef = useRef<HTMLDivElement>(null);
rightOffset: 0, const getRootPickerNode = useCallback(() => rootPickerNodeRef.current, []);
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>
);
}
if (props.enableGifPicker && typeof props.onGifClick != 'undefined') {
return ( return (
<div <div
id='emojiPicker' id='emojiGifPicker'
style={pickerStyle} ref={rootPickerNodeRef}
className={classNames('a11y__popup', 'emoji-picker', 'emoji-picker--single', { className='a11y__popup emoji-picker'
bottom: this.props.placement === 'bottom',
})}
role='dialog' role='dialog'
aria-label={ 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'})}
intl.formatMessage({id: 'emoji_gif_picker.dialog.emojis', defaultMessage: 'Emoji Picker'})}
aria-modal='true' aria-modal='true'
> >
<EmojiPickerHeader handleEmojiPickerClose={this.handleEmojiPickerClose}/> <Tabs
<EmojiPicker id='emoji-picker-tabs'
filter={this.state.filter} defaultActiveKey={1}
onEmojiClick={this.props.onEmojiClick} justified={true}
handleFilterChange={this.handleFilterChange} mountOnEnter={true}
handleEmojiPickerClose={this.handleEmojiPickerClose} unmountOnExit={true}
onAddCustomEmojiClick={this.props.onAddCustomEmojiClick} 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> </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>
);
}

Просмотреть файл

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

Просмотреть файл

@@ -59,27 +59,35 @@ type Props = {
}; };
const PostOptions = (props: Props): JSX.Element => { const PostOptions = (props: Props): JSX.Element => {
const dotMenuRef = useRef<HTMLUListElement>(null);
const [showEmojiPicker, setShowEmojiPicker] = useState(false); const [showEmojiPicker, setShowEmojiPicker] = useState(false);
const [showDotMenu, setShowDotMenu] = useState(false); const [showDotMenu, setShowDotMenu] = useState(false);
const [showActionsMenu, setShowActionsMenu] = useState(false); const [showActionsMenu, setShowActionsMenu] = useState(false);
const toggleEmojiPicker = useCallback(() => { const toggleEmojiPicker = useCallback((show: boolean) => {
setShowEmojiPicker(!showEmojiPicker); setShowEmojiPicker(show);
props.handleDropdownOpened!(!showEmojiPicker); props.handleDropdownOpened!(show);
}, [props.handleDropdownOpened, showEmojiPicker]); }, [props.handleDropdownOpened]);
const lastEmittedFrom = useRef(props.shortcutReactToLastPostEmittedFrom);
useEffect(() => { 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; const locationToUse = props.location === 'RHS_COMMENT' ? Locations.RHS_ROOT : props.location;
if (props.isLastPost && if (props.isLastPost &&
(props.shortcutReactToLastPostEmittedFrom === locationToUse) && (props.shortcutReactToLastPostEmittedFrom === locationToUse) &&
props.isPostHeaderVisible) { props.isPostHeaderVisible) {
toggleEmojiPicker();
props.actions.emitShortcutReactToLastPostFrom(Locations.NO_WHERE); props.actions.emitShortcutReactToLastPostFrom(Locations.NO_WHERE);
toggleEmojiPicker(!showEmojiPicker);
} }
// eslint-disable-next-line react-hooks/exhaustive-deps // 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 { const {
channelIsArchived, channelIsArchived,
@@ -108,8 +116,6 @@ const PostOptions = (props: Props): JSX.Element => {
props.handleDropdownOpened!(open); props.handleDropdownOpened!(open);
}; };
const getDotMenuRef = () => dotMenuRef.current;
const isPostDeleted = post && post.state === Posts.POST_DELETED; const isPostDeleted = post && post.state === Posts.POST_DELETED;
const hoverLocal = props.hover || showEmojiPicker || showDotMenu || showActionsMenu; const hoverLocal = props.hover || showEmojiPicker || showDotMenu || showActionsMenu;
const showCommentIcon = isFromAutoResponder || (!systemMessage && (isMobileView || const showCommentIcon = isFromAutoResponder || (!systemMessage && (isMobileView ||
@@ -160,9 +166,8 @@ const PostOptions = (props: Props): JSX.Element => {
location={props.location} location={props.location}
postId={post.id} postId={post.id}
teamId={props.teamId} teamId={props.teamId}
getDotMenuRef={getDotMenuRef}
showEmojiPicker={showEmojiPicker} showEmojiPicker={showEmojiPicker}
toggleEmojiPicker={toggleEmojiPicker} setShowEmojiPicker={toggleEmojiPicker}
/> />
</li> </li>
); );
@@ -278,7 +283,6 @@ const PostOptions = (props: Props): JSX.Element => {
} else if (!props.isPostBeingEdited) { } else if (!props.isPostBeingEdited) {
options = ( options = (
<ul <ul
ref={dotMenuRef}
data-testid={`post-menu-${props.post.id}`} data-testid={`post-menu-${props.post.id}`}
className={classnames('col post-menu', {'post-menu--position': !hoverLocal && showCommentIcon})} className={classnames('col post-menu', {'post-menu--position': !hoverLocal && showCommentIcon})}
> >

Просмотреть файл

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

Просмотреть файл

@@ -3,10 +3,12 @@
import React from 'react'; 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 {TestHelper} from 'utils/test_helper';
import PostReaction, {type PostReaction as PostReactionComponent} from './post_reaction'; import PostReaction from './post_reaction';
describe('components/post_view/PostReaction', () => { describe('components/post_view/PostReaction', () => {
const baseProps = { const baseProps = {
@@ -16,24 +18,62 @@ describe('components/post_view/PostReaction', () => {
getDotMenuRef: jest.fn(), getDotMenuRef: jest.fn(),
showIcon: false, showIcon: false,
showEmojiPicker: false, showEmojiPicker: false,
toggleEmojiPicker: jest.fn(), setShowEmojiPicker: jest.fn(),
actions: { actions: {
toggleReaction: jest.fn(), toggleReaction: jest.fn(),
}, },
}; };
test('should match snapshot', () => { const userId = 'userId';
const wrapper = shallowWithIntl(<PostReaction {...baseProps}/>); const initialState = {
expect(wrapper).toMatchSnapshot(); 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', () => { test('should toggle the reaction and close the emoji picker when an emoji is selected', async () => {
const wrapper = shallowWithIntl(<PostReaction {...baseProps}/>); renderWithContext(
const instance = wrapper.instance() as PostReactionComponent; <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).toHaveBeenCalledTimes(1);
expect(baseProps.actions.toggleReaction).toHaveBeenCalledWith('post_id_1', 'smile'); expect(baseProps.actions.toggleReaction).toHaveBeenCalledWith('post_id_1', 'grinning');
expect(baseProps.toggleEmojiPicker).toHaveBeenCalledTimes(1); expect(baseProps.setShowEmojiPicker).toHaveBeenCalledWith(false);
}); });
}); });

Просмотреть файл

@@ -2,114 +2,87 @@
// See LICENSE.txt for license information. // See LICENSE.txt for license information.
import classNames from 'classnames'; import classNames from 'classnames';
import React from 'react'; import React, {useCallback} from 'react';
import type {WrappedComponentProps} from 'react-intl'; import {useIntl} from 'react-intl';
import {defineMessages, injectIntl} from 'react-intl';
import type {Emoji} from '@mattermost/types/emojis'; import type {Emoji} from '@mattermost/types/emojis';
import Permissions from 'mattermost-redux/constants/permissions'; import Permissions from 'mattermost-redux/constants/permissions';
import {getEmojiName} from 'mattermost-redux/utils/emoji_utils'; 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 ChannelPermissionGate from 'components/permissions_gates/channel_permission_gate';
import EmojiIcon from 'components/widgets/icons/emoji_icon'; import EmojiIcon from 'components/widgets/icons/emoji_icon';
import WithTooltip from 'components/with_tooltip'; import WithTooltip from 'components/with_tooltip';
import {Locations} from 'utils/constants'; import {Locations} from 'utils/constants';
const TOP_OFFSET = -7; export type Props = {
const messages = defineMessages({
addReaction: {
id: 'post_info.tooltip.add_reactions',
defaultMessage: 'Add Reaction',
},
});
export type Props = WrappedComponentProps & {
channelId?: string; channelId?: string;
postId: string; postId: string;
teamId: string; teamId: string;
getDotMenuRef: () => HTMLUListElement | null;
location?: keyof typeof Locations; location?: keyof typeof Locations;
setShowEmojiPicker: (showEmojiPicker: boolean) => void;
showEmojiPicker: boolean; showEmojiPicker: boolean;
toggleEmojiPicker: (e?: React.MouseEvent<HTMLButtonElement, MouseEvent>) => void;
actions: { actions: {
toggleReaction: (postId: string, emojiName: string) => void; toggleReaction: (postId: string, emojiName: string) => void;
}; };
} }
type State = { export default function PostReaction({
location: keyof typeof Locations; channelId,
showEmojiPicker: boolean; location = Locations.CENTER,
} postId,
teamId,
showEmojiPicker,
setShowEmojiPicker,
actions: {
toggleReaction,
},
}: Props) {
const intl = useIntl();
export class PostReaction extends React.PureComponent<Props, State> { const handleEmojiClick = useCallback((emoji: Emoji) => {
public static defaultProps: Partial<Props> = {
location: Locations.CENTER as 'CENTER',
showEmojiPicker: false,
};
handleToggleEmoji = (emoji: Emoji): void => {
this.setState({showEmojiPicker: false});
const emojiName = getEmojiName(emoji); const emojiName = getEmojiName(emoji);
this.props.actions.toggleReaction(this.props.postId, emojiName); toggleReaction(postId, emojiName);
this.props.toggleEmojiPicker();
};
render() { setShowEmojiPicker(false);
const { }, [postId, setShowEmojiPicker, toggleReaction]);
channelId,
location,
postId,
showEmojiPicker,
teamId,
intl,
} = this.props;
let spaceRequiredAbove; const {
let spaceRequiredBelow; emojiPicker,
if (location === Locations.RHS_ROOT || location === Locations.RHS_COMMENT) { getReferenceProps,
spaceRequiredAbove = EmojiPickerOverlay.RHS_SPACE_REQUIRED_ABOVE; setReference,
spaceRequiredBelow = EmojiPickerOverlay.RHS_SPACE_REQUIRED_BELOW; } = useEmojiPicker({
} showEmojiPicker,
setShowEmojiPicker,
return ( onEmojiClick: handleEmojiClick,
<ChannelPermissionGate });
channelId={channelId}
teamId={teamId} const ariaLabel = intl.formatMessage({id: 'post_info.tooltip.add_reactions', defaultMessage: 'Add Reaction'});
permissions={[Permissions.ADD_REACTION]}
> return (
<> <ChannelPermissionGate
<EmojiPickerOverlay channelId={channelId}
show={showEmojiPicker} teamId={teamId}
target={this.props.getDotMenuRef} permissions={[Permissions.ADD_REACTION]}
onHide={this.props.toggleEmojiPicker} >
onEmojiClick={this.handleToggleEmoji} <WithTooltip title={ariaLabel}>
topOffset={TOP_OFFSET} <button
spaceRequiredAbove={spaceRequiredAbove} ref={setReference}
spaceRequiredBelow={spaceRequiredBelow} data-testid='post-reaction-emoji-icon'
/> id={`${location}_reaction_${postId}`}
<WithTooltip aria-label={ariaLabel}
title={messages.addReaction} className={classNames('post-menu__item', 'post-menu__item--reactions', {
> 'post-menu__item--active': showEmojiPicker,
<button })}
data-testid='post-reaction-emoji-icon' {...getReferenceProps()}
id={`${location}_reaction_${postId}`} >
aria-label={intl.formatMessage({id: 'post_info.tooltip.add_reactions', defaultMessage: 'Add Reaction'})} <EmojiIcon className='icon icon--small'/>
className={classNames('post-menu__item', 'post-menu__item--reactions', { </button>
'post-menu__item--active': showEmojiPicker, </WithTooltip>
})} {emojiPicker}
onClick={this.props.toggleEmojiPicker} </ChannelPermissionGate>
> );
<EmojiIcon className='icon icon--small'/>
</button>
</WithTooltip>
</>
</ChannelPermissionGate>
);
}
} }
export default injectIntl(PostReaction);

Просмотреть файл

@@ -56,11 +56,19 @@
cursor: default; cursor: default;
} }
&__add { &.Reaction__add {
position: relative; position: relative;
font-size: 20px; font-size: 20px;
line-height: 0; line-height: 0;
vertical-align: middle; 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 { &__emoji {

Просмотреть файл

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

Просмотреть файл

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

Просмотреть файл

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

Просмотреть файл

@@ -2,32 +2,18 @@
// See LICENSE.txt for license information. // See LICENSE.txt for license information.
import React from 'react'; import React from 'react';
import {defineMessages} from 'react-intl';
import type {Emoji} from '@mattermost/types/emojis'; import type {Emoji} from '@mattermost/types/emojis';
import type {Post} from '@mattermost/types/posts'; import type {Post} from '@mattermost/types/posts';
import type {Reaction as ReactionType} from '@mattermost/types/reactions'; 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 {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 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'; import {localizeMessage} from 'utils/utils';
const DEFAULT_EMOJI_PICKER_RIGHT_OFFSET = 15; import AddReactionButton from './add_reaction_button';
const EMOJI_PICKER_WIDTH_OFFSET = 260;
const messages = defineMessages({
addAReaction: {
id: 'reaction_list.addReactionTooltip',
defaultMessage: 'Add a reaction',
},
});
type Props = { type Props = {
@@ -62,18 +48,14 @@ type Props = {
type State = { type State = {
emojiNames: string[]; emojiNames: string[];
showEmojiPicker: boolean;
}; };
export default class ReactionList extends React.PureComponent<Props, State> { export default class ReactionList extends React.PureComponent<Props, State> {
private addReactionButtonRef = React.createRef<HTMLButtonElement>();
constructor(props: Props) { constructor(props: Props) {
super(props); super(props);
this.state = { this.state = {
emojiNames: [], emojiNames: [],
showEmojiPicker: false,
}; };
} }
@@ -89,25 +71,11 @@ export default class ReactionList extends React.PureComponent<Props, State> {
return (emojiNames === state.emojiNames) ? null : {emojiNames}; return (emojiNames === state.emojiNames) ? null : {emojiNames};
} }
getTarget = (): HTMLButtonElement | null => {
return this.addReactionButtonRef.current;
};
handleEmojiClick = (emoji: Emoji): void => { handleEmojiClick = (emoji: Emoji): void => {
this.setState({showEmojiPicker: false});
const emojiName = getEmojiName(emoji); const emojiName = getEmojiName(emoji);
this.props.actions.toggleReaction(this.props.post.id, emojiName); 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 { render(): React.ReactNode {
const reactionsByName = new Map(); const reactionsByName = new Map();
@@ -141,69 +109,24 @@ export default class ReactionList extends React.PureComponent<Props, State> {
return null; return null;
}); });
const addReactionButton = this.getTarget(); let addReaction = null;
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;
if (this.props.canAddReactions) { if (this.props.canAddReactions) {
emojiPicker = ( addReaction = (
<span className='emoji-picker__container'> <AddReactionButton
<EmojiPickerOverlay post={this.props.post}
show={this.state.showEmojiPicker} teamId={this.props.teamId}
target={this.getTarget} onEmojiClick={this.handleEmojiClick}
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>
); );
} }
let addReactionClassName = 'post-add-reaction';
if (this.state.showEmojiPicker) {
addReactionClassName += ' post-add-reaction-emoji-picker-open';
}
return ( return (
<div <div
aria-label={localizeMessage({id: 'reaction.container.ariaLabel', defaultMessage: 'reactions'})} aria-label={localizeMessage({id: 'reaction.container.ariaLabel', defaultMessage: 'reactions'})}
className='post-reaction-list' className='post-reaction-list'
> >
{reactions} {reactions}
<div className={addReactionClassName}> {addReaction}
{emojiPicker}
</div>
</div> </div>
); );
} }

Просмотреть файл

@@ -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": "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.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_limit_reached_modal.title": "You've reached the reaction limit",
"reaction_list.addReactionTooltip": "Add a reaction",
"reaction.add.ariaLabel": "Add a reaction", "reaction.add.ariaLabel": "Add a reaction",
"reaction.clickToAdd": "(click to add)", "reaction.clickToAdd": "(click to add)",
"reaction.clickToRemove": "(click to remove)", "reaction.clickToRemove": "(click to remove)",

Просмотреть файл

@@ -1,3 +1,5 @@
@use "utils/variables";
.emoji-picker { .emoji-picker {
pointer-events: auto; pointer-events: auto;
@@ -49,6 +51,11 @@
} }
} }
.emoji-picker-overlay {
z-index: variables.$z-index-popover;
pointer-events: none;
}
.custom-emoji-tab__icon__text { .custom-emoji-tab__icon__text {
display: flex; display: flex;
flex-direction: row; flex-direction: row;

Просмотреть файл

@@ -86,11 +86,11 @@
} }
.emoji-picker { .emoji-picker {
position: absolute; // position: relative;
z-index: 1100; // z-index: 1100;
display: flex; // display: flex;
width: 350px; width: 350px;
flex-direction: column; // flex-direction: column;
border: 1px solid; border: 1px solid;
border-radius: var(--radius-s); border-radius: var(--radius-s);
margin-right: 3px; margin-right: 3px;
@@ -118,10 +118,6 @@
margin-right: 0; margin-right: 0;
} }
&.bottom {
margin-top: 10px;
}
.nav-tabs { .nav-tabs {
display: flex; display: flex;
flex: 0 0 34px; flex: 0 0 34px;

Просмотреть файл

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

Просмотреть файл

@@ -343,12 +343,6 @@
} }
} }
.emoji-picker {
position: absolute;
top: -361px;
right: 0;
}
.scroll { .scroll {
.custom-textarea { .custom-textarea {
overflow: auto; overflow: auto;
@@ -658,11 +652,9 @@
visibility: visible; visibility: visible;
} }
.post-add-reaction { .Reaction__add {
.Reaction { opacity: 1;
opacity: 1; visibility: visible;
visibility: visible;
}
} }
} }
@@ -1373,13 +1365,10 @@
} }
} }
.post-add-reaction { .Reaction__add {
display: inline-block; display: inline-block;
opacity: 0;
.Reaction { visibility: hidden;
opacity: 0;
visibility: hidden;
}
} }
.post__body { .post__body {
@@ -1488,21 +1477,8 @@
align-items: center; align-items: center;
padding: 4px 0 0; padding: 4px 0 0;
.post-add-reaction-emoji-picker-open { .Reaction__add {
display: inline-block; 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);
} }
} }

Просмотреть файл

@@ -467,7 +467,6 @@
} }
.emoji-picker { .emoji-picker {
z-index: 1070;
// !important is used to overide inline styles // !important is used to overide inline styles
// used on larger screens // used on larger screens
top: 0 !important; top: 0 !important;
@@ -482,10 +481,6 @@
margin-top: 66px; margin-top: 66px;
} }
&.bottom {
margin-top: 0;
}
.search-grid-container { .search-grid-container {
height: calc(100vh - 170px); height: calc(100vh - 170px);
} }
@@ -861,11 +856,9 @@
} }
} }
.post-add-reaction { .Reaction__add {
.Reaction { opacity: 1;
opacity: 1; visibility: visible;
visibility: visible;
}
} }
.browser--ie & { .browser--ie & {
@@ -2082,19 +2075,6 @@
transform: translate3d(0, 0, 0); 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) { @media screen and (max-height: 640px) {

Просмотреть файл

@@ -1657,10 +1657,6 @@ export const Constants = {
OPEN_TEAM: 'O', OPEN_TEAM: 'O',
THREADS: 'threads', THREADS: 'threads',
MAX_POST_LEN: 4000, 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, SIDEBAR_MINIMUM_WIDTH: 640,
THEME_ELEMENTS: [ THEME_ELEMENTS: [
{ {

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

Просмотреть файл

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

Просмотреть файл

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