From 0865a5481c8175ce0c86a08b219f23c4afcf3de1 Mon Sep 17 00:00:00 2001 From: Ken Powers Date: Tue, 5 May 2020 15:49:34 -0400 Subject: [PATCH] Pre-alpha: React with any emoji, behind flag --- _locales/en/messages.json | 4 + images/any-emoji-32-hover.svg | 6 + images/any-emoji-32.svg | 6 + preload.js | 2 + stylesheets/_modules.scss | 17 +++ ts/components/AvatarPopup.tsx | 2 +- ts/components/ShortcutGuide.tsx | 2 +- .../conversation/Message.stories.tsx | 25 ++++- ts/components/conversation/Message.tsx | 11 +- ts/components/conversation/ReactionPicker.md | 24 ---- .../conversation/ReactionPicker.stories.tsx | 51 +++++++++ ts/components/conversation/ReactionPicker.tsx | 62 ++++++++++- ts/components/conversation/ReactionViewer.tsx | 2 +- ts/components/conversation/TimelineItem.md | 51 --------- .../conversation/TimelineItem.stories.tsx | 104 ++++++++++++++++++ ts/components/conversation/TimelineItem.tsx | 5 +- ts/components/emoji/EmojiButton.md | 56 ---------- ts/components/emoji/EmojiButton.stories.tsx | 66 +++++++++++ ts/components/emoji/EmojiPicker.md | 60 ---------- ts/components/emoji/EmojiPicker.stories.tsx | 71 ++++++++++++ ts/components/emoji/EmojiPicker.tsx | 36 +++--- ts/components/stickers/StickerPicker.tsx | 2 +- .../stickers/StickerPreviewModal.tsx | 2 +- ts/state/ducks/emojis.ts | 21 ++-- ts/state/ducks/items.ts | 16 +++ ts/state/smart/CompositionArea.tsx | 2 +- ts/state/smart/EmojiPicker.tsx | 57 ++++++++++ ts/state/smart/Timeline.tsx | 18 +++ ts/{components => util}/hooks.ts | 12 ++ ts/util/lint/exceptions.json | 10 +- ts/window.d.ts | 3 + 31 files changed, 572 insertions(+), 234 deletions(-) create mode 100644 images/any-emoji-32-hover.svg create mode 100644 images/any-emoji-32.svg delete mode 100644 ts/components/conversation/ReactionPicker.md create mode 100644 ts/components/conversation/ReactionPicker.stories.tsx delete mode 100644 ts/components/conversation/TimelineItem.md create mode 100644 ts/components/conversation/TimelineItem.stories.tsx delete mode 100644 ts/components/emoji/EmojiButton.md create mode 100644 ts/components/emoji/EmojiButton.stories.tsx delete mode 100644 ts/components/emoji/EmojiPicker.md create mode 100644 ts/components/emoji/EmojiPicker.stories.tsx create mode 100644 ts/state/smart/EmojiPicker.tsx rename ts/{components => util}/hooks.ts (68%) diff --git a/_locales/en/messages.json b/_locales/en/messages.json index 7edc4abd4..40f907898 100644 --- a/_locales/en/messages.json +++ b/_locales/en/messages.json @@ -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" diff --git a/images/any-emoji-32-hover.svg b/images/any-emoji-32-hover.svg new file mode 100644 index 000000000..5017da6bf --- /dev/null +++ b/images/any-emoji-32-hover.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/images/any-emoji-32.svg b/images/any-emoji-32.svg new file mode 100644 index 000000000..812437005 --- /dev/null +++ b/images/any-emoji-32.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/preload.js b/preload.js index f791b4ce1..3b4fe9e82 100644 --- a/preload.js +++ b/preload.js @@ -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; diff --git a/stylesheets/_modules.scss b/stylesheets/_modules.scss index 083403f30..f8a8a975b 100644 --- a/stylesheets/_modules.scss +++ b/stylesheets/_modules.scss @@ -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 { diff --git a/ts/components/AvatarPopup.tsx b/ts/components/AvatarPopup.tsx index 876c95e8b..13b643be0 100644 --- a/ts/components/AvatarPopup.tsx +++ b/ts/components/AvatarPopup.tsx @@ -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'; diff --git a/ts/components/ShortcutGuide.tsx b/ts/components/ShortcutGuide.tsx index 4e5017c88..f973f06cf 100644 --- a/ts/components/ShortcutGuide.tsx +++ b/ts/components/ShortcutGuide.tsx @@ -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 = { diff --git a/ts/components/conversation/Message.stories.tsx b/ts/components/conversation/Message.stories.tsx index b8e4ae84b..a6989cfb1 100644 --- a/ts/components/conversation/Message.stories.tsx +++ b/ts/components/conversation/Message.stories.tsx @@ -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 = [ ], ]; +const renderEmojiPicker: AllProps['renderEmojiPicker'] = ({ + onClose, + onPickEmoji, + ref, +}) => ( + +); + stories.forEach(([chapterTitle, propsArr]) => book.add(chapterTitle, () => propsArr.map( @@ -1294,6 +1316,7 @@ stories.forEach(([chapterTitle, propsArr]) => {...dataProps} {...makeActionProps()} {...makeHouseKeepingProps()} + renderEmojiPicker={renderEmojiPicker} /> diff --git a/ts/components/conversation/Message.tsx b/ts/components/conversation/Message.tsx index 612319761..79021ca6e 100644 --- a/ts/components/conversation/Message.tsx +++ b/ts/components/conversation/Message.tsx @@ -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; interface State { expiring: boolean; @@ -1001,9 +1004,11 @@ export class Message extends React.PureComponent { canReply, direction, disableMenu, + i18n, id, isSticker, isTapToView, + renderEmojiPicker, replyToMessage, } = this.props; @@ -1121,6 +1126,7 @@ export class Message extends React.PureComponent { {({ ref, style }) => ( { remove: emoji === this.props.selectedReaction, }); }} + renderEmojiPicker={renderEmojiPicker} /> )} , diff --git a/ts/components/conversation/ReactionPicker.md b/ts/components/conversation/ReactionPicker.md deleted file mode 100644 index 1c8ffce5e..000000000 --- a/ts/components/conversation/ReactionPicker.md +++ /dev/null @@ -1,24 +0,0 @@ -### Reaction Picker - -#### Base - -```jsx - - console.log(`Picked reaction: ${e}`)} /> - -``` - -#### Selected - -```jsx - - {['❤️', '👍', '👎', '😂', '😮', '😢', '😡'].map(e => ( -
- console.log(`Picked reaction: ${e}`)} - /> -
- ))} -
-``` diff --git a/ts/components/conversation/ReactionPicker.stories.tsx b/ts/components/conversation/ReactionPicker.stories.tsx new file mode 100644 index 000000000..f434802a3 --- /dev/null +++ b/ts/components/conversation/ReactionPicker.stories.tsx @@ -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, +}) => ( + +); + +storiesOf('Components/Conversation/ReactionPicker', module) + .add('Base', () => { + return ( + + ); + }) + .add('Selected Reaction', () => { + return ['❤️', '👍', '👎', '😂', '😮', '😢', '😡'].map(e => ( +
+ +
+ )); + }); diff --git a/ts/components/conversation/ReactionPicker.tsx b/ts/components/conversation/ReactionPicker.tsx index bd61610c4..5fe073b18 100644 --- a/ts/components/conversation/ReactionPicker.tsx +++ b/ts/components/conversation/ReactionPicker.tsx @@ -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 & + Pick & { + ref: React.Ref; + }; export type OwnProps = { + i18n: LocalizerType; selected?: string; onClose?: () => unknown; onPick: (emoji: string) => unknown; + renderEmojiPicker: (props: RenderEmojiPickerProps) => React.ReactElement; }; export type Props = OwnProps & Pick, 'style'>; const emojis = ['❤️', '👍', '👎', '😂', '😮', '😢', '😡']; +const getEmojis = () => + emojis.slice(0, window.REACT_ANY_EMOJI ? emojis.length - 1 : emojis.length); + export const ReactionPicker = React.forwardRef( - ({ selected, onClose, onPick, ...rest }, ref) => { + ({ i18n, selected, onClose, onPick, renderEmojiPicker, style }, ref) => { + const [pickingOther, setPickingOther] = React.useState(false); const focusRef = React.useRef(null); // Handle escape key @@ -32,12 +46,24 @@ export const ReactionPicker = React.forwardRef( }; }, [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 ( -
- {emojis.map((emoji, index) => { + const otherSelected = selected && !getEmojis().includes(selected); + + return pickingOther ? ( + renderEmojiPicker({ onPickEmoji, onClose, style, ref }) + ) : ( +
+ {getEmojis().map((emoji, index) => { const maybeFocusRef = index === 0 ? focusRef : undefined; return ( @@ -55,6 +81,7 @@ export const ReactionPicker = React.forwardRef( e.stopPropagation(); onPick(emoji); }} + title={emoji} >
@@ -62,6 +89,31 @@ export const ReactionPicker = React.forwardRef( ); })} + {window.REACT_ANY_EMOJI ? ( + + ) : null}
); } diff --git a/ts/components/conversation/ReactionViewer.tsx b/ts/components/conversation/ReactionViewer.tsx index 220b146db..a41708a10 100644 --- a/ts/components/conversation/ReactionViewer.tsx +++ b/ts/components/conversation/ReactionViewer.tsx @@ -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; diff --git a/ts/components/conversation/TimelineItem.md b/ts/components/conversation/TimelineItem.md deleted file mode 100644 index 15ed8defd..000000000 --- a/ts/components/conversation/TimelineItem.md +++ /dev/null @@ -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: '🔥', - }, -}; - -; -``` - -### A notification - -```jsx -const item = { - type: 'timerNotification', - data: { - type: 'fromOther', - phoneNumber: '(202) 555-0000', - timespan: '1 hour', - }, -}; - -; -``` - -### Unknown type - -```jsx -const item = { - type: 'random', - data: { - somethin: 'somethin', - }, -}; - -; -``` - -### Missing itme - -```jsx - -``` diff --git a/ts/components/conversation/TimelineItem.stories.tsx b/ts/components/conversation/TimelineItem.stories.tsx new file mode 100644 index 000000000..d6ee52916 --- /dev/null +++ b/ts/components/conversation/TimelineItem.stories.tsx @@ -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, +}) => ( + +); + +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 ; + }) + .add('Notification', () => { + const item = { + type: 'timerNotification', + data: { + type: 'fromOther', + phoneNumber: '(202) 555-0000', + timespan: '1 hour', + }, + } as TimelineItemProps['item']; + + return ; + }) + .add('Unknown Type', () => { + // @ts-ignore: intentional + const item = { + type: 'random', + data: { + somethin: 'somethin', + }, + } as TimelineItemProps['item']; + + return ; + }) + .add('Missing Item', () => { + // @ts-ignore: intentional + const item = null as TimelineItemProps['item']; + + return ; + }); diff --git a/ts/components/conversation/TimelineItem.tsx b/ts/components/conversation/TimelineItem.tsx index 96df91991..a96d16458 100644 --- a/ts/components/conversation/TimelineItem.tsx +++ b/ts/components/conversation/TimelineItem.tsx @@ -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; export class TimelineItem extends React.PureComponent { public render() { diff --git a/ts/components/emoji/EmojiButton.md b/ts/components/emoji/EmojiButton.md deleted file mode 100644 index 675dfd2d6..000000000 --- a/ts/components/emoji/EmojiButton.md +++ /dev/null @@ -1,56 +0,0 @@ -#### Default - -```jsx - -
- 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', - ]} - /> -
-
-``` diff --git a/ts/components/emoji/EmojiButton.stories.tsx b/ts/components/emoji/EmojiButton.stories.tsx new file mode 100644 index 000000000..b4865ac23 --- /dev/null +++ b/ts/components/emoji/EmojiButton.stories.tsx @@ -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 ( +
+ +
+ ); +}); diff --git a/ts/components/emoji/EmojiPicker.md b/ts/components/emoji/EmojiPicker.md deleted file mode 100644 index 9cbdbf9b1..000000000 --- a/ts/components/emoji/EmojiPicker.md +++ /dev/null @@ -1,60 +0,0 @@ -#### Default - -```jsx - - 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', - ]} - /> - -``` - -#### No Recents - -```jsx - - console.log('onPickEmoji', e)} - onSetSkinTone={t => console.log('onSetSkinTone', t)} - onClose={() => console.log('onClose')} - recentEmojis={[]} - /> - -``` diff --git a/ts/components/emoji/EmojiPicker.stories.tsx b/ts/components/emoji/EmojiPicker.stories.tsx new file mode 100644 index 000000000..f8c458d62 --- /dev/null +++ b/ts/components/emoji/EmojiPicker.stories.tsx @@ -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 ( + + ); + }) + .add('No Recents', () => { + return ( + + ); + }); diff --git a/ts/components/emoji/EmojiPicker.tsx b/ts/components/emoji/EmojiPicker.tsx index a4555d175..4e4a367b9 100644 --- a/ts/components/emoji/EmojiPicker.tsx +++ b/ts/components/emoji/EmojiPicker.tsx @@ -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; - readonly onClose: () => unknown; + readonly onClose?: () => unknown; }; export type Props = OwnProps & Pick, '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 ) => { 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) => { - const { category } = currentTarget.dataset; + (e: React.MouseEvent) => { + e.stopPropagation(); + const { category } = e.currentTarget.dataset; if (category) { setSelectedCategory(category); setScrollToRow(catToRowOffsets[category]); diff --git a/ts/components/stickers/StickerPicker.tsx b/ts/components/stickers/StickerPicker.tsx index 8945f00e9..1daa05e17 100644 --- a/ts/components/stickers/StickerPicker.tsx +++ b/ts/components/stickers/StickerPicker.tsx @@ -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'; diff --git a/ts/components/stickers/StickerPreviewModal.tsx b/ts/components/stickers/StickerPreviewModal.tsx index f035f0931..5be8f9ecd 100644 --- a/ts/components/stickers/StickerPreviewModal.tsx +++ b/ts/components/stickers/StickerPreviewModal.tsx @@ -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; diff --git a/ts/state/ducks/emojis.ts b/ts/state/ducks/emojis.ts index 1f689ce49..a4229c6a7 100644 --- a/ts/state/ducks/emojis.ts +++ b/ts/state/ducks/emojis.ts @@ -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; + payload: Promise; }; -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 { +async function doUseEmoji(shortName: string): Promise { await updateEmojiUsage(shortName); return shortName; diff --git a/ts/state/ducks/items.ts b/ts/state/ducks/items.ts index 98a8e1d52..6348bff16 100644 --- a/ts/state/ducks/items.ts +++ b/ts/state/ducks/items.ts @@ -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); diff --git a/ts/state/smart/CompositionArea.tsx b/ts/state/smart/CompositionArea.tsx index 287e386a3..e6c560fd9 100644 --- a/ts/state/smart/CompositionArea.tsx +++ b/ts/state/smart/CompositionArea.tsx @@ -81,7 +81,7 @@ const dispatchPropsMap = { mapDispatchToProps.removeItem('showStickersIntroduction'), clearShowPickerHint: () => mapDispatchToProps.removeItem('showStickerPickerHint'), - onPickEmoji: mapDispatchToProps.useEmoji, + onPickEmoji: mapDispatchToProps.onUseEmoji, }; const smart = connect(mapStateToProps, dispatchPropsMap); diff --git a/ts/state/smart/EmojiPicker.tsx b/ts/state/smart/EmojiPicker.tsx new file mode 100644 index 000000000..8341bddc2 --- /dev/null +++ b/ts/state/smart/EmojiPicker.tsx @@ -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 +>(({ onPickEmoji, onClose, style }, ref) => { + const i18n = useSelector(getIntl); + const skinTone = useSelector(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 ( + + ); +}); diff --git a/ts/state/smart/Timeline.tsx b/ts/state/smart/Timeline.tsx index ab43c0636..2b9fb1049 100644 --- a/ts/state/smart/Timeline.tsx +++ b/ts/state/smart/Timeline.tsx @@ -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 ( + ); } diff --git a/ts/components/hooks.ts b/ts/util/hooks.ts similarity index 68% rename from ts/components/hooks.ts rename to ts/util/hooks.ts index 1506683cc..ff9f55d68 100644 --- a/ts/components/hooks.ts +++ b/ts/util/hooks.ts @@ -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 = ( + actions: T +) => { + const dispatch = useDispatch(); + + return React.useMemo(() => { + return bindActionCreators(actions, dispatch); + }, [dispatch]); +}; diff --git a/ts/util/lint/exceptions.json b/ts/util/lint/exceptions.json index 6adebec22..95be90125 100644 --- a/ts/util/lint/exceptions.json +++ b/ts/util/lint/exceptions.json @@ -11551,17 +11551,17 @@ "rule": "React-createRef", "path": "ts/components/conversation/Message.tsx", "line": " public audioRef: React.RefObject = 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" } -] \ No newline at end of file +] diff --git a/ts/window.d.ts b/ts/window.d.ts index adf8e502e..fdb0efb5e 100644 --- a/ts/window.d.ts +++ b/ts/window.d.ts @@ -47,6 +47,9 @@ declare global { ConversationController: ConversationControllerType; WebAPI: WebAPIConnectType; Whisper: WhisperType; + + // Flags + REACT_ANY_EMOJI: boolean; } }