Composer redesign: Emoji picker (#40009)

This commit is contained in:
Echo
2026-08-10 18:24:46 +02:00
committed by GitHub
parent a09a498dda
commit eca7a8c09d
10 changed files with 375 additions and 33 deletions

View File

@@ -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 '#'

View File

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

View File

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

View File

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

View File

@@ -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<HTMLButtonElement | null>(null);
return (
<>
<IconButton
size='sm'
icon={SmileyIcon}
ref={setTarget}
onClick={onToggle}
>
<FormattedMessage
id='emoji_button.label'
defaultMessage='Insert emoji'
/>
</IconButton>
<Popover
isOpen={open}
onClose={onFalse}
reference={target}
placement='bottom'
offset={4}
>
{({ props, placement }) => (
<ComposeEmojiDropdown
onPick={onPick}
onClose={onFalse}
className={placement}
{...props}
/>
)}
</Popover>
</>
);
};
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<HTMLInputElement>) => {
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<HTMLDivElement>(null);
const [open, { onTrue, onFalse }] = useToggle();
const handleRootClick: React.MouseEventHandler<HTMLDivElement> = useCallback(
(event) => {
if (
!open ||
(event.target instanceof Node &&
modifierRef.current?.contains(event.target))
) {
return;
}
onFalse();
},
[onFalse, open],
);
return (
<Dropdown
{...props}
className={classNames(
'dropdown-animation',
classes.emojiRoot,
className,
open && 'selecting',
)}
onClick={handleRootClick}
>
<Suspense
fallback={
<CircularProgress
size={50}
strokeWidth={2}
role='none'
className={classes.emojiLoading}
/>
}
>
<EmojiPicker
perLine={PER_LINE}
emojiSize={22}
color=''
emoji=''
title={intl.formatMessage(messages.emoji)}
i18n={i18n}
onClick={handleEmojiPick}
recent={frequentlyUsedEmojis}
skin={skinTone ?? 1}
showPreview={false}
showSkinTones={false}
// We need to cast as unknown as this requires a legacy class component.
notFound={NotFound as unknown as () => React.Component}
// eslint-disable-next-line jsx-a11y/no-autofocus
autoFocus
emojiTooltip
/>
<div className='emoji-picker-dropdown__modifiers'>
<Emoji emoji='fist' size={22} skin={skinTone} onClick={onTrue} />
<div
className='emoji-picker-dropdown__modifiers__menu'
hidden={!open}
ref={modifierRef}
>
<ModifierButton skin={1} onClose={onFalse} />
<ModifierButton skin={2} onClose={onFalse} />
<ModifierButton skin={3} onClose={onFalse} />
<ModifierButton skin={4} onClose={onFalse} />
<ModifierButton skin={5} onClose={onFalse} />
<ModifierButton skin={6} onClose={onFalse} />
</div>
</div>
</Suspense>
</Dropdown>
);
};
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 (
<button type='button' onClick={handleClick} data-index={1}>
<Emoji emoji='fist' size={22} skin={skin} />
</button>
);
};
const NotFound = () => (
<div className='emoji-mart-no-results'>
<Emoji emoji='sleuth_or_spy' size={32} />
<div className='emoji-mart-no-results-label'>
<FormattedMessage
id='emoji_button.not_found'
defaultMessage='No matching emojis found'
/>
</div>
</div>
);

View File

@@ -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 = () => {
<footer className={classes.footer}>
<ComposeUploadButton disabled={hasQuote} />
<IconButton size='sm' icon={SmileyIcon}>
<FormattedMessage
id='emoji_button.label'
defaultMessage='Insert emoji'
/>
</IconButton>
<ComposeEmojiButton onPick={onEmojiPick} />
<IconButton
size='sm'

View File

@@ -7,7 +7,7 @@ const meta = {
component: RedesignComposeForm,
render() {
return (
<div style={{ width: '40vw' }}>
<div style={{ width: 'max(40vw, 400px)' }}>
<RedesignComposeForm />
</div>
);

View File

@@ -12,6 +12,7 @@ import {
changeComposeSpoilerText,
clearComposeSuggestions,
fetchComposeSuggestions,
insertEmojiCompose,
selectComposeSuggestion,
} from '@/mastodon/actions/compose';
import {
@@ -27,6 +28,7 @@ import { Icon } from '@/mastodon/components/icon';
import { useAppDispatch, useAppSelector } from '@/mastodon/store';
import { ComposeAttachments } from './attachments';
import type { OnEmojiPick } from './emoji';
import { ComposeFooter } from './footer';
import { ComposeFormHeader } from './header';
import { LanguageButton } from './language';
@@ -76,6 +78,7 @@ export const RedesignComposeForm: React.FC<RedesignComposeFormProps> = ({
textAreaRef,
onSensitiveChange,
onSensitiveTextChange,
onEmojiPick,
onSubmit,
...handlers
} = useHandlers(redirectOnSuccess);
@@ -142,7 +145,7 @@ export const RedesignComposeForm: React.FC<RedesignComposeFormProps> = ({
<ComposeAttachments />
<ComposeFooter />
<ComposeFooter onEmojiPick={onEmojiPick} />
</form>
);
};
@@ -163,8 +166,12 @@ const ComposeTextarea = AutosuggestTextarea as React.ForwardRefExoticComponent<
React.RefAttributes<HTMLTextAreaElement>
>;
const allowedAroundShortCode =
'><\u0085\u0020\u00a0\u1680\u2000\u2001\u2002\u2003\u2004\u2005\u2006\u2007\u2008\u2009\u200a\u202f\u205f\u3000\u2028\u2029\u0009\u000a\u000b\u000c\u000d';
function useHandlers(redirectOnSuccess?: boolean) {
const textAreaRef = useRef<HTMLTextAreaElement>(null);
const text = useAppSelector((state) => state.compose.get('text') as string);
const dispatch = useAppDispatch();
@@ -250,6 +257,19 @@ function useHandlers(redirectOnSuccess?: boolean) {
},
[dispatch],
);
const onEmojiPick: OnEmojiPick = useCallback(
(emoji) => {
const position = textAreaRef.current?.selectionStart ?? 0;
const beforePosition = text[position - 1];
const needsSpace =
'custom' in emoji &&
!!emoji.custom &&
!!beforePosition &&
!allowedAroundShortCode.includes(beforePosition);
dispatch(insertEmojiCompose(position, emoji, needsSpace));
},
[dispatch, text],
);
// Suggestions
@@ -276,6 +296,7 @@ function useHandlers(redirectOnSuccess?: boolean) {
onKeyDown,
onPaste,
onDrop,
onEmojiPick,
onSensitiveChange,
onSensitiveTextChange,
onSuggestionsFetchRequested,

View File

@@ -79,6 +79,58 @@ export const selectComposeState = createAppSelector(
}),
);
export const PER_LINE = 8;
export const LINES = 2;
const DEFAULTS = [
'+1',
'grinning',
'kissing_heart',
'heart_eyes',
'laughing',
'stuck_out_tongue_winking_eye',
'sweat_smile',
'joy',
'yum',
'disappointed',
'thinking_face',
'weary',
'sob',
'sunglasses',
'heart',
'ok_hand',
];
export const selectFrequentlyUsedEmoji = createAppSelector(
[
(state) =>
state.settings.get('frequentlyUsedEmojis') as
| Immutable.Map<string, number>
| undefined,
],
(emojiCounters) => {
if (!emojiCounters) {
return DEFAULTS;
}
let emojis = emojiCounters
.toArray()
.sort((a, b) => a[1] - b[1])
.reverse()
.slice(0, PER_LINE * LINES)
.map(([emoji]) => emoji);
if (emojis.length < DEFAULTS.length) {
const uniqueDefaults = DEFAULTS.filter(
(emoji) => !emojis.includes(emoji),
);
emojis = emojis.concat(
uniqueDefaults.slice(0, DEFAULTS.length - emojis.length),
);
}
return emojis;
},
);
export const selectComposeHasAttachments = createAppSelector(
[
(state) => !!state.compose.get('poll'),

View File

@@ -9,6 +9,7 @@
flex-direction: column;
gap: var(--space-md);
padding: var(--space-md);
min-width: 300px;
}
.header {
@@ -232,3 +233,23 @@
display: flex;
justify-content: space-between;
}
// Emoji picker
div.emojiRoot {
position: relative;
z-index: 2;
padding: 0;
min-width: 300px;
min-height: 300px;
&:has(.emojiLoading) {
display: flex;
align-items: center;
justify-content: center;
}
@media screen and (width > 400px) {
min-height: 370px;
}
}