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
Этот коммит содержится в:
коммит произвёл
GitHub
родитель
da7192246e
Коммит
b6118b7701
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|||||||
145
webapp/channels/src/components/emoji_picker/use_emoji_picker.tsx
Обычный файл
145
webapp/channels/src/components/emoji_picker/use_emoji_picker.tsx
Обычный файл
@@ -0,0 +1,145 @@
|
|||||||
|
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
||||||
|
// See LICENSE.txt for license information.
|
||||||
|
|
||||||
|
import type {UseFloatingOptions, UseFloatingReturn} from '@floating-ui/react';
|
||||||
|
import {
|
||||||
|
flip,
|
||||||
|
FloatingFocusManager,
|
||||||
|
FloatingOverlay,
|
||||||
|
FloatingPortal,
|
||||||
|
offset,
|
||||||
|
shift,
|
||||||
|
useClick,
|
||||||
|
useDismiss,
|
||||||
|
useFloating,
|
||||||
|
useInteractions,
|
||||||
|
useRole,
|
||||||
|
} from '@floating-ui/react';
|
||||||
|
import React, {useCallback} from 'react';
|
||||||
|
import {useSelector} from 'react-redux';
|
||||||
|
|
||||||
|
import type {Emoji} from '@mattermost/types/emojis';
|
||||||
|
|
||||||
|
import {getIsMobileView} from 'selectors/views/browser';
|
||||||
|
|
||||||
|
import {RootHtmlPortalId} from 'utils/constants';
|
||||||
|
|
||||||
|
import EmojiPickerTabs from './emoji_picker_tabs';
|
||||||
|
|
||||||
|
export const useEmojiPickerOffset = 4;
|
||||||
|
|
||||||
|
type UseEmojiPickerOptions = {
|
||||||
|
showEmojiPicker: boolean;
|
||||||
|
setShowEmojiPicker: (showEmojiPicker: boolean) => void;
|
||||||
|
|
||||||
|
enableGifPicker?: boolean;
|
||||||
|
onAddCustomEmojiClick?: () => void;
|
||||||
|
onEmojiClick: (emoji: Emoji) => void;
|
||||||
|
onGifClick?: (gif: string) => void;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Replaces the middleware for positioning the emoji picker in cases where we want it positioned differently.
|
||||||
|
*/
|
||||||
|
overrideMiddleware?: UseFloatingOptions['middleware'];
|
||||||
|
}
|
||||||
|
|
||||||
|
type UseEmojiPickerReturn = {
|
||||||
|
emojiPicker: React.ReactNode;
|
||||||
|
getReferenceProps: ReturnType<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
Обычный файл
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;
|
|
||||||
}
|
|
||||||
Ссылка в новой задаче
Block a user