Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
40 commits
Select commit Hold shift + click to select a range
39b51e9
Add ability to highlight emoji, move around emoji picker
jasperhuangg Apr 22, 2021
657161b
Fit highlighted emoji into scroll window
jasperhuangg Apr 22, 2021
5702c12
Use React.memo
jasperhuangg Apr 22, 2021
0472f66
use onScroll
jasperhuangg Apr 22, 2021
ea40482
DRY
jasperhuangg Apr 22, 2021
d58757a
reorder functions
jasperhuangg Apr 22, 2021
6cc2cd5
Add ability to press enter to send
jasperhuangg Apr 22, 2021
5e79d43
Add consts
jasperhuangg Apr 22, 2021
8266c69
Rename const
jasperhuangg Apr 22, 2021
baf8253
Change function def
jasperhuangg Apr 22, 2021
af70691
Remove unused
jasperhuangg Apr 22, 2021
a7ebcf6
Add comment for React.memo
jasperhuangg Apr 22, 2021
5dcae44
Fix check for keypresses
jasperhuangg Apr 22, 2021
85d6788
Reorder
jasperhuangg Apr 22, 2021
bee98e0
hover to updated the highlighted index, add mode where no emoji is hi…
jasperhuangg Apr 22, 2021
afaac1d
Prevent arrow key presses from moving the cursor when they're changin…
jasperhuangg Apr 22, 2021
aba464c
style
jasperhuangg Apr 22, 2021
3370f23
style
jasperhuangg Apr 22, 2021
3c4c303
renaming and comments
jasperhuangg Apr 23, 2021
348e338
Add cleanup for event listeners
jasperhuangg Apr 23, 2021
2a58b6f
Move setState logic for shouldDisablePointerEvents into a more fittin…
jasperhuangg Apr 26, 2021
65797b9
Add comment
jasperhuangg Apr 26, 2021
a5453e3
Use this.numColumns
jasperhuangg Apr 26, 2021
f39e793
Fix comment
jasperhuangg Apr 26, 2021
a46758d
Fix comments
jasperhuangg Apr 26, 2021
a5c8f92
Fix comments
jasperhuangg Apr 26, 2021
521ff3a
Update comments
jasperhuangg Apr 26, 2021
9f48301
Update comments
jasperhuangg Apr 26, 2021
9bdedc4
Update comments
jasperhuangg Apr 26, 2021
482660a
Update comments
jasperhuangg Apr 26, 2021
c4b712d
Merge branch 'main' into jasper-emojiPickerArrowKeys
jasperhuangg Apr 27, 2021
29557e3
merge master
jasperhuangg Apr 28, 2021
902211a
Remove unused pointerEvents variable, change name of pointer events d…
jasperhuangg Apr 28, 2021
cb44670
Merge remote-tracking branch 'origin/jasper-emojiPickerArrowKeys' int…
jasperhuangg Apr 28, 2021
602f5d0
Remove whitespace
jasperhuangg Apr 28, 2021
ed346c5
Change const name
jasperhuangg Apr 30, 2021
b573d11
Get rid of touchscreen check
jasperhuangg Apr 30, 2021
dc30517
Move event handler setup into function to cleanup componentDidMount
jasperhuangg Apr 30, 2021
91d604a
Remove canUseTouchScreen
jasperhuangg May 3, 2021
6926d31
Cleanup prevent default behavior for arrow key presses on the search …
jasperhuangg May 3, 2021
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions src/CONST.js
Original file line number Diff line number Diff line change
Expand Up @@ -114,6 +114,9 @@ const CONST = {
},

EMOJI_PICKER_SIZE: 360,
NON_NATIVE_EMOJI_PICKER_LIST_HEIGHT: 300,
EMOJI_PICKER_ITEM_HEIGHT: 40,
EMOJI_PICKER_HEADER_HEIGHT: 38,

EMAIL: {
CHRONOS: 'chronos@expensify.com',
Expand Down
203 changes: 194 additions & 9 deletions src/pages/home/report/EmojiPickerMenu/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,9 @@ class EmojiPickerMenu extends Component {
// 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
Expand All @@ -45,11 +48,19 @@ class EmojiPickerMenu extends Component {
this.unfilteredHeaderIndices = [0, 33, 59, 87, 98, 120, 147];

this.filterEmojis = _.debounce(this.filterEmojis.bind(this), 300);
this.highlightAdjacentEmoji = this.highlightAdjacentEmoji.bind(this);
this.scrollToHighlightedIndex = this.scrollToHighlightedIndex.bind(this);
this.toggleArrowKeysOnSearchInput = this.toggleArrowKeysOnSearchInput.bind(this);
this.setupEventHandlers = this.setupEventHandlers.bind(this);
this.cleanupEventHandlers = this.cleanupEventHandlers.bind(this);
this.renderItem = this.renderItem.bind(this);

this.state = {
filteredEmojis: emojis,
headerIndices: this.unfilteredHeaderIndices,
highlightedIndex: -1,
currentScrollOffset: 0,
arePointerEventsDisabled: false,
};
}

Expand All @@ -61,6 +72,146 @@ class EmojiPickerMenu extends Component {
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) {
this.keyDownHandler = (keyBoardEvent) => {
if (keyBoardEvent.key.startsWith('Arrow')) {
// Depending on the position of the highlighted emoji after moving and rendering,
// toggle which arrow keys can affect the cursor position in the search input.
this.toggleArrowKeysOnSearchInput(keyBoardEvent);

// Move the highlight when arrow keys are pressed
this.highlightAdjacentEmoji(keyBoardEvent.key);
}

// 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);
}
};
document.addEventListener('keydown', this.keyDownHandler);

// Re-enable pointer events and hovering over EmojiPickerItems when the mouse moves
this.mouseMoveHandler = () => {
Comment thread
jasperhuangg marked this conversation as resolved.
if (this.state.arePointerEventsDisabled) {
this.setState({arePointerEventsDisabled: false});
}
};
document.addEventListener('mousemove', this.mouseMoveHandler);
}
}

/**
* Cleanup all mouse/keydown event listeners that we've set up
*/
cleanupEventHandlers() {
if (document) {
document.removeEventListener('keydown', this.keyDownHandler);
document.removeEventListener('mousemove', this.mouseMoveHandler);
}
}

/**
* Highlights emojis adjacent to the currently highlighted emoji depending on the arrowKey
* @param {String} arrowKey
*/
highlightAdjacentEmoji(arrowKey) {
Comment thread
jasperhuangg marked this conversation as resolved.
const firstNonHeaderIndex = this.state.filteredEmojis.length === emojis.length ? this.numColumns : 0;

// 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) => {
if (boundsCheck()) {
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]));
Comment thread
jasperhuangg marked this conversation as resolved.
};

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);
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 === emojis.length) {
numHeaders = this.unfilteredHeaderIndices
.filter(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.state.currentScrollOffset;
if (offsetAtEmojiBottom - this.state.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.state.currentScrollOffset) {
targetOffset = offsetAtEmojiTop - CONST.EMOJI_PICKER_ITEM_HEIGHT;
}
if (targetOffset !== this.state.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});
}
}

/**
Expand All @@ -72,7 +223,11 @@ class EmojiPickerMenu extends Component {
const normalizedSearchTerm = searchTerm.toLowerCase();
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: emojis, headerIndices: this.unfilteredHeaderIndices});
this.setState({
filteredEmojis: emojis,
headerIndices: this.unfilteredHeaderIndices,
highlightedIndex: this.numColumns,
});
return;
}

Expand All @@ -83,7 +238,28 @@ class EmojiPickerMenu extends Component {
));

// Remove sticky header indices. There are no headers while searching and we don't want to make emojis sticky
this.setState({filteredEmojis: newFilteredEmojiList, headerIndices: []});
this.setState({filteredEmojis: newFilteredEmojiList, headerIndices: [], highlightedIndex: 0});
}

/**
* Toggles which arrow keys can affect the cursor in the search input,
* depending on whether the arrow keys will affect the index of the highlighted emoji.
*
* @param {KeyboardEvent} arrowKeyBoardEvent
*/
toggleArrowKeysOnSearchInput(arrowKeyBoardEvent) {
let keysToIgnore = ['ArrowDown', 'ArrowRight', 'ArrowLeft', 'ArrowUp'];
if (this.state.highlightedIndex === 0 && this.state.filteredEmojis.length) {
keysToIgnore = ['ArrowDown', 'ArrowRight'];
} else if (this.state.highlightedIndex === this.state.filteredEmojis.length - 1) {
keysToIgnore = ['ArrowLeft', 'ArrowUp'];
}

// Moving the cursor is the default behavior for arrow key presses while an input is focused,
// so prevent it
if (keysToIgnore.includes(arrowKeyBoardEvent.key)) {
arrowKeyBoardEvent.preventDefault();
}
}

/**
Expand All @@ -92,32 +268,39 @@ class EmojiPickerMenu extends Component {
* so that the sticky headers function properly
*
* @param {Object} item
* @param {Number} index
* @returns {*}
*/
renderItem({item}) {
if (item.code === CONST.EMOJI_SPACER) {
renderItem({item, index}) {
const {code, header} = item;
if (code === CONST.EMOJI_SPACER) {
return null;
}

if (item.header) {
if (header) {
return (
<Text style={styles.emojiHeaderStyle}>
{item.code}
{code}
</Text>
);
}

return (
<EmojiPickerMenuItem
onPress={this.props.onEmojiSelected}
emoji={item.code}
onHover={() => this.setState({highlightedIndex: index})}
emoji={code}
isHighlighted={index === this.state.highlightedIndex}
/>
);
}

render() {
return (
<View style={styles.emojiPickerContainer}>
<View
style={styles.emojiPickerContainer}
pointerEvents={this.state.arePointerEventsDisabled ? 'none' : 'auto'}
>
{!this.props.isSmallScreenWidth && (
<View style={[styles.pt4, styles.ph4, styles.pb1]}>
<TextInputFocusable
Expand All @@ -132,13 +315,15 @@ class EmojiPickerMenu extends Component {
</View>
)}
<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}
extraData={this.state.filteredEmojis}
extraData={[this.state.filteredEmojis, this.state.highlightedIndex]}
stickyHeaderIndices={this.state.headerIndices}
onScroll={e => this.setState({currentScrollOffset: e.nativeEvent.contentOffset.y})}
/>
</View>
);
Expand Down
26 changes: 22 additions & 4 deletions src/pages/home/report/EmojiPickerMenuItem.js
Original file line number Diff line number Diff line change
Expand Up @@ -3,28 +3,46 @@ import PropTypes from 'prop-types';
import {Pressable, Text} from 'react-native';
import styles, {getButtonBackgroundColorStyle} from '../../../styles/styles';
import getButtonState from '../../../libs/getButtonState';
import Hoverable from '../../../components/Hoverable';

const propTypes = {
// The unicode that is used to display the emoji
emoji: PropTypes.string.isRequired,

// The function to call when an emoji is selected
onPress: PropTypes.func.isRequired,

// Handles what to do when we hover over this item with our cursor
onHover: PropTypes.func.isRequired,

// Whether this menu item is currently highlighted or not
isHighlighted: PropTypes.bool.isRequired,
Comment thread
jasperhuangg marked this conversation as resolved.
};

const EmojiPickerMenuItem = props => (
<Pressable
onPress={() => props.onPress(props.emoji)}
style={({hovered, pressed}) => ([
style={({
pressed,
}) => ([
styles.emojiItem,
getButtonBackgroundColorStyle(getButtonState(hovered, pressed)),
getButtonBackgroundColorStyle(getButtonState(false, pressed)),
props.isHighlighted ? styles.emojiItemHighlighted : {},
])}
>
<Text style={styles.emojiText}>{props.emoji}</Text>
<Hoverable onHoverIn={props.onHover}>
<Text style={styles.emojiText}>{props.emoji}</Text>
</Hoverable>
</Pressable>

);

EmojiPickerMenuItem.propTypes = propTypes;
EmojiPickerMenuItem.displayName = 'EmojiPickerMenuItem';

export default EmojiPickerMenuItem;
// Significantly speeds up re-renders of the EmojiPickerMenu's FlatList
// by only re-rendering at most two EmojiPickerMenuItems that are highlighted/un-highlighted per user action.
export default React.memo(
EmojiPickerMenuItem,
(prevProps, nextProps) => prevProps.isHighlighted === nextProps.isHighlighted,
);
7 changes: 7 additions & 0 deletions src/styles/styles.js
Original file line number Diff line number Diff line change
Expand Up @@ -853,7 +853,14 @@ const styles = {

emojiItem: {
width: '12.5%',
height: 40,
textAlign: 'center',
borderRadius: 8,
},

emojiItemHighlighted: {
transition: '0.2s ease',
backgroundColor: themeColors.buttonDefaultBG,
},

chatItemEmojiButton: {
Expand Down