2017-06-08_509bba0/509bba0_unpacked_with_node_modules/discord_app/components/EmojiPicker.js
2022-07-26 10:06:20 -07:00

723 lines
22 KiB
JavaScript
Executable file

import React from 'react';
import PureRenderMixin from 'react-addons-pure-render-mixin';
import LazyScroller, {Themes} from './common/LazyScroller';
import PopoutActionCreators from '../actions/PopoutActionCreators';
import UserSettingsModalActionCreators from '../actions/UserSettingsModalActionCreators';
import Popout from './common/Popout';
import lodash from 'lodash';
import classNames from 'classnames';
import i18n from '../i18n';
import SearchBar from './common/SearchBar';
import NoSearchResults from './common/NoSearchResults';
import PopoutStore from '../stores/PopoutStore';
import Flux from '../lib/flux';
import twemoji from 'twemoji';
import EmojiStore from '../stores/EmojiStore';
import GuildStore from '../stores/GuildStore';
import SelectedGuildStore from '../stores/SelectedGuildStore';
import SortedGuildStore from '../stores/SortedGuildStore';
import SelectedChannelStore from '../stores/SelectedChannelStore';
import ChannelStore from '../stores/ChannelStore';
import EmojiActionCreators from '../actions/EmojiActionCreators';
import Animated from '../lib/animated';
import UnicodeEmojis from '../lib/UnicodeEmojis';
import ReactDOM from 'react-dom';
import AnalyticsUtils from '../utils/AnalyticsUtils';
import EmojiUtils from '../utils/EmojiUtils';
import {
EmojiSprites,
ComponentActions,
DIVERSITY_SURROGATES,
UserSettingsSections,
EMOJI_CATEGORY_CUSTOM,
EMOJI_CATEGORY_RECENT,
} from '../Constants';
import '../styles/emoji_picker.styl';
const ELEMENT_HEIGHT = 32;
const EMOJI_OK_HAND = twemoji.convert.fromCodePoint('1f44c'); // ok_hand
const EMOJI_SPRITE_SIZE = 22;
const ICON_FADE_IN = 150;
const ICON_DELAY_OFFSET = 20;
const SELECTOR_EXPAND_DURATION = 125;
const DiversityIcon = React.createClass({
getDefaultProps() {
return {
delay: 0,
fade: false,
};
},
getInitialState() {
return {
opacity: new Animated.Value(this.props.fade ? 0 : 1),
};
},
componentDidMount() {
if (this.props.fade) {
Animated.timing(this.state.opacity, {delay: this.props.delay, toValue: 1, duration: ICON_FADE_IN}).start();
}
},
render() {
const url = EmojiUtils.getURL(EMOJI_OK_HAND + this.props.surrogate);
const style = {
opacity: this.state.opacity,
backgroundImage: `url("${url}")`,
};
return <Animated.div onClick={() => this.props.onClick(this.props.surrogate)} className="item" style={style} />;
},
});
const CLOSED_HEIGHT = 28;
const DiversitySelector = React.createClass({
mixins: [PureRenderMixin, Flux.StoreListenerMixin(EmojiStore)],
getInitialState() {
return {
isOpen: false,
height: new Animated.Value(CLOSED_HEIGHT),
};
},
getStateFromStores() {
return {
diversitySurrogate: EmojiStore.diversitySurrogate,
};
},
handleDiversityColorChange(selectedColor) {
EmojiActionCreators.setDiversityColor(selectedColor);
this.close(null);
},
handleOpen() {
Animated.timing(this.state.height, {
fromValue: CLOSED_HEIGHT,
toValue: 30 * 6,
duration: SELECTOR_EXPAND_DURATION,
}).start();
this.setState({isOpen: true});
document.addEventListener('click', this.close, true);
this.props.onOpen && this.props.onOpen(true);
},
componentWillUnmount() {
document.removeEventListener('click', this.close, true);
},
close(e) {
if (e != null) {
let target = e.target;
const me = ReactDOM.findDOMNode(this);
while (target != null) {
if (target === me) return;
target = target.parentNode;
}
}
this.setState({isOpen: false});
this.state.height.setValue(CLOSED_HEIGHT);
this.props.onOpen && this.props.onOpen(false);
},
render() {
const selectedSurrogate = this.state.diversitySurrogate;
if (this.state.isOpen) {
const surrogates = ['', ...DIVERSITY_SURROGATES];
lodash.remove(surrogates, s => s === selectedSurrogate);
surrogates.unshift(selectedSurrogate);
const icons = surrogates.map((s, index) =>
<DiversityIcon
fade={index > 0}
delay={index * ICON_DELAY_OFFSET}
key={index}
surrogate={s}
onClick={this.handleDiversityColorChange}
/>
);
return (
<div className="diversity-selector">
<Animated.div className="popout" style={{height: this.state.height}}>
{icons}
</Animated.div>
</div>
);
}
const url = EmojiUtils.getURL(EMOJI_OK_HAND + selectedSurrogate);
return (
<div className="diversity-selector">
<div onClick={this.handleOpen} className="item" style={{backgroundImage: `url("${url}")`}} />
</div>
);
},
});
export const EmojiPicker = React.createClass({
mixins: [PureRenderMixin, Flux.StoreListenerMixin(EmojiStore, SelectedGuildStore, SelectedChannelStore)],
getStateFromStores() {
return {
diversitySurrogate: EmojiStore.diversitySurrogate,
selectedChannel: ChannelStore.getChannel(SelectedChannelStore.getChannelId()),
selectedGuildId: SelectedGuildStore.getGuildId(),
};
},
getInitialState() {
return {
premiumPromoOpen: false,
diversityPickerOpen: false,
metaData: this.computeMetaData(null),
searchResults: null,
currentSection: EmojiStore.categories[0],
selectedRow: -1,
selectedColumn: -1,
query: '',
placeholder: i18n.Messages.SEARCH_FOR_EMOJI,
};
},
selectEmoji(emojiObject, close = true) {
if (emojiObject != null && EmojiUtils.isEmojiDisabled(emojiObject, this.state.selectedChannel)) {
this.setState({premiumPromoOpen: true});
return;
}
if (this.props.onSelectEmoji) {
this.props.onSelectEmoji(emojiObject);
}
if (close) {
PopoutActionCreators.close(this.props.popoutKey);
} else {
this.focusSearch();
}
},
clearNow(afterClear) {
const state = {
query: '',
placeholder: i18n.Messages.SEARCH_FOR_EMOJI,
searchResults: null,
metaData: this.computeMetaData(null),
selectedRow: -1,
selectedColumn: -1,
};
if (this.state.query !== '') {
state.currentSection = EmojiStore.categories[0];
}
this.focusSearch();
this.setState(state, afterClear);
},
handleClear() {
this.clearNow();
},
handleSelect(selectedRow, selectedColumn, e) {
if (selectedRow === null && selectedColumn === null) {
this.selectEmoji(null);
return;
}
this.selectEmoji(this.state.metaData[selectedRow].items[selectedColumn].emoji, !e.shiftKey);
},
handleSelectionChange(selectedRow, selectedColumn) {
if (selectedRow < 0 || selectedColumn < 0) {
return;
}
const {emoji, offsetTop} = this.state.metaData[selectedRow].items[selectedColumn];
this.setState({selectedRow, selectedColumn, placeholder: emoji.allNamesString});
this.refs.scroller.scrollIntoViewRect(offsetTop, offsetTop + ELEMENT_HEIGHT, false);
},
handleQueryChange(query) {
if (query === '') {
this.clearNow();
return;
}
// eat a leading : if it's present
let searchParam = query;
if (searchParam[0] === ':') {
searchParam = searchParam.slice(1);
}
const searchResults = EmojiStore.search(this.props.channel, searchParam);
this.setState({
query,
searchResults,
metaData: this.computeMetaData(searchResults),
selectedRow: -1,
selectedColumn: -1,
currentSection: null,
});
},
focusSearch() {
this.refs.search.focus();
},
componentWillMount() {
this.spriteSheetSize = [
`${EMOJI_SPRITE_SIZE * EmojiSprites.NonDiversityPerRow}px`,
`${EMOJI_SPRITE_SIZE * Math.ceil(UnicodeEmojis.numNonDiversitySprites / EmojiSprites.NonDiversityPerRow)}px`,
].join(' ');
this.diversitySpriteSheetSize = [
`${EMOJI_SPRITE_SIZE * EmojiSprites.DiversityPerRow}px`,
`${EMOJI_SPRITE_SIZE * Math.ceil(UnicodeEmojis.numDiversitySprites / EmojiSprites.DiversityPerRow)}px`,
].join(' ');
},
componentDidMount() {
AnalyticsUtils.track('Open Popout', {
Type: 'Emoji Picker',
});
this.focusSearch();
},
onHoverEmoji(emoji, selectedRow, selectedColumn) {
this.setState({selectedRow, selectedColumn, placeholder: emoji.allNamesString});
},
scrollNow(name) {
this.clearNow(() => {
this.refs.scroller.scrollTo(this.categoryOffsets[name], true);
});
},
getCurrentSection() {
if (this.state.query !== '') {
// if the user is searching, return the first category as selected but don't scroll around
return null;
}
const VISUAL_THRESHOLD = 0.25;
const scroller = this.refs.scroller.getScrollerNode();
const scrollOffset = scroller.scrollTop + scroller.offsetHeight * VISUAL_THRESHOLD;
if (scroller.scrollTop === 0) {
// first section no matter what
return EmojiStore.categories[0];
}
let currentSectionOffset = -1;
let currentSection = null;
lodash.forEach(this.categoryOffsets, (offsetTop, key) => {
if (offsetTop <= scrollOffset && offsetTop >= currentSectionOffset) {
currentSectionOffset = offsetTop;
currentSection = key;
}
});
return currentSection;
},
handleScroll() {
const currentSection = this.getCurrentSection();
if (currentSection != this.state.currentSection) {
this.setState({currentSection});
}
},
computeMetaData(optionalSearchResults) {
const emojiPerRow = EmojiStore.categories.length;
let selectedChannel = this.state && this.state.selectedChannel;
let selectedGuildId = this.state && this.state.selectedGuildId;
if (selectedChannel == null) {
selectedChannel = ChannelStore.getChannel(SelectedChannelStore.getChannelId());
}
if (selectedGuildId == null) {
selectedGuildId = SelectedGuildStore.getGuildId();
}
const metaData = [];
let visualRows = 0;
let row = 0;
const computeCategory = (categorizedEmoji, category = null) => {
let currentRowMetaData = [];
lodash.forEach(categorizedEmoji, emoji => {
if (EmojiUtils.isEmojiFiltered(emoji, selectedChannel)) {
return;
}
currentRowMetaData.push({
emoji,
offsetTop: visualRows * ELEMENT_HEIGHT,
row,
column: currentRowMetaData.length,
});
if (currentRowMetaData.length === emojiPerRow) {
metaData.push({category, items: currentRowMetaData});
currentRowMetaData = [];
row += 1;
visualRows += 1;
}
});
if (currentRowMetaData.length > 0) {
metaData.push({category, items: currentRowMetaData});
row += 1;
visualRows += 1;
}
};
if (optionalSearchResults) {
computeCategory(optionalSearchResults);
} else {
// if we are not searching, only do this once and then cache it for use later.
if (this.cachedMetaDataNoSearch) {
return this.cachedMetaDataNoSearch;
}
this.categoryOffsets = {};
this.categories = [];
const pushCategory = (category, title, emojis) => {
this.categoryOffsets[category] = visualRows * ELEMENT_HEIGHT;
this.categories.push({category, offsetTop: visualRows * ELEMENT_HEIGHT, title});
visualRows += 1;
computeCategory(emojis, category);
};
const emojiContext = EmojiStore.getDisambiguatedEmojiContext(selectedGuildId);
lodash.forEach(EmojiStore.categories, category => {
if (category === EMOJI_CATEGORY_CUSTOM) {
// Generate a separate category for each guild that has emoji.
let guildCategories = 0;
const customEmoji = emojiContext.getCustomEmoji();
const customEmojisByGuild = lodash.groupBy(customEmoji, 'guildId');
const pushGuildCategory = guild => {
const emojis = customEmojisByGuild[guild.id];
if (emojis == null || emojis.length === 0) return;
pushCategory(
// First category has to be just the custom category for scroll spy.
guildCategories++ === 0 ? category : `${category}${guildCategories}`,
guild.toString(),
emojis
);
};
const selectedGuild = GuildStore.getGuild(selectedGuildId);
if (selectedGuild) {
pushGuildCategory(selectedGuild);
}
lodash.each(SortedGuildStore.getSortedGuilds(), ({guild}) => {
if (guild.id !== selectedGuildId) {
pushGuildCategory(guild);
}
});
} else {
const emojis = category === EMOJI_CATEGORY_RECENT
? emojiContext.getFrequentlyUsedEmojis()
: UnicodeEmojis.getByCategory(category);
pushCategory(category, i18n.Messages[`EMOJI_CATEGORY_${category.toUpperCase()}`], emojis);
}
});
this.cachedMetaDataNoSearch = metaData;
}
return metaData;
},
handleDiversityPickerToggled(diversityPickerOpen) {
this.setState({diversityPickerOpen});
},
renderStickyHeader() {
if (this.state.query !== '') {
return null;
}
if (!this.refs.scroller) {
return (
<div className="sticky-header">
<div className="category">{this.categoryOffsets[EmojiStore.categories[0]]}</div>
</div>
);
}
const scroller = this.refs.scroller.getScrollerNode();
const scrollOffset = scroller.scrollTop;
const headerBottom = scrollOffset + ELEMENT_HEIGHT;
let topSectionOffset = -1;
let topSectionIndex = null;
lodash.forEach(this.categories, ({offsetTop}, index) => {
if (offsetTop <= scrollOffset) {
if (topSectionOffset < offsetTop) {
topSectionOffset = offsetTop;
topSectionIndex = index;
}
}
});
const nextSectionIndex = topSectionIndex + 1;
if (nextSectionIndex < this.categories.length && this.categories[nextSectionIndex].offsetTop < headerBottom) {
const offsetStyle = {
marginTop: this.categories[nextSectionIndex].offsetTop - headerBottom,
};
return (
<div className="sticky-header" style={offsetStyle}>
<div className="category">{this.categories[topSectionIndex].title}</div>
<div className="category">{this.categories[nextSectionIndex].title}</div>
</div>
);
}
return (
<div className="sticky-header">
<div className="category">{this.categories[topSectionIndex].title}</div>
</div>
);
},
render() {
const rows = [];
let currentCategory = null;
let body = null;
if (this.state.metaData.length === 0) {
body = <NoSearchResults message={i18n.Messages.NO_EMOJI_SEARCH_RESULTS} />;
} else {
const surrogateCP = this.state.diversitySurrogate !== ''
? twemoji.convert.toCodePoint(this.state.diversitySurrogate)
: '';
lodash.forEach(this.state.metaData, ({category, items}) => {
if (category && category !== currentCategory) {
currentCategory = category;
const {title} = lodash.find(this.categories, c => c.category === category);
rows.push(<div key={category} name={category} className="category">{title}</div>);
}
const elements = lodash.map(items, ({emoji, row, column}) => {
let style = null;
const selected = row === this.state.selectedRow && column === this.state.selectedColumn;
const props = {
key: `${emoji.name}-${row}-${column}`,
className: classNames('emoji-item', {
selected,
disabled: EmojiUtils.isEmojiDisabled(emoji, this.state.selectedChannel),
}),
onClick: e => this.selectEmoji(emoji, !e.shiftKey),
onMouseOver: () => this.onHoverEmoji(emoji, row, column),
};
if (!emoji.useSpriteSheet) {
return <div {...props} style={{backgroundImage: `url('${emoji.url}')`}} />;
}
if (emoji.hasDiversity) {
const x = -emoji.index % EmojiSprites.DiversityPerRow * EMOJI_SPRITE_SIZE;
const y = -Math.floor(emoji.index / EmojiSprites.DiversityPerRow) * EMOJI_SPRITE_SIZE;
style = {
backgroundImage: `url('${require(`../images/emoji/spritesheet-${surrogateCP}.png`)}')`,
backgroundPosition: `${x}px ${y}px`,
backgroundSize: this.diversitySpriteSheetSize,
};
} else {
const x = -emoji.index % EmojiSprites.NonDiversityPerRow * EMOJI_SPRITE_SIZE;
const y = -Math.floor(emoji.index / EmojiSprites.NonDiversityPerRow) * EMOJI_SPRITE_SIZE;
style = {
backgroundImage: `url('${require(`../images/emoji/spritesheet-emoji.png`)}')`,
backgroundPosition: `${x}px ${y}px`,
backgroundSize: this.spriteSheetSize,
};
}
return (
<div {...props}>
<div className="sprite-item" style={style} />
</div>
);
});
rows.push(<div key={rows.length} className="row">{elements}</div>);
});
body = (
<LazyScroller
ref="scroller"
onScroll={this.handleScroll}
elementHeight={ELEMENT_HEIGHT}
fade
theme={Themes.LIGHT}
renderStickyHeader={() => this.renderStickyHeader()}>
{rows}
</LazyScroller>
);
}
const categories = lodash.map(EmojiStore.categories, cat => {
const options = {
[cat]: true,
selected: this.state.currentSection != null && this.state.currentSection.indexOf(cat) === 0,
};
return <div key={cat} className={classNames('item', options)} onClick={() => this.scrollNow(cat)} />;
});
let premiumPromo;
if (this.state.premiumPromoOpen) {
premiumPromo = (
<PremiumPromo
onLearnMore={() => PopoutActionCreators.close(this.props.popoutKey)}
onClose={() => this.setState({premiumPromoOpen: false})}
/>
);
}
return (
<div className="emoji-picker">
<div className={classNames('dimmer', {visible: this.state.diversityPickerOpen})} />
<div className="header">
<SearchBar
query={this.state.query}
ref="search"
light
placeholder={this.state.placeholder}
gridResults={true}
selectedRow={this.state.selectedRow}
selectedColumn={this.state.selectedColumn}
sections={this.state.metaData.map(row => row.items.length)}
onClear={this.handleClear}
onSelect={this.handleSelect}
onSelectionChange={this.handleSelectionChange}
onQueryChange={this.handleQueryChange}
/>
<DiversitySelector onOpen={this.handleDiversityPickerToggled} />
</div>
{body}
<div className="categories">
{categories}
</div>
{premiumPromo}
</div>
);
},
});
const PremiumPromo = ({onLearnMore, onClose}) => {
return (
<div className="premium-promo">
<div className="premium-promo-close" onClick={onClose}>{i18n.Messages.CLOSE}</div>
<img
className="premium-promo-image"
src={require('../images/premium/img_premium_emoji_light.svg')}
width={124}
height={96}
/>
<div className="premium-promo-title">{i18n.Messages.PREMIUM_PROMO_TITLE}</div>
<div className="premium-promo-description">{i18n.Messages.PREMIUM_PROMO_DESCRIPTION}</div>
<button
className="btn btn-primary"
onClick={() => {
UserSettingsModalActionCreators.open(UserSettingsSections.PREMIUM);
onLearnMore();
}}
type="button">
{i18n.Messages.LEARN_MORE}
</button>
</div>
);
};
const EmojiButton = React.createClass({
mixins: [PureRenderMixin],
getInitialState() {
return {
hovered: false,
uniqueId: `emoji-picker-${this.props.name}`,
};
},
componentWillMount() {
this.spriteSheetSize = [
`${EMOJI_SPRITE_SIZE * EmojiSprites.PickerPerRow}px`,
`${EMOJI_SPRITE_SIZE * Math.ceil(EmojiSprites.PickerCount / EmojiSprites.PickerPerRow)}px`,
].join(' ');
},
renderEmojiPickerPopout(props) {
return <EmojiPicker {...props} onSelectEmoji={this.props.onSelectEmoji} channel={this.props.channel} />;
},
onMouseOver() {
this.onMouseEnter();
},
onMouseEnter() {
if (this.state.hovered || PopoutStore.isOpen(this.state.uniqueId)) {
return;
}
const emojiIndex = Math.floor(Math.random() * EmojiSprites.PickerCount);
this.setState({hovered: true, emojiIndex});
},
onMouseLeave() {
this.setState({hovered: false});
},
render() {
const ABOVE_DRAG_AND_DROP = 1000;
const {emojiIndex} = this.state;
const x = -emojiIndex % EmojiSprites.PickerPerRow * EMOJI_SPRITE_SIZE;
const y = -Math.floor(emojiIndex / EmojiSprites.PickerPerRow) * EMOJI_SPRITE_SIZE;
const style = {
backgroundImage: `url('${require('../images/emoji/spritesheet-picker.png')}')`,
backgroundPosition: `${x}px ${y}px`,
backgroundSize: this.spriteSheetSize,
};
return (
<Popout
closeOnScroll={false}
render={this.renderEmojiPickerPopout}
position={Popout.TOP_RIGHT}
zIndexBoost={ABOVE_DRAG_AND_DROP}
shadow={false}
animationType="none"
uniqueId={this.state.uniqueId}
subscribeTo={ComponentActions.TOGGLE_EMOJI_POPOUT}>
<div
className={classNames('channel-textarea-emoji', {hovered: this.state.hovered})}
onMouseEnter={this.onMouseEnter}
onMouseOver={this.onMouseOver}
onMouseLeave={this.onMouseLeave}>
<div className="sprite-item" style={style} />
</div>
</Popout>
);
},
});
export default EmojiButton;
// WEBPACK FOOTER //
// ./discord_app/components/EmojiPicker.js