-
Notifications
You must be signed in to change notification settings - Fork 3k
/
Copy pathindex.js
executable file
·437 lines (382 loc) · 17.8 KB
/
index.js
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
import React, {Component} from 'react';
import {View, FlatList} from 'react-native';
import PropTypes from 'prop-types';
import _ from 'underscore';
import CONST from '../../../../CONST';
import styles from '../../../../styles/styles';
import * as StyleUtils from '../../../../styles/StyleUtils';
import themeColors from '../../../../styles/themes/default';
import emojis from '../../../../../assets/emojis';
import EmojiPickerMenuItem from '../EmojiPickerMenuItem';
import Text from '../../../../components/Text';
import TextInputFocusable from '../../../../components/TextInputFocusable';
import withWindowDimensions, {windowDimensionsPropTypes} from '../../../../components/withWindowDimensions';
import withLocalize, {withLocalizePropTypes} from '../../../../components/withLocalize';
import compose from '../../../../libs/compose';
import getOperatingSystem from '../../../../libs/getOperatingSystem';
import EmojiSkinToneList from '../EmojiSkinToneList';
import * as EmojiUtils from '../../../../libs/EmojiUtils';
const propTypes = {
/** Function to add the selected emoji to the main compose text input */
onEmojiSelected: PropTypes.func.isRequired,
/** The ref to the search input (may be null on small screen widths) */
forwardedRef: PropTypes.func,
/** Stores user's preferred skin tone */
preferredSkinTone: PropTypes.oneOfType([PropTypes.number, PropTypes.string]).isRequired,
/** Function to sync the selected skin tone with parent, onyx and nvp */
updatePreferredSkinTone: PropTypes.func,
/** User's frequently used emojis */
frequentlyUsedEmojis: PropTypes.arrayOf(PropTypes.shape({
code: PropTypes.string.isRequired,
keywords: PropTypes.arrayOf(PropTypes.string),
})).isRequired,
/** Props related to the dimensions of the window */
...windowDimensionsPropTypes,
...withLocalizePropTypes,
};
const defaultProps = {
forwardedRef: () => {},
updatePreferredSkinTone: undefined,
};
class EmojiPickerMenu extends Component {
constructor(props) {
super(props);
// Ref for the emoji search input
this.searchInput = undefined;
// Ref for emoji FlatList
this.emojiList = undefined;
// This is the number of columns in each row of the picker.
// Because of how flatList implements these rows, each row is an index rather than each element
// For this reason to make headers work, we need to have the header be the only rendered element in its row
// If this number is changed, emojis.js will need to be updated to have the proper number of spacer elements
// around each header.
this.numColumns = CONST.EMOJI_NUM_PER_ROW;
const allEmojis = EmojiUtils.mergeEmojisWithFrequentlyUsedEmojis(emojis, this.props.frequentlyUsedEmojis);
// This is the indices of each category of emojis
// The positions are static, and are calculated as index/numColumns (8 in our case)
// This is because each row of 8 emojis counts as one index
this.unfilteredHeaderIndices = EmojiUtils.getDynamicHeaderIndices(allEmojis);
// If we're on Windows, don't display the flag emojis (the last category),
// since Windows doesn't support them (and only displays country codes instead)
this.emojis = getOperatingSystem() === CONST.OS.WINDOWS
? allEmojis.slice(0, this.unfilteredHeaderIndices.pop() * this.numColumns)
: allEmojis;
this.filterEmojis = _.debounce(this.filterEmojis.bind(this), 300);
this.highlightAdjacentEmoji = this.highlightAdjacentEmoji.bind(this);
this.scrollToHighlightedIndex = this.scrollToHighlightedIndex.bind(this);
this.setupEventHandlers = this.setupEventHandlers.bind(this);
this.cleanupEventHandlers = this.cleanupEventHandlers.bind(this);
this.renderItem = this.renderItem.bind(this);
this.isMobileLandscape = this.isMobileLandscape.bind(this);
this.currentScrollOffset = 0;
this.state = {
filteredEmojis: this.emojis,
headerIndices: this.unfilteredHeaderIndices,
highlightedIndex: -1,
arePointerEventsDisabled: false,
};
}
componentDidMount() {
// This callback prop is used by the parent component using the constructor to
// get a ref to the inner textInput element e.g. if we do
// <constructor ref={el => this.textInput = el} /> this will not
// return a ref to the component, but rather the HTML element by default
if (this.props.forwardedRef && _.isFunction(this.props.forwardedRef)) {
this.props.forwardedRef(this.searchInput);
}
this.setupEventHandlers();
}
componentWillUnmount() {
this.cleanupEventHandlers();
}
/**
* Setup and attach keypress/mouse handlers for highlight navigation.
*/
setupEventHandlers() {
if (!document) {
return;
}
this.keyDownHandler = (keyBoardEvent) => {
if (keyBoardEvent.key.startsWith('Arrow')) {
// Move the highlight when arrow keys are pressed
this.highlightAdjacentEmoji(keyBoardEvent.key);
return;
}
// Select the currently highlighted emoji if enter is pressed
if (keyBoardEvent.key === 'Enter' && this.state.highlightedIndex !== -1) {
this.props.onEmojiSelected(this.state.filteredEmojis[this.state.highlightedIndex].code, this.state.filteredEmojis[this.state.highlightedIndex]);
return;
}
// We allow typing in the search box if any key is pressed apart from Arrow keys.
if (this.searchInput && !this.searchInput.isFocused()) {
this.setState({selectTextOnFocus: false});
this.searchInput.value = '';
this.searchInput.focus();
// Re-enable selection on the searchInput
this.setState({selectTextOnFocus: true});
}
};
// Keyboard events are not bubbling on TextInput in RN-Web, Bubbling was needed for this event to trigger
// event handler attached to document root. To fix this, trigger event handler in Capture phase.
document.addEventListener('keydown', this.keyDownHandler, true);
// Re-enable pointer events and hovering over EmojiPickerItems when the mouse moves
this.mouseMoveHandler = () => {
if (!this.state.arePointerEventsDisabled) {
return;
}
this.setState({arePointerEventsDisabled: false});
};
document.addEventListener('mousemove', this.mouseMoveHandler);
}
/**
* Cleanup all mouse/keydown event listeners that we've set up
*/
cleanupEventHandlers() {
if (!document) {
return;
}
document.removeEventListener('keydown', this.keyDownHandler, true);
document.removeEventListener('mousemove', this.mouseMoveHandler);
}
/**
* Highlights emojis adjacent to the currently highlighted emoji depending on the arrowKey
* @param {String} arrowKey
*/
highlightAdjacentEmoji(arrowKey) {
const firstNonHeaderIndex = this.state.filteredEmojis.length === this.emojis.length ? this.numColumns : 0;
// Arrow Down enable arrow navigation when search is focused
if (this.searchInput && this.searchInput.isFocused() && this.state.filteredEmojis.length) {
if (arrowKey !== 'ArrowDown') {
return;
}
this.searchInput.blur();
// We only want to hightlight the Emoji if none was highlighted already
// If we already have a highlighted Emoji, lets just skip the first navigation
if (this.state.highlightedIndex !== -1) {
return;
}
}
// If nothing is highlighted and an arrow key is pressed
// select the first emoji
if (this.state.highlightedIndex === -1) {
this.setState({highlightedIndex: firstNonHeaderIndex});
this.scrollToHighlightedIndex();
return;
}
let newIndex = this.state.highlightedIndex;
const move = (steps, boundsCheck, onBoundReached = () => {}) => {
if (boundsCheck()) {
onBoundReached();
return;
}
// Move in the prescribed direction until we reach an element that isn't a header
const isHeader = e => e.header || e.code === CONST.EMOJI_SPACER;
do {
newIndex += steps;
} while (isHeader(this.state.filteredEmojis[newIndex]));
};
switch (arrowKey) {
case 'ArrowDown':
move(
this.numColumns,
() => this.state.highlightedIndex + this.numColumns > this.state.filteredEmojis.length - 1,
);
break;
case 'ArrowLeft':
move(-1, () => this.state.highlightedIndex - 1 < firstNonHeaderIndex);
break;
case 'ArrowRight':
move(1, () => this.state.highlightedIndex + 1 > this.state.filteredEmojis.length - 1);
break;
case 'ArrowUp':
move(
-this.numColumns,
() => this.state.highlightedIndex - this.numColumns < firstNonHeaderIndex,
() => {
if (!this.searchInput) {
return;
}
// Reaching start of the list, arrow up set the focus to searchInput.
this.searchInput.focus();
newIndex = -1;
},
);
break;
default:
break;
}
// Actually highlight the new emoji and scroll to it if the index was changed
if (newIndex !== this.state.highlightedIndex) {
this.setState({highlightedIndex: newIndex});
this.scrollToHighlightedIndex();
}
}
/**
* Calculates the required scroll offset (aka distance from top) and scrolls the FlatList to the highlighted emoji
* if any portion of it falls outside of the window.
* Doing this because scrollToIndex doesn't work as expected.
*/
scrollToHighlightedIndex() {
// If there are headers in the emoji array, so we need to offset by their heights as well
let numHeaders = 0;
if (this.state.filteredEmojis.length === this.emojis.length) {
numHeaders = _.filter(this.unfilteredHeaderIndices, i => this.state.highlightedIndex > i * this.numColumns).length;
}
// Calculate the scroll offset at the bottom of the currently highlighted emoji
// (subtract numHeaders because the highlightedIndex includes them, and add 1 to include the current row)
const numEmojiRows = (Math.floor(this.state.highlightedIndex / this.numColumns) - numHeaders) + 1;
// The scroll offsets at the top and bottom of the highlighted emoji
const offsetAtEmojiBottom = ((numHeaders) * CONST.EMOJI_PICKER_HEADER_HEIGHT)
+ (numEmojiRows * CONST.EMOJI_PICKER_ITEM_HEIGHT);
const offsetAtEmojiTop = offsetAtEmojiBottom - CONST.EMOJI_PICKER_ITEM_HEIGHT;
// Scroll to fit the entire highlighted emoji into the window if we need to
let targetOffset = this.currentScrollOffset;
if (offsetAtEmojiBottom - this.currentScrollOffset >= CONST.NON_NATIVE_EMOJI_PICKER_LIST_HEIGHT) {
targetOffset = offsetAtEmojiBottom - CONST.NON_NATIVE_EMOJI_PICKER_LIST_HEIGHT;
} else if (offsetAtEmojiTop - CONST.EMOJI_PICKER_ITEM_HEIGHT <= this.currentScrollOffset) {
targetOffset = offsetAtEmojiTop - CONST.EMOJI_PICKER_ITEM_HEIGHT;
}
if (targetOffset !== this.currentScrollOffset) {
// Disable pointer events so that onHover doesn't get triggered when the items move while we're scrolling
if (!this.state.arePointerEventsDisabled) {
this.setState({arePointerEventsDisabled: true});
}
this.emojiList.scrollToOffset({offset: targetOffset, animated: false});
}
}
/**
* Filter the entire list of emojis to only emojis that have the search term in their keywords
*
* @param {String} searchTerm
*/
filterEmojis(searchTerm) {
const normalizedSearchTerm = searchTerm.toLowerCase().trim();
if (normalizedSearchTerm === '') {
// There are no headers when searching, so we need to re-make them sticky when there is no search term
this.setState({
filteredEmojis: this.emojis,
headerIndices: this.unfilteredHeaderIndices,
highlightedIndex: this.numColumns,
});
return;
}
const newFilteredEmojiList = _.filter(this.emojis, emoji => (
!emoji.header
&& emoji.code !== CONST.EMOJI_SPACER
&& _.find(emoji.keywords, keyword => keyword.includes(normalizedSearchTerm))
));
// Remove sticky header indices. There are no headers while searching and we don't want to make emojis sticky
this.setState({filteredEmojis: newFilteredEmojiList, headerIndices: [], highlightedIndex: 0});
}
/**
* Check if its a landscape mode of mobile device
*
* @returns {Boolean}
*/
isMobileLandscape() {
return this.props.isSmallScreenWidth && this.props.windowWidth >= this.props.windowHeight;
}
/**
* Given an emoji item object, render a component based on its type.
* Items with the code "SPACER" return nothing and are used to fill rows up to 8
* so that the sticky headers function properly.
*
* @param {Object} item
* @param {Number} index
* @returns {*}
*/
renderItem({item, index}) {
const {code, header, types} = item;
if (code === CONST.EMOJI_SPACER) {
return null;
}
if (header) {
return (
<Text style={styles.emojiHeaderStyle}>
{code}
</Text>
);
}
const emojiCode = types && types[this.props.preferredSkinTone]
? types[this.props.preferredSkinTone]
: code;
return (
<EmojiPickerMenuItem
onPress={emoji => this.props.onEmojiSelected(emoji, item)}
onHover={() => this.setState({highlightedIndex: index})}
emoji={emojiCode}
isHighlighted={index === this.state.highlightedIndex}
/>
);
}
render() {
return (
<View
style={[styles.emojiPickerContainer, StyleUtils.getEmojiPickerStyle(this.props.isSmallScreenWidth)]}
pointerEvents={this.state.arePointerEventsDisabled ? 'none' : 'auto'}
>
{!this.props.isSmallScreenWidth && (
<View style={[styles.pt4, styles.ph4, styles.pb1]}>
<TextInputFocusable
textAlignVertical="top"
placeholder={this.props.translate('common.search')}
placeholderTextColor={themeColors.textSupporting}
onChangeText={this.filterEmojis}
style={styles.textInput}
defaultValue=""
ref={el => this.searchInput = el}
autoFocus
selectTextOnFocus={this.state.selectTextOnFocus}
/>
</View>
)}
{this.state.filteredEmojis.length === 0
? (
<Text
style={[
styles.disabledText,
styles.emojiPickerList,
styles.dFlex,
styles.alignItemsCenter,
styles.justifyContentCenter,
this.isMobileLandscape() && styles.emojiPickerListLandscape,
]}
>
{this.props.translate('common.noResultsFound')}
</Text>
)
: (
<FlatList
ref={el => this.emojiList = el}
data={this.state.filteredEmojis}
renderItem={this.renderItem}
keyExtractor={item => `emoji_picker_${item.code}`}
numColumns={this.numColumns}
style={[
styles.emojiPickerList,
this.isMobileLandscape() && styles.emojiPickerListLandscape,
]}
extraData={
[this.state.filteredEmojis, this.state.highlightedIndex, this.props.preferredSkinTone]
}
stickyHeaderIndices={this.state.headerIndices}
onScroll={e => this.currentScrollOffset = e.nativeEvent.contentOffset.y}
/>
)}
<EmojiSkinToneList
updatePreferredSkinTone={this.props.updatePreferredSkinTone}
preferredSkinTone={this.props.preferredSkinTone}
/>
</View>
);
}
}
EmojiPickerMenu.propTypes = propTypes;
EmojiPickerMenu.defaultProps = defaultProps;
export default compose(
withWindowDimensions,
withLocalize,
)(React.forwardRef((props, ref) => (
// eslint-disable-next-line react/jsx-props-no-spreading
<EmojiPickerMenu {...props} forwardedRef={ref} />
)));