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

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

@ -1,30 +0,0 @@
import * as React from 'react';
// Restore focus on teardown
export const useRestoreFocus = (
// The ref for the element to receive initial focus
focusRef: React.RefObject<any>,
// Allow for an optional root element that must exist
root: boolean | HTMLElement | null = true
) => {
React.useEffect(() => {
if (!root) {
return;
}
const lastFocused = document.activeElement as any;
if (focusRef.current) {
focusRef.current.focus();
}
return () => {
// This ensures that the focus is returned to
// previous element
setTimeout(() => {
if (lastFocused && lastFocused.focus) {
lastFocused.focus();
}
});
};
}, [focusRef, root]);
};

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;