Pre-alpha: React with any emoji, behind flag

This commit is contained in:
Ken Powers 2020-05-05 15:49:34 -04:00 committed by Scott Nonnenberg
parent d13c3d3350
commit 0865a5481c
31 changed files with 572 additions and 234 deletions

View File

@ -2296,6 +2296,10 @@
"message": "Failed to send reaction. Please try again.",
"description": "Shown when a reaction fails to send"
},
"ReactionsViewer--more": {
"message": "More",
"description": "Use in the reaction picker as the alt text for the 'more' button"
},
"ReactionsViewer--all": {
"message": "All",
"description": "Shown in reaction viewer as the title for the 'all' category"

View File

@ -0,0 +1,6 @@
<svg width="32" height="32" viewBox="0 0 32 32" fill="none" xmlns="http://www.w3.org/2000/svg">
<circle cx="16" cy="16" r="16" fill="#4A4A4A"/>
<circle cx="8" cy="16" r="2" fill="#E9E9E9"/>
<circle cx="16" cy="16" r="2" fill="#E9E9E9"/>
<circle cx="24" cy="16" r="2" fill="#E9E9E9"/>
</svg>

After

Width:  |  Height:  |  Size: 291 B

6
images/any-emoji-32.svg Normal file
View File

@ -0,0 +1,6 @@
<svg width="32" height="32" viewBox="0 0 32 32" fill="none" xmlns="http://www.w3.org/2000/svg">
<circle cx="16" cy="16" r="16" fill="#2E2E2E"/>
<circle cx="8" cy="16" r="2" fill="#E9E9E9"/>
<circle cx="16" cy="16" r="2" fill="#E9E9E9"/>
<circle cx="24" cy="16" r="2" fill="#E9E9E9"/>
</svg>

After

Width:  |  Height:  |  Size: 291 B

View File

@ -15,6 +15,8 @@ try {
// Derive profile key versions, then use those to fetch versioned profiles from server
window.VERSIONED_PROFILE_FETCH = false;
// Enable full emoji picker for reactions
window.REACT_ANY_EMOJI = false;
window.PROTO_ROOT = 'protos';
const config = require('url').parse(window.location.toString(), true).query;

View File

@ -5163,6 +5163,23 @@ button.module-image__border-overlay:focus {
// This color is the same in both light and dark themes
background: rgba($color-white, 0.3);
}
&--more {
background: url('../images/any-emoji-32.svg') no-repeat center;
&::after {
content: '';
display: block;
width: 52px;
height: 52px;
background: url('../images/any-emoji-32-hover.svg') no-repeat center;
opacity: 0;
transition: opacity 400ms $ease-out-expo;
}
&:hover::after {
opacity: 1;
}
}
@include keyboard-mode {
&:focus:before {

View File

@ -3,7 +3,7 @@ import classNames from 'classnames';
import { isEmpty } from 'lodash';
import { Avatar, Props as AvatarProps } from './Avatar';
import { useRestoreFocus } from './hooks';
import { useRestoreFocus } from '../util/hooks';
import { LocalizerType } from '../types/Util';

View File

@ -1,6 +1,6 @@
import * as React from 'react';
import classNames from 'classnames';
import { useRestoreFocus } from './hooks';
import { useRestoreFocus } from '../util/hooks';
import { LocalizerType } from '../types/Util';
export type Props = {

View File

@ -8,7 +8,14 @@ import { setup as setupI18n } from '../../../js/modules/i18n';
// @ts-ignore
import enMessages from '../../../_locales/en/messages.json';
import { Message, PropsActions, PropsData, PropsHousekeeping } from './Message';
import {
Message,
Props as AllProps,
PropsActions,
PropsData,
PropsHousekeeping,
} from './Message';
import { EmojiPicker } from '../emoji/EmojiPicker';
const book = storiesOf('Components/Conversation/Message', module);
@ -1267,6 +1274,21 @@ const stories: Array<MessageStory> = [
],
];
const renderEmojiPicker: AllProps['renderEmojiPicker'] = ({
onClose,
onPickEmoji,
ref,
}) => (
<EmojiPicker
i18n={setupI18n('en', enMessages)}
skinTone={0}
onSetSkinTone={action('EmojiPicker::onSetSkinTone')}
ref={ref}
onClose={onClose}
onPickEmoji={onPickEmoji}
/>
);
stories.forEach(([chapterTitle, propsArr]) =>
book.add(chapterTitle, () =>
propsArr.map(
@ -1294,6 +1316,7 @@ stories.forEach(([chapterTitle, propsArr]) =>
{...dataProps}
{...makeActionProps()}
{...makeHouseKeepingProps()}
renderEmojiPicker={renderEmojiPicker}
/>
</div>
</>

View File

@ -19,7 +19,7 @@ import {
OwnProps as ReactionViewerProps,
ReactionViewer,
} from './ReactionViewer';
import { ReactionPicker } from './ReactionPicker';
import { Props as ReactionPickerProps, ReactionPicker } from './ReactionPicker';
import { Emoji } from '../emoji/Emoji';
import {
@ -155,7 +155,10 @@ export type PropsActions = {
showExpiredOutgoingTapToViewToast: () => unknown;
};
export type Props = PropsData & PropsHousekeeping & PropsActions;
export type Props = PropsData &
PropsHousekeeping &
PropsActions &
Pick<ReactionPickerProps, 'renderEmojiPicker'>;
interface State {
expiring: boolean;
@ -1001,9 +1004,11 @@ export class Message extends React.PureComponent<Props, State> {
canReply,
direction,
disableMenu,
i18n,
id,
isSticker,
isTapToView,
renderEmojiPicker,
replyToMessage,
} = this.props;
@ -1121,6 +1126,7 @@ export class Message extends React.PureComponent<Props, State> {
<Popper placement="top">
{({ ref, style }) => (
<ReactionPicker
i18n={i18n}
ref={ref}
style={style}
selected={this.props.selectedReaction}
@ -1132,6 +1138,7 @@ export class Message extends React.PureComponent<Props, State> {
remove: emoji === this.props.selectedReaction,
});
}}
renderEmojiPicker={renderEmojiPicker}
/>
)}
</Popper>,

View File

@ -1,24 +0,0 @@
### Reaction Picker
#### Base
```jsx
<util.ConversationContext theme={util.theme} ios={util.ios} mode={util.mode}>
<ReactionPicker onPick={e => console.log(`Picked reaction: ${e}`)} />
</util.ConversationContext>
```
#### Selected
```jsx
<util.ConversationContext theme={util.theme} ios={util.ios} mode={util.mode}>
{['❤️', '👍', '👎', '😂', '😮', '😢', '😡'].map(e => (
<div key={e} style={{ height: '100px' }}>
<ReactionPicker
selected={e}
onPick={e => console.log(`Picked reaction: ${e}`)}
/>
</div>
))}
</util.ConversationContext>
```

View File

@ -0,0 +1,51 @@
import * as React from 'react';
// @ts-ignore
import { setup as setupI18n } from '../../../js/modules/i18n';
// @ts-ignore
import enMessages from '../../../_locales/en/messages.json';
import { storiesOf } from '@storybook/react';
import { action } from '@storybook/addon-actions';
import { Props as ReactionPickerProps, ReactionPicker } from './ReactionPicker';
import { EmojiPicker } from '../emoji/EmojiPicker';
const i18n = setupI18n('en', enMessages);
const renderEmojiPicker: ReactionPickerProps['renderEmojiPicker'] = ({
onClose,
onPickEmoji,
ref,
}) => (
<EmojiPicker
i18n={i18n}
skinTone={0}
onSetSkinTone={action('EmojiPicker::onSetSkinTone')}
ref={ref}
onClose={onClose}
onPickEmoji={onPickEmoji}
/>
);
storiesOf('Components/Conversation/ReactionPicker', module)
.add('Base', () => {
return (
<ReactionPicker
i18n={i18n}
onPick={action('onPick')}
renderEmojiPicker={renderEmojiPicker}
/>
);
})
.add('Selected Reaction', () => {
return ['❤️', '👍', '👎', '😂', '😮', '😢', '😡'].map(e => (
<div key={e} style={{ height: '100px' }}>
<ReactionPicker
i18n={i18n}
selected={e}
onPick={action('onPick')}
renderEmojiPicker={renderEmojiPicker}
/>
</div>
));
});

View File

@ -1,20 +1,34 @@
import * as React from 'react';
import classNames from 'classnames';
import { Emoji } from '../emoji/Emoji';
import { useRestoreFocus } from '../hooks';
import { convertShortName } from '../emoji/lib';
import { Props as EmojiPickerProps } from '../emoji/EmojiPicker';
import { useRestoreFocus } from '../../util/hooks';
import { LocalizerType } from '../../types/Util';
export type RenderEmojiPickerProps = Pick<Props, 'onClose' | 'style'> &
Pick<EmojiPickerProps, 'onPickEmoji'> & {
ref: React.Ref<HTMLDivElement>;
};
export type OwnProps = {
i18n: LocalizerType;
selected?: string;
onClose?: () => unknown;
onPick: (emoji: string) => unknown;
renderEmojiPicker: (props: RenderEmojiPickerProps) => React.ReactElement;
};
export type Props = OwnProps & Pick<React.HTMLProps<HTMLDivElement>, 'style'>;
const emojis = ['❤️', '👍', '👎', '😂', '😮', '😢', '😡'];
const getEmojis = () =>
emojis.slice(0, window.REACT_ANY_EMOJI ? emojis.length - 1 : emojis.length);
export const ReactionPicker = React.forwardRef<HTMLDivElement, Props>(
({ selected, onClose, onPick, ...rest }, ref) => {
({ i18n, selected, onClose, onPick, renderEmojiPicker, style }, ref) => {
const [pickingOther, setPickingOther] = React.useState(false);
const focusRef = React.useRef<HTMLButtonElement>(null);
// Handle escape key
@ -32,12 +46,24 @@ export const ReactionPicker = React.forwardRef<HTMLDivElement, Props>(
};
}, [onClose]);
// Handle EmojiPicker::onPickEmoji
const onPickEmoji: EmojiPickerProps['onPickEmoji'] = React.useCallback(
({ shortName, skinTone }) => {
onPick(convertShortName(shortName, skinTone));
},
[onPick]
);
// Focus first button and restore focus on unmount
useRestoreFocus(focusRef);
return (
<div {...rest} ref={ref} className="module-reaction-picker">
{emojis.map((emoji, index) => {
const otherSelected = selected && !getEmojis().includes(selected);
return pickingOther ? (
renderEmojiPicker({ onPickEmoji, onClose, style, ref })
) : (
<div ref={ref} style={style} className="module-reaction-picker">
{getEmojis().map((emoji, index) => {
const maybeFocusRef = index === 0 ? focusRef : undefined;
return (
@ -55,6 +81,7 @@ export const ReactionPicker = React.forwardRef<HTMLDivElement, Props>(
e.stopPropagation();
onPick(emoji);
}}
title={emoji}
>
<div className="module-reaction-picker__emoji-btn__emoji">
<Emoji size={48} emoji={emoji} />
@ -62,6 +89,31 @@ export const ReactionPicker = React.forwardRef<HTMLDivElement, Props>(
</button>
);
})}
{window.REACT_ANY_EMOJI ? (
<button
className={classNames(
'module-reaction-picker__emoji-btn',
otherSelected
? 'module-reaction-picker__emoji-btn--selected'
: 'module-reaction-picker__emoji-btn--more'
)}
onClick={e => {
e.stopPropagation();
if (otherSelected && selected) {
onPick(selected);
} else {
setPickingOther(true);
}
}}
title={i18n('ReactionsViewer--more')}
>
{otherSelected ? (
<div className="module-reaction-picker__emoji-btn__emoji">
<Emoji size={48} emoji={selected} />
</div>
) : null}
</button>
) : null}
</div>
);
}

View File

@ -4,7 +4,7 @@ import classNames from 'classnames';
import { ContactName } from './ContactName';
import { Avatar, Props as AvatarProps } from '../Avatar';
import { Emoji } from '../emoji/Emoji';
import { useRestoreFocus } from '../hooks';
import { useRestoreFocus } from '../../util/hooks';
export type Reaction = {
emoji: string;

View File

@ -1,51 +0,0 @@
### A plain message
```jsx
const item = {
type: 'message',
data: {
id: 'id-1',
direction: 'incoming',
timestamp: Date.now(),
authorPhoneNumber: '(202) 555-2001',
authorColor: 'green',
text: '🔥',
},
};
<TimelineItem item={item} i18n={util.i18n} />;
```
### A notification
```jsx
const item = {
type: 'timerNotification',
data: {
type: 'fromOther',
phoneNumber: '(202) 555-0000',
timespan: '1 hour',
},
};
<TimelineItem item={item} i18n={util.i18n} />;
```
### Unknown type
```jsx
const item = {
type: 'random',
data: {
somethin: 'somethin',
},
};
<TimelineItem item={item} i18n={util.i18n} />;
```
### Missing itme
```jsx
<TimelineItem item={null} i18n={util.i18n} />
```

View File

@ -0,0 +1,104 @@
import * as React from 'react';
import { storiesOf } from '@storybook/react';
import { action } from '@storybook/addon-actions';
import { EmojiPicker } from '../emoji/EmojiPicker';
// @ts-ignore
import { setup as setupI18n } from '../../../js/modules/i18n';
// @ts-ignore
import enMessages from '../../../_locales/en/messages.json';
import { PropsType as TimelineItemProps, TimelineItem } from './TimelineItem';
const i18n = setupI18n('en', enMessages);
const renderEmojiPicker: TimelineItemProps['renderEmojiPicker'] = ({
onClose,
onPickEmoji,
ref,
}) => (
<EmojiPicker
i18n={setupI18n('en', enMessages)}
skinTone={0}
onSetSkinTone={action('EmojiPicker::onSetSkinTone')}
ref={ref}
onClose={onClose}
onPickEmoji={onPickEmoji}
/>
);
const getDefaultProps = () => ({
conversationId: 'conversation-id',
id: 'asdf',
isSelected: false,
selectMessage: action('selectMessage'),
reactToMessage: action('reactToMessage'),
clearSelectedMessage: action('clearSelectedMessage'),
replyToMessage: action('replyToMessage'),
retrySend: action('retrySend'),
deleteMessage: action('deleteMessage'),
showMessageDetail: action('showMessageDetail'),
openConversation: action('openConversation'),
showContactDetail: action('showContactDetail'),
showVisualAttachment: action('showVisualAttachment'),
downloadAttachment: action('downloadAttachment'),
displayTapToViewMessage: action('displayTapToViewMessage'),
showExpiredIncomingTapToViewToast: action(
'showExpiredIncomingTapToViewToast'
),
showExpiredOutgoingTapToViewToast: action(
'showExpiredIncomingTapToViewToast'
),
openLink: action('openLink'),
scrollToQuotedMessage: action('scrollToQuotedMessage'),
downloadNewVersion: action('downloadNewVersion'),
showIdentity: action('showIdentity'),
renderEmojiPicker,
});
storiesOf('Components/Conversation/TimelineItem', module)
.add('Plain Message', () => {
const item = {
type: 'message',
data: {
id: 'id-1',
direction: 'incoming',
timestamp: Date.now(),
authorPhoneNumber: '(202) 555-2001',
authorColor: 'green',
text: '🔥',
},
} as TimelineItemProps['item'];
return <TimelineItem {...getDefaultProps()} item={item} i18n={i18n} />;
})
.add('Notification', () => {
const item = {
type: 'timerNotification',
data: {
type: 'fromOther',
phoneNumber: '(202) 555-0000',
timespan: '1 hour',
},
} as TimelineItemProps['item'];
return <TimelineItem {...getDefaultProps()} item={item} i18n={i18n} />;
})
.add('Unknown Type', () => {
// @ts-ignore: intentional
const item = {
type: 'random',
data: {
somethin: 'somethin',
},
} as TimelineItemProps['item'];
return <TimelineItem {...getDefaultProps()} item={item} i18n={i18n} />;
})
.add('Missing Item', () => {
// @ts-ignore: intentional
const item = null as TimelineItemProps['item'];
return <TimelineItem {...getDefaultProps()} item={item} i18n={i18n} />;
});

View File

@ -3,6 +3,7 @@ import { LocalizerType } from '../../types/Util';
import {
Message,
Props as AllMessageProps,
PropsActions as MessageActionsType,
PropsData as MessageProps,
} from './Message';
@ -87,7 +88,9 @@ type PropsActionsType = MessageActionsType &
UnsupportedMessageActionsType &
SafetyNumberActionsType;
type PropsType = PropsLocalType & PropsActionsType;
export type PropsType = PropsLocalType &
PropsActionsType &
Pick<AllMessageProps, 'renderEmojiPicker'>;
export class TimelineItem extends React.PureComponent<PropsType> {
public render() {

View File

@ -1,56 +0,0 @@
#### Default
```jsx
<util.ConversationContext theme={util.theme}>
<div
style={{
height: '500px',
display: 'flex',
flexDirection: 'column',
justifyContent: 'flex-end',
}}
>
<EmojiButton
i18n={util.i18n}
onPickEmoji={e => console.log('onPickEmoji', e)}
skinTone={0}
onSetSkinTone={t => console.log('onSetSkinTone', t)}
onClose={() => console.log('onClose')}
recentEmojis={[
'grinning',
'grin',
'joy',
'rolling_on_the_floor_laughing',
'smiley',
'smile',
'sweat_smile',
'laughing',
'wink',
'blush',
'yum',
'sunglasses',
'heart_eyes',
'kissing_heart',
'kissing',
'kissing_smiling_eyes',
'kissing_closed_eyes',
'relaxed',
'slightly_smiling_face',
'hugging_face',
'grinning_face_with_star_eyes',
'thinking_face',
'face_with_one_eyebrow_raised',
'neutral_face',
'expressionless',
'no_mouth',
'face_with_rolling_eyes',
'smirk',
'persevere',
'disappointed_relieved',
'open_mouth',
'zipper_mouth_face',
]}
/>
</div>
</util.ConversationContext>
```

View File

@ -0,0 +1,66 @@
import * as React from 'react';
// @ts-ignore
import { setup as setupI18n } from '../../../js/modules/i18n';
// @ts-ignore
import enMessages from '../../../_locales/en/messages.json';
import { storiesOf } from '@storybook/react';
import { action } from '@storybook/addon-actions';
import { EmojiButton } from './EmojiButton';
const i18n = setupI18n('en', enMessages);
storiesOf('Components/emojis/EmojiButton', module).add('Base', () => {
return (
<div
style={{
height: '500px',
display: 'flex',
flexDirection: 'column',
justifyContent: 'flex-end',
}}
>
<EmojiButton
i18n={i18n}
onPickEmoji={action('onPickEmoji')}
skinTone={0}
onSetSkinTone={action('onSetSkinTone')}
recentEmojis={[
'grinning',
'grin',
'joy',
'rolling_on_the_floor_laughing',
'smiley',
'smile',
'sweat_smile',
'laughing',
'wink',
'blush',
'yum',
'sunglasses',
'heart_eyes',
'kissing_heart',
'kissing',
'kissing_smiling_eyes',
'kissing_closed_eyes',
'relaxed',
'slightly_smiling_face',
'hugging_face',
'grinning_face_with_star_eyes',
'thinking_face',
'face_with_one_eyebrow_raised',
'neutral_face',
'expressionless',
'no_mouth',
'face_with_rolling_eyes',
'smirk',
'persevere',
'disappointed_relieved',
'open_mouth',
'zipper_mouth_face',
]}
/>
</div>
);
});

View File

@ -1,60 +0,0 @@
#### Default
```jsx
<util.ConversationContext theme={util.theme}>
<EmojiPicker
i18n={util.i18n}
onPickEmoji={e => console.log('onPickEmoji', e)}
onSetSkinTone={t => console.log('onSetSkinTone', t)}
onClose={() => console.log('onClose')}
recentEmojis={[
'grinning',
'grin',
'joy',
'rolling_on_the_floor_laughing',
'smiley',
'smile',
'sweat_smile',
'laughing',
'wink',
'blush',
'yum',
'sunglasses',
'heart_eyes',
'kissing_heart',
'kissing',
'kissing_smiling_eyes',
'kissing_closed_eyes',
'relaxed',
'slightly_smiling_face',
'hugging_face',
'grinning_face_with_star_eyes',
'thinking_face',
'face_with_one_eyebrow_raised',
'neutral_face',
'expressionless',
'no_mouth',
'face_with_rolling_eyes',
'smirk',
'persevere',
'disappointed_relieved',
'open_mouth',
'zipper_mouth_face',
]}
/>
</util.ConversationContext>
```
#### No Recents
```jsx
<util.ConversationContext theme={util.theme}>
<EmojiPicker
i18n={util.i18n}
onPickEmoji={e => console.log('onPickEmoji', e)}
onSetSkinTone={t => console.log('onSetSkinTone', t)}
onClose={() => console.log('onClose')}
recentEmojis={[]}
/>
</util.ConversationContext>
```

View File

@ -0,0 +1,71 @@
import * as React from 'react';
// @ts-ignore
import { setup as setupI18n } from '../../../js/modules/i18n';
// @ts-ignore
import enMessages from '../../../_locales/en/messages.json';
import { storiesOf } from '@storybook/react';
import { action } from '@storybook/addon-actions';
import { EmojiPicker } from './EmojiPicker';
const i18n = setupI18n('en', enMessages);
storiesOf('Components/emojis/EmojiPicker', module)
.add('Base', () => {
return (
<EmojiPicker
i18n={i18n}
onPickEmoji={action('onPickEmoji')}
onSetSkinTone={action('onSetSkinTone')}
onClose={action('onClose')}
skinTone={0}
recentEmojis={[
'grinning',
'grin',
'joy',
'rolling_on_the_floor_laughing',
'smiley',
'smile',
'sweat_smile',
'laughing',
'wink',
'blush',
'yum',
'sunglasses',
'heart_eyes',
'kissing_heart',
'kissing',
'kissing_smiling_eyes',
'kissing_closed_eyes',
'relaxed',
'slightly_smiling_face',
'hugging_face',
'grinning_face_with_star_eyes',
'thinking_face',
'face_with_one_eyebrow_raised',
'neutral_face',
'expressionless',
'no_mouth',
'face_with_rolling_eyes',
'smirk',
'persevere',
'disappointed_relieved',
'open_mouth',
'zipper_mouth_face',
]}
/>
);
})
.add('No Recents', () => {
return (
<EmojiPicker
i18n={i18n}
onPickEmoji={action('onPickEmoji')}
onSetSkinTone={action('onSetSkinTone')}
onClose={action('onClose')}
skinTone={0}
recentEmojis={[]}
/>
);
});

View File

@ -17,7 +17,7 @@ import {
} from 'lodash';
import { Emoji } from './Emoji';
import { dataByCategory, search } from './lib';
import { useRestoreFocus } from '../hooks';
import { useRestoreFocus } from '../../util/hooks';
import { LocalizerType } from '../../types/Util';
export type EmojiPickDataType = { skinTone?: number; shortName: string };
@ -29,7 +29,7 @@ export type OwnProps = {
readonly skinTone: number;
readonly onSetSkinTone: (tone: number) => unknown;
readonly recentEmojis?: Array<string>;
readonly onClose: () => unknown;
readonly onClose?: () => unknown;
};
export type Props = OwnProps & Pick<React.HTMLProps<HTMLDivElement>, 'style'>;
@ -83,11 +83,15 @@ export const EmojiPicker = React.memo(
const [scrollToRow, setScrollToRow] = React.useState(0);
const [selectedTone, setSelectedTone] = React.useState(skinTone);
const handleToggleSearch = React.useCallback(() => {
setSearchText('');
setSelectedCategory(categories[0]);
setSearchMode(m => !m);
}, [setSearchText, setSearchMode]);
const handleToggleSearch = React.useCallback(
(e: React.MouseEvent) => {
e.stopPropagation();
setSearchText('');
setSelectedCategory(categories[0]);
setSearchMode(m => !m);
},
[setSearchText, setSearchMode]
);
const debounceSearchChange = React.useMemo(
() =>
@ -122,15 +126,16 @@ export const EmojiPicker = React.memo(
| React.KeyboardEvent<HTMLButtonElement>
) => {
if ('key' in e) {
if (e.key === 'Enter') {
if (e.key === 'Enter' && doSend) {
e.stopPropagation();
e.preventDefault();
if (doSend) {
doSend();
}
doSend();
}
} else {
const { shortName } = e.currentTarget.dataset;
if (shortName) {
e.stopPropagation();
e.preventDefault();
onPickEmoji({ skinTone: selectedTone, shortName });
}
}
@ -160,7 +165,9 @@ export const EmojiPicker = React.memo(
' ', // Space
].includes(event.key)
) {
onClose();
if (onClose) {
onClose();
}
event.preventDefault();
event.stopPropagation();
@ -224,8 +231,9 @@ export const EmojiPicker = React.memo(
);
const handleSelectCategory = React.useCallback(
({ currentTarget }: React.MouseEvent<HTMLButtonElement>) => {
const { category } = currentTarget.dataset;
(e: React.MouseEvent<HTMLButtonElement>) => {
e.stopPropagation();
const { category } = e.currentTarget.dataset;
if (category) {
setSelectedCategory(category);
setScrollToRow(catToRowOffsets[category]);

View File

@ -2,7 +2,7 @@
/* tslint:disable:cyclomatic-complexity */
import * as React from 'react';
import classNames from 'classnames';
import { useRestoreFocus } from '../hooks';
import { useRestoreFocus } from '../../util/hooks';
import { StickerPackType, StickerType } from '../../state/ducks/stickers';
import { LocalizerType } from '../../types/Util';

View File

@ -7,7 +7,7 @@ import { ConfirmationDialog } from '../ConfirmationDialog';
import { LocalizerType } from '../../types/Util';
import { StickerPackType } from '../../state/ducks/stickers';
import { Spinner } from '../Spinner';
import { useRestoreFocus } from '../hooks';
import { useRestoreFocus } from '../../util/hooks';
export type OwnProps = {
readonly onClose: () => unknown;

View File

@ -1,6 +1,7 @@
import { take, uniq } from 'lodash';
import { EmojiPickDataType } from '../../components/emoji/EmojiPicker';
import dataInterface from '../../sql/Client';
import { useBoundActions } from '../../util/hooks';
const { updateEmojiUsage } = dataInterface;
@ -12,32 +13,34 @@ export type EmojisStateType = {
// Actions
type UseEmojiPayloadType = string;
type UseEmojiAction = {
type OnUseEmojiPayloadType = string;
type OnUseEmojiAction = {
type: 'emojis/USE_EMOJI';
payload: Promise<UseEmojiPayloadType>;
payload: Promise<OnUseEmojiPayloadType>;
};
type UseEmojiFulfilledAction = {
type OnUseEmojiFulfilledAction = {
type: 'emojis/USE_EMOJI_FULFILLED';
payload: UseEmojiPayloadType;
payload: OnUseEmojiPayloadType;
};
export type EmojisActionType = UseEmojiAction | UseEmojiFulfilledAction;
export type EmojisActionType = OnUseEmojiAction | OnUseEmojiFulfilledAction;
// Action Creators
export const actions = {
useEmoji,
onUseEmoji,
};
function useEmoji({ shortName }: EmojiPickDataType): UseEmojiAction {
export const useActions = () => useBoundActions(actions);
function onUseEmoji({ shortName }: EmojiPickDataType): OnUseEmojiAction {
return {
type: 'emojis/USE_EMOJI',
payload: doUseEmoji(shortName),
};
}
async function doUseEmoji(shortName: string): Promise<UseEmojiPayloadType> {
async function doUseEmoji(shortName: string): Promise<OnUseEmojiPayloadType> {
await updateEmojiUsage(shortName);
return shortName;

View File

@ -1,5 +1,10 @@
import { omit } from 'lodash';
import { createSelector } from 'reselect';
import { useSelector } from 'react-redux';
import { StateType } from '../reducer';
import * as storageShim from '../../shims/storage';
import { isShortName } from '../../components/emoji/lib';
import { useBoundActions } from '../../util/hooks';
// State
@ -53,6 +58,8 @@ export const actions = {
resetItems,
};
export const useActions = () => useBoundActions(actions);
function putItem(key: string, value: any): ItemPutAction {
storageShim.put(key, value);
@ -123,3 +130,12 @@ export function reducer(
return state;
}
// Selectors
const selectRecentEmojis = createSelector(
({ emojis }: StateType) => emojis.recents,
recents => recents.filter(isShortName)
);
export const useRecentEmojis = () => useSelector(selectRecentEmojis);

View File

@ -81,7 +81,7 @@ const dispatchPropsMap = {
mapDispatchToProps.removeItem('showStickersIntroduction'),
clearShowPickerHint: () =>
mapDispatchToProps.removeItem('showStickerPickerHint'),
onPickEmoji: mapDispatchToProps.useEmoji,
onPickEmoji: mapDispatchToProps.onUseEmoji,
};
const smart = connect(mapStateToProps, dispatchPropsMap);

View File

@ -0,0 +1,57 @@
import * as React from 'react';
import { useSelector } from 'react-redux';
import { get } from 'lodash';
import { StateType } from '../reducer';
import { useActions as useItemActions, useRecentEmojis } from '../ducks/items';
import { useActions as useEmojiActions } from '../ducks/emojis';
import {
EmojiPicker,
Props as EmojiPickerProps,
} from '../../components/emoji/EmojiPicker';
import { getIntl } from '../selectors/user';
import { LocalizerType } from '../../types/Util';
export const SmartEmojiPicker = React.forwardRef<
HTMLDivElement,
Pick<EmojiPickerProps, 'onPickEmoji' | 'onClose' | 'style'>
>(({ onPickEmoji, onClose, style }, ref) => {
const i18n = useSelector<StateType, LocalizerType>(getIntl);
const skinTone = useSelector<StateType, number>(state =>
get(state, ['items', 'skinTone'], 0)
);
const recentEmojis = useRecentEmojis();
const { putItem } = useItemActions();
const onSetSkinTone = React.useCallback(
tone => {
putItem('skinTone', tone);
},
[putItem]
);
const { onUseEmoji } = useEmojiActions();
const handlePickEmoji = React.useCallback(
data => {
onUseEmoji({ shortName: data.shortName });
onPickEmoji(data);
},
[onUseEmoji, onPickEmoji]
);
return (
<EmojiPicker
ref={ref}
i18n={i18n}
skinTone={skinTone}
onSetSkinTone={onSetSkinTone}
onPickEmoji={handlePickEmoji}
recentEmojis={recentEmojis}
onClose={onClose}
style={style}
/>
);
});

View File

@ -3,6 +3,7 @@ import React from 'react';
import { connect } from 'react-redux';
import { mapDispatchToProps } from '../actions';
import { Timeline } from '../../components/conversation/Timeline';
import { RenderEmojiPickerProps } from '../../components/conversation/ReactionPicker';
import { StateType } from '../reducer';
import { getIntl } from '../selectors/user';
@ -16,6 +17,7 @@ import { SmartTimelineItem } from './TimelineItem';
import { SmartTypingBubble } from './TypingBubble';
import { SmartLastSeenIndicator } from './LastSeenIndicator';
import { SmartTimelineLoadingRow } from './TimelineLoadingRow';
import { SmartEmojiPicker } from './EmojiPicker';
// Workaround: A react component's required properties are filtering up through connect()
// https://github.com/DefinitelyTyped/DefinitelyTyped/issues/31363
@ -41,6 +43,22 @@ function renderItem(
{...actionProps}
conversationId={conversationId}
id={messageId}
renderEmojiPicker={renderEmojiPicker}
/>
);
}
function renderEmojiPicker({
ref,
onPickEmoji,
onClose,
style,
}: RenderEmojiPickerProps): JSX.Element {
return (
<SmartEmojiPicker
ref={ref}
onPickEmoji={onPickEmoji}
onClose={onClose}
style={style}
/>
);
}

View File

@ -1,4 +1,6 @@
import * as React from 'react';
import { ActionCreatorsMapObject, bindActionCreators } from 'redux';
import { useDispatch } from 'react-redux';
// Restore focus on teardown
export const useRestoreFocus = (
@ -28,3 +30,13 @@ export const useRestoreFocus = (
};
}, [focusRef, root]);
};
export const useBoundActions = <T extends ActionCreatorsMapObject>(
actions: T
) => {
const dispatch = useDispatch();
return React.useMemo(() => {
return bindActionCreators(actions, dispatch);
}, [dispatch]);
};

View File

@ -11551,17 +11551,17 @@
"rule": "React-createRef",
"path": "ts/components/conversation/Message.tsx",
"line": " public audioRef: React.RefObject<HTMLAudioElement> = React.createRef();",
"lineNumber": 181,
"lineNumber": 184,
"reasonCategory": "usageTrusted",
"updated": "2020-04-16T19:36:47.586Z"
"updated": "2020-04-30T15:59:13.160Z"
},
{
"rule": "React-createRef",
"path": "ts/components/conversation/Message.tsx",
"line": " > = React.createRef();",
"lineNumber": 185,
"lineNumber": 188,
"reasonCategory": "usageTrusted",
"updated": "2020-04-16T19:36:47.586Z"
"updated": "2020-04-30T15:59:13.160Z"
},
{
"rule": "React-createRef",
@ -11784,4 +11784,4 @@
"reasonCategory": "falseMatch",
"updated": "2020-04-05T23:45:16.746Z"
}
]
]

3
ts/window.d.ts vendored
View File

@ -47,6 +47,9 @@ declare global {
ConversationController: ConversationControllerType;
WebAPI: WebAPIConnectType;
Whisper: WhisperType;
// Flags
REACT_ANY_EMOJI: boolean;
}
}