From eca7a8c09d1168f6939b5f253901e27b41909b3c Mon Sep 17 00:00:00 2001 From: Echo Date: Mon, 10 Aug 2026 18:24:46 +0200 Subject: [PATCH] Composer redesign: Emoji picker (#40009) --- app/javascript/mastodon/actions/compose.js | 4 +- app/javascript/mastodon/actions/emojis.js | 14 - app/javascript/mastodon/actions/emojis.ts | 15 ++ .../emoji_picker_dropdown_container.js | 5 +- .../features/compose/redesign/emoji.tsx | 253 ++++++++++++++++++ .../features/compose/redesign/footer.tsx | 19 +- .../compose/redesign/index.stories.tsx | 2 +- .../features/compose/redesign/index.tsx | 23 +- .../features/compose/redesign/selectors.ts | 52 ++++ .../compose/redesign/styles.module.scss | 21 ++ 10 files changed, 375 insertions(+), 33 deletions(-) delete mode 100644 app/javascript/mastodon/actions/emojis.js create mode 100644 app/javascript/mastodon/actions/emojis.ts create mode 100644 app/javascript/mastodon/features/compose/redesign/emoji.tsx diff --git a/app/javascript/mastodon/actions/compose.js b/app/javascript/mastodon/actions/compose.js index d6d2fa0ac3c..c7489e790a5 100644 --- a/app/javascript/mastodon/actions/compose.js +++ b/app/javascript/mastodon/actions/compose.js @@ -10,7 +10,7 @@ import { tagHistory } from 'mastodon/settings'; import { emojiMartSearch } from '@/mastodon/features/emoji/picker'; import { showAlert, showAlertForError } from './alerts'; -import { useEmoji } from './emojis'; +import { emojiUse } from './emojis'; import { importFetchedAccounts, importFetchedStatus } from './importer'; import { openModal } from './modal'; import { updateTimeline } from './timelines'; @@ -633,7 +633,7 @@ export function selectComposeSuggestion(position, token, suggestion, path) { completion = suggestion.native || `:${suggestion.id}:`; startPosition = position - 1; - dispatch(useEmoji(suggestion)); + dispatch(emojiUse(suggestion)); } else if (suggestion.type === 'hashtag') { // TODO: it could make sense to keep the “most capitalized” of the two const tokenName = token.slice(1); // strip leading '#' diff --git a/app/javascript/mastodon/actions/emojis.js b/app/javascript/mastodon/actions/emojis.js deleted file mode 100644 index 3b5d53996c8..00000000000 --- a/app/javascript/mastodon/actions/emojis.js +++ /dev/null @@ -1,14 +0,0 @@ -import { saveSettings } from './settings'; - -export const EMOJI_USE = 'EMOJI_USE'; - -export function useEmoji(emoji) { - return dispatch => { - dispatch({ - type: EMOJI_USE, - emoji, - }); - - dispatch(saveSettings()); - }; -} diff --git a/app/javascript/mastodon/actions/emojis.ts b/app/javascript/mastodon/actions/emojis.ts new file mode 100644 index 00000000000..a84f81a7437 --- /dev/null +++ b/app/javascript/mastodon/actions/emojis.ts @@ -0,0 +1,15 @@ +import type { EmojiData } from 'emoji-mart'; + +import { createAppThunk } from '../store/typed_functions'; + +import { saveSettings } from './settings'; + +export const EMOJI_USE = 'EMOJI_USE'; + +export const emojiUse = createAppThunk((emoji: EmojiData, { dispatch }) => { + dispatch({ + type: EMOJI_USE, + emoji, + }); + dispatch(saveSettings()); +}); diff --git a/app/javascript/mastodon/features/compose/containers/emoji_picker_dropdown_container.js b/app/javascript/mastodon/features/compose/containers/emoji_picker_dropdown_container.js index 80423c11837..32ac207bffe 100644 --- a/app/javascript/mastodon/features/compose/containers/emoji_picker_dropdown_container.js +++ b/app/javascript/mastodon/features/compose/containers/emoji_picker_dropdown_container.js @@ -3,7 +3,7 @@ import { Map as ImmutableMap } from 'immutable'; import { connect } from 'react-redux'; -import { useEmoji } from '../../../actions/emojis'; +import { emojiUse } from '../../../actions/emojis'; import { changeSetting } from '../../../actions/settings'; import EmojiPickerDropdown from '../components/emoji_picker_dropdown'; @@ -58,8 +58,7 @@ const mapDispatchToProps = (dispatch, { onPickEmoji }) => ({ }, onPickEmoji: emoji => { - // eslint-disable-next-line react-hooks/rules-of-hooks -- this is not a react hook - dispatch(useEmoji(emoji)); + dispatch(emojiUse(emoji)); if (onPickEmoji) { onPickEmoji(emoji); diff --git a/app/javascript/mastodon/features/compose/redesign/emoji.tsx b/app/javascript/mastodon/features/compose/redesign/emoji.tsx new file mode 100644 index 00000000000..61ef4cd0f64 --- /dev/null +++ b/app/javascript/mastodon/features/compose/redesign/emoji.tsx @@ -0,0 +1,253 @@ +import type React from 'react'; +import { lazy, Suspense, useCallback, useMemo, useRef, useState } from 'react'; + +import { defineMessages, FormattedMessage, useIntl } from 'react-intl'; + +import classNames from 'classnames'; + +import { SmileyIcon } from '@phosphor-icons/react'; +import type { EmojiData, EmojiSkin } from 'emoji-mart'; + +import { emojiUse } from '@/mastodon/actions/emojis'; +import { changeSetting } from '@/mastodon/actions/settings'; +import { IconButton } from '@/mastodon/components/button/redesign'; +import { CircularProgress } from '@/mastodon/components/circular_progress'; +import { Dropdown } from '@/mastodon/components/dropdown/redesign'; +import type { PopoverChildProps } from '@/mastodon/components/popover'; +import { Popover } from '@/mastodon/components/popover'; +import { useToggle } from '@/mastodon/hooks/useToggle'; +import { useAppDispatch, useAppSelector } from '@/mastodon/store'; + +import { PER_LINE, selectFrequentlyUsedEmoji } from './selectors'; +import classes from './styles.module.scss'; + +export type OnEmojiPick = (emoji: EmojiData) => void; + +const messages = defineMessages({ + emoji: { id: 'emoji_button.label', defaultMessage: 'Insert emoji' }, + emoji_search: { id: 'emoji_button.search', defaultMessage: 'Search...' }, + custom: { id: 'emoji_button.custom', defaultMessage: 'Custom' }, + recent: { id: 'emoji_button.recent', defaultMessage: 'Frequently used' }, + search_results: { + id: 'emoji_button.search_results', + defaultMessage: 'Search results', + }, + people: { id: 'emoji_button.people', defaultMessage: 'People' }, + nature: { id: 'emoji_button.nature', defaultMessage: 'Nature' }, + food: { id: 'emoji_button.food', defaultMessage: 'Food & Drink' }, + activity: { id: 'emoji_button.activity', defaultMessage: 'Activity' }, + travel: { id: 'emoji_button.travel', defaultMessage: 'Travel & Places' }, + objects: { id: 'emoji_button.objects', defaultMessage: 'Objects' }, + symbols: { id: 'emoji_button.symbols', defaultMessage: 'Symbols' }, + flags: { id: 'emoji_button.flags', defaultMessage: 'Flags' }, +}); + +export const ComposeEmojiButton: React.FC<{ onPick: OnEmojiPick }> = ({ + onPick, +}) => { + const [open, { onToggle, onFalse }] = useToggle(); + const [target, setTarget] = useState(null); + + return ( + <> + + + + + + {({ props, placement }) => ( + + )} + + + ); +}; + +const EmojiPicker = lazy(() => + import('@/mastodon/features/emoji/emoji_picker').then(({ Picker }) => ({ + default: Picker, + })), +); +const Emoji = lazy(() => + import('@/mastodon/features/emoji/emoji_picker').then(({ Emoji }) => ({ + default: Emoji, + })), +); + +const ComposeEmojiDropdown: React.FC< + { + onClose: () => void; + onPick: OnEmojiPick; + className?: string; + } & PopoverChildProps +> = ({ onPick, onClose, className, ...props }) => { + const intl = useIntl(); + const skinTone = useAppSelector( + (state) => state.settings.get('skinTone') as EmojiSkin | undefined, + ); + const frequentlyUsedEmojis = useAppSelector(selectFrequentlyUsedEmoji); + const i18n = useMemo( + () => ({ + search: intl.formatMessage(messages.emoji_search), + categories: { + search: intl.formatMessage(messages.search_results), + recent: intl.formatMessage(messages.recent), + people: intl.formatMessage(messages.people), + nature: intl.formatMessage(messages.nature), + foods: intl.formatMessage(messages.food), + activity: intl.formatMessage(messages.activity), + places: intl.formatMessage(messages.travel), + objects: intl.formatMessage(messages.objects), + symbols: intl.formatMessage(messages.symbols), + flags: intl.formatMessage(messages.flags), + custom: intl.formatMessage(messages.custom), + }, + }), + [intl], + ); + + const dispatch = useAppDispatch(); + const handleEmojiPick = useCallback( + (rawEmoji: EmojiData, event: React.MouseEvent) => { + const emoji = { + ...rawEmoji, + native: 'native' in rawEmoji ? rawEmoji.native : `:${rawEmoji.id}:`, + }; + + if (!(event.ctrlKey || event.metaKey)) { + onClose(); + } + dispatch(emojiUse(emoji)); + onPick(emoji); + }, + [dispatch, onPick, onClose], + ); + + // Close modifier window if it's not clicked in. + const modifierRef = useRef(null); + const [open, { onTrue, onFalse }] = useToggle(); + const handleRootClick: React.MouseEventHandler = useCallback( + (event) => { + if ( + !open || + (event.target instanceof Node && + modifierRef.current?.contains(event.target)) + ) { + return; + } + onFalse(); + }, + [onFalse, open], + ); + + return ( + + + } + > + React.Component} + // eslint-disable-next-line jsx-a11y/no-autofocus + autoFocus + emojiTooltip + /> + +
+ + +
+
+
+ ); +}; + +const ModifierButton: React.FC<{ skin: EmojiSkin; onClose: () => void }> = ({ + skin, + onClose, +}) => { + const dispatch = useAppDispatch(); + const handleClick: React.MouseEventHandler = useCallback( + (event) => { + event.preventDefault(); + dispatch(changeSetting(['skinTone'], skin)); + onClose(); + }, + [dispatch, onClose, skin], + ); + return ( + + ); +}; + +const NotFound = () => ( +
+ + +
+ +
+
+); diff --git a/app/javascript/mastodon/features/compose/redesign/footer.tsx b/app/javascript/mastodon/features/compose/redesign/footer.tsx index 57723a3b3fb..faa17ef56d5 100644 --- a/app/javascript/mastodon/features/compose/redesign/footer.tsx +++ b/app/javascript/mastodon/features/compose/redesign/footer.tsx @@ -3,11 +3,7 @@ import { useCallback, useRef } from 'react'; import { FormattedMessage } from 'react-intl'; -import { - ImageSquareIcon, - SmileyIcon, - ChartBarHorizontalIcon, -} from '@phosphor-icons/react'; +import { ImageSquareIcon, ChartBarHorizontalIcon } from '@phosphor-icons/react'; import { addPoll, uploadCompose } from '@/mastodon/actions/compose'; import { Button, IconButton } from '@/mastodon/components/button/redesign'; @@ -17,6 +13,8 @@ import { useAppSelector, } from '@/mastodon/store'; +import type { OnEmojiPick } from './emoji'; +import { ComposeEmojiButton } from './emoji'; import { selectComposeAttachments, selectComposeCanSubmit, @@ -26,7 +24,9 @@ import { } from './selectors'; import classes from './styles.module.scss'; -export const ComposeFooter: React.FC = () => { +export const ComposeFooter: React.FC<{ onEmojiPick: OnEmojiPick }> = ({ + onEmojiPick, +}) => { const type = useAppSelector(selectComposeType); const { current, max } = useAppSelector(selectComposeCharsCount); const { hasPoll, quotedStatusId } = useAppSelector( @@ -47,12 +47,7 @@ export const ComposeFooter: React.FC = () => {