Add recently used section and scroll to category

Signed-off-by: Tulir Asokan <tulir@maunium.net>
This commit is contained in:
Tulir Asokan 2019-10-14 19:40:57 +03:00
parent 497b779334
commit 088c9bff9e
7 changed files with 115 additions and 34 deletions

View file

@ -49,7 +49,11 @@ limitations under the License.
fill: $primary-fg-color; fill: $primary-fg-color;
} }
&:hover { &:disabled svg {
fill: $focus-bg-color;
}
&:not(:disabled):hover {
background-color: $focus-bg-color; background-color: $focus-bg-color;
border-bottom: 2px solid $button-bg-color; border-bottom: 2px solid $button-bg-color;
} }
@ -73,11 +77,17 @@ limitations under the License.
border-radius: 4px 0; border-radius: 4px 0;
} }
svg { button {
align-self: center; border: none;
width: 16px; background-color: inherit;
height: 16px; padding: 0;
margin: 8px; margin: 8px;
svg {
align-self: center;
width: 16px;
height: 16px;
}
} }
} }

View file

@ -23,31 +23,27 @@ class Category extends React.PureComponent {
static propTypes = { static propTypes = {
emojis: PropTypes.arrayOf(PropTypes.object).isRequired, emojis: PropTypes.arrayOf(PropTypes.object).isRequired,
name: PropTypes.string.isRequired, name: PropTypes.string.isRequired,
id: PropTypes.string.isRequired,
onMouseEnter: PropTypes.func.isRequired, onMouseEnter: PropTypes.func.isRequired,
onMouseLeave: PropTypes.func.isRequired, onMouseLeave: PropTypes.func.isRequired,
onClick: PropTypes.func.isRequired, onClick: PropTypes.func.isRequired,
filter: PropTypes.string,
}; };
render() { render() {
const { onClick, onMouseEnter, onMouseLeave, emojis, name, filter } = this.props; const { onClick, onMouseEnter, onMouseLeave, emojis, name, filter } = this.props;
if (!emojis || emojis.length === 0) {
const Emoji = sdk.getComponent("emojipicker.Emoji");
const renderedEmojis = (emojis || []).map(emoji => !filter || emoji.filterString.includes(filter) ? (
<Emoji key={emoji.hexcode} emoji={emoji}
onClick={onClick} onMouseEnter={onMouseEnter} onMouseLeave={onMouseLeave} />
) : null).filter(component => component !== null);
if (renderedEmojis.length === 0) {
return null; return null;
} }
const Emoji = sdk.getComponent("emojipicker.Emoji");
return ( return (
<section className="mx_EmojiPicker_category"> <section className="mx_EmojiPicker_category" data-category-id={this.props.id}>
<h2 className="mx_EmojiPicker_category_label"> <h2 className="mx_EmojiPicker_category_label">
{name} {name}
</h2> </h2>
<ul className="mx_EmojiPicker_list"> <ul className="mx_EmojiPicker_list">
{renderedEmojis} {emojis.map(emoji => <Emoji key={emoji.hexcode} emoji={emoji}
onClick={onClick} onMouseEnter={onMouseEnter} onMouseLeave={onMouseLeave} />)}
</ul> </ul>
</section> </section>
) )

View file

@ -16,11 +16,13 @@ limitations under the License.
import React from 'react'; import React from 'react';
import PropTypes from 'prop-types'; import PropTypes from 'prop-types';
import EMOJIBASE from 'emojibase-data/en/compact.json'; import EMOJIBASE from 'emojibase-data/en/compact.json';
import sdk from '../../../index'; import sdk from '../../../index';
import { _t } from '../../../languageHandler'; import { _t } from '../../../languageHandler';
import * as recent from './recent';
const EMOJIBASE_CATEGORY_IDS = [ const EMOJIBASE_CATEGORY_IDS = [
"people", // smileys "people", // smileys
"people", // actually people "people", // actually people
@ -43,11 +45,15 @@ const DATA_BY_CATEGORY = {
"objects": [], "objects": [],
"symbols": [], "symbols": [],
"flags": [], "flags": [],
"control": [],
}; };
const DATA_BY_EMOJI = {};
EMOJIBASE.forEach(emoji => { EMOJIBASE.forEach(emoji => {
DATA_BY_CATEGORY[EMOJIBASE_CATEGORY_IDS[emoji.group]].push(emoji); DATA_BY_EMOJI[emoji.unicode] = emoji;
const categoryId = EMOJIBASE_CATEGORY_IDS[emoji.group];
if (DATA_BY_CATEGORY.hasOwnProperty(categoryId)) {
DATA_BY_CATEGORY[categoryId].push(emoji);
}
// This is used as the string to match the query against when filtering emojis. // This is used as the string to match the query against when filtering emojis.
emoji.filterString = `${emoji.annotation}\n${emoji.shortcodes.join('\n')}}\n${emoji.emoticon || ''}`; emoji.filterString = `${emoji.annotation}\n${emoji.shortcodes.join('\n')}}\n${emoji.emoticon || ''}`;
}); });
@ -65,49 +71,76 @@ class EmojiPicker extends React.Component {
previewEmoji: null, previewEmoji: null,
}; };
this.bodyRef = React.createRef();
this.recentlyUsed = recent.get().map(unicode => DATA_BY_EMOJI[unicode]);
this.memoizedDataByCategory = {
recent: this.recentlyUsed,
...DATA_BY_CATEGORY,
};
this.categories = [{ this.categories = [{
id: "recent", id: "recent",
name: _t("Frequently Used"), name: _t("Frequently Used"),
enabled: this.recentlyUsed.length > 0,
}, { }, {
id: "people", id: "people",
name: _t("Smileys & People"), name: _t("Smileys & People"),
enabled: true,
}, { }, {
id: "nature", id: "nature",
name: _t("Animals & Nature"), name: _t("Animals & Nature"),
enabled: true,
}, { }, {
id: "foods", id: "foods",
name: _t("Food & Drink"), name: _t("Food & Drink"),
enabled: true,
}, { }, {
id: "activity", id: "activity",
name: _t("Activities"), name: _t("Activities"),
enabled: true,
}, { }, {
id: "places", id: "places",
name: _t("Travel & Places"), name: _t("Travel & Places"),
enabled: true,
}, { }, {
id: "objects", id: "objects",
name: _t("Objects"), name: _t("Objects"),
enabled: true,
}, { }, {
id: "symbols", id: "symbols",
name: _t("Symbols"), name: _t("Symbols"),
enabled: true,
}, { }, {
id: "flags", id: "flags",
name: _t("Flags"), name: _t("Flags"),
enabled: true,
}]; }];
this.onChangeFilter = this.onChangeFilter.bind(this); this.onChangeFilter = this.onChangeFilter.bind(this);
this.onHoverEmoji = this.onHoverEmoji.bind(this); this.onHoverEmoji = this.onHoverEmoji.bind(this);
this.onHoverEmojiEnd = this.onHoverEmojiEnd.bind(this); this.onHoverEmojiEnd = this.onHoverEmojiEnd.bind(this);
this.onClickEmoji = this.onClickEmoji.bind(this); this.onClickEmoji = this.onClickEmoji.bind(this);
this.scrollToCategory = this.scrollToCategory.bind(this);
window.bodyRef = this.bodyRef;
} }
scrollToCategory() { scrollToCategory(category) {
// TODO const index = this.categories.findIndex(cat => cat.id === category);
this.bodyRef.current.querySelector(`[data-category-id="${category}"]`).scrollIntoView();
} }
onChangeFilter(ev) { onChangeFilter(filter) {
this.setState({ for (let [id, emojis] of Object.entries(this.memoizedDataByCategory)) {
filter: ev.target.value, // If the new filter string includes the old filter string, we don't have to re-filter the whole dataset.
}); if (!filter.includes(this.state.filter)) {
emojis = id === "recent" ? this.recentlyUsed : DATA_BY_CATEGORY[id];
}
this.memoizedDataByCategory[id] = emojis.filter(emoji => emoji.filterString.includes(filter));
this.categories.find(cat => cat.id === id).enabled = this.memoizedDataByCategory[id].length > 0;
}
this.setState({ filter });
} }
onHoverEmoji(emoji) { onHoverEmoji(emoji) {
@ -124,6 +157,10 @@ class EmojiPicker extends React.Component {
onClickEmoji(emoji) { onClickEmoji(emoji) {
this.props.onChoose(emoji.unicode); this.props.onChoose(emoji.unicode);
recent.add(emoji.unicode);
this.recentlyUsed = recent.get().map(unicode => DATA_BY_EMOJI[unicode]);
this.memoizedDataByCategory.recent = this.recentlyUsed.filter(emoji =>
emoji.filterString.includes(this.state.filter))
} }
render() { render() {
@ -136,10 +173,10 @@ class EmojiPicker extends React.Component {
<div className="mx_EmojiPicker"> <div className="mx_EmojiPicker">
<Header categories={this.categories} defaultCategory="recent" onAnchorClick={this.scrollToCategory}/> <Header categories={this.categories} defaultCategory="recent" onAnchorClick={this.scrollToCategory}/>
<Search query={this.state.filter} onChange={this.onChangeFilter}/> <Search query={this.state.filter} onChange={this.onChangeFilter}/>
<div className="mx_EmojiPicker_body"> <div className="mx_EmojiPicker_body" ref={this.bodyRef}>
{this.categories.map(category => ( {this.categories.map(category => (
<Category key={category.id} emojis={DATA_BY_CATEGORY[category.id]} name={category.name} <Category key={category.id} id={category.id} name={category.name}
filter={this.state.filter} onClick={this.onClickEmoji} emojis={this.memoizedDataByCategory[category.id]} onClick={this.onClickEmoji}
onMouseEnter={this.onHoverEmoji} onMouseLeave={this.onHoverEmojiEnd} /> onMouseEnter={this.onHoverEmoji} onMouseLeave={this.onHoverEmojiEnd} />
))} ))}
</div> </div>

View file

@ -34,8 +34,7 @@ class Header extends React.Component {
this.handleClick = this.handleClick.bind(this); this.handleClick = this.handleClick.bind(this);
} }
handleClick(ev) { handleClick(selected) {
const selected = ev.target.getAttribute("data-category-id");
this.setState({selected}); this.setState({selected});
this.props.onAnchorClick(selected); this.props.onAnchorClick(selected);
}; };
@ -44,9 +43,9 @@ class Header extends React.Component {
return ( return (
<nav className="mx_EmojiPicker_header"> <nav className="mx_EmojiPicker_header">
{this.props.categories.map(category => ( {this.props.categories.map(category => (
<button key={category.id} className={`mx_EmojiPicker_anchor ${ <button disabled={!category.enabled} key={category.id} className={`mx_EmojiPicker_anchor ${
this.state.selected === category.id ? 'mx_EmojiPicker_anchor_selected' : ''}`} this.state.selected === category.id ? 'mx_EmojiPicker_anchor_selected' : ''}`}
onClick={this.handleClick} data-category-id={category.id} title={category.name}> onClick={() => this.handleClick(category.id)} title={category.name}>
{icons.categories[category.id]()} {icons.categories[category.id]()}
</button> </button>
))} ))}

View file

@ -28,8 +28,11 @@ class Search extends React.PureComponent {
render() { render() {
return ( return (
<div className="mx_EmojiPicker_search"> <div className="mx_EmojiPicker_search">
<input type="text" placeholder="Search" value={this.props.query} onChange={this.props.onChange}/> <input type="text" placeholder="Search" value={this.props.query}
{icons.search.search()} onChange={ev => this.props.onChange(ev.target.value)}/>
<button onClick={() => this.props.onChange("")}>
{this.props.query ? icons.search.delete() : icons.search.search()}
</button>
</div> </div>
) )
} }

View file

@ -0,0 +1,35 @@
/*
Copyright 2019 Tulir Asokan
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
const REACTION_COUNT = JSON.parse(window.localStorage.mx_reaction_count || '{}');
let sorted = null;
export function add(emoji) {
const [count] = REACTION_COUNT[emoji] || [0];
REACTION_COUNT[emoji] = [count + 1, Date.now()];
window.localStorage.mx_reaction_count = JSON.stringify(REACTION_COUNT);
sorted = null;
}
export function get(limit = 24) {
if (sorted === null) {
sorted = Object.entries(REACTION_COUNT)
.sort(([, [count1, date1]], [, [count2, date2]]) =>
count2 === count1 ? date2 - date1 : count2 - count1)
.map(([emoji, count]) => emoji);
}
return sorted.slice(0, limit);
}

View file

@ -1839,5 +1839,6 @@
"Travel & Places": "Travel & Places", "Travel & Places": "Travel & Places",
"Objects": "Objects", "Objects": "Objects",
"Symbols": "Symbols", "Symbols": "Symbols",
"Flags": "Flags" "Flags": "Flags",
"React": "React"
} }