mirror of
https://github.com/mastodon/mastodon.git
synced 2026-09-13 02:36:36 -05:00
Composer redesign: Emoji picker (#40009)
This commit is contained in:
@@ -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 '#'
|
||||
|
||||
@@ -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());
|
||||
};
|
||||
}
|
||||
15
app/javascript/mastodon/actions/emojis.ts
Normal file
15
app/javascript/mastodon/actions/emojis.ts
Normal 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());
|
||||
});
|
||||
@@ -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);
|
||||
|
||||
253
app/javascript/mastodon/features/compose/redesign/emoji.tsx
Normal file
253
app/javascript/mastodon/features/compose/redesign/emoji.tsx
Normal 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>
|
||||
);
|
||||
@@ -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'
|
||||
|
||||
@@ -7,7 +7,7 @@ const meta = {
|
||||
component: RedesignComposeForm,
|
||||
render() {
|
||||
return (
|
||||
<div style={{ width: '40vw' }}>
|
||||
<div style={{ width: 'max(40vw, 400px)' }}>
|
||||
<RedesignComposeForm />
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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'),
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user