You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.

459 lines
13 KiB

  1. import { connect } from 'react-redux';
  2. import { changeSetting } from 'flavours/glitch/actions/settings';
  3. import { createSelector } from 'reselect';
  4. import { Map as ImmutableMap } from 'immutable';
  5. import { useEmoji } from 'flavours/glitch/actions/emojis';
  6. import React from 'react';
  7. import PropTypes from 'prop-types';
  8. import { defineMessages, injectIntl } from 'react-intl';
  9. import { EmojiPicker as EmojiPickerAsync } from 'flavours/glitch/util/async-components';
  10. import Overlay from 'react-overlays/lib/Overlay';
  11. import classNames from 'classnames';
  12. import ImmutablePropTypes from 'react-immutable-proptypes';
  13. import detectPassiveEvents from 'detect-passive-events';
  14. import { buildCustomEmojis } from 'flavours/glitch/util/emoji';
  15. const messages = defineMessages({
  16. emoji: { id: 'emoji_button.label', defaultMessage: 'Insert emoji' },
  17. emoji_search: { id: 'emoji_button.search', defaultMessage: 'Search...' },
  18. emoji_not_found: { id: 'emoji_button.not_found', defaultMessage: 'No emojos!! (╯°□°)╯︵ ┻━┻' },
  19. custom: { id: 'emoji_button.custom', defaultMessage: 'Custom' },
  20. recent: { id: 'emoji_button.recent', defaultMessage: 'Frequently used' },
  21. search_results: { id: 'emoji_button.search_results', defaultMessage: 'Search results' },
  22. people: { id: 'emoji_button.people', defaultMessage: 'People' },
  23. nature: { id: 'emoji_button.nature', defaultMessage: 'Nature' },
  24. food: { id: 'emoji_button.food', defaultMessage: 'Food & Drink' },
  25. activity: { id: 'emoji_button.activity', defaultMessage: 'Activity' },
  26. travel: { id: 'emoji_button.travel', defaultMessage: 'Travel & Places' },
  27. objects: { id: 'emoji_button.objects', defaultMessage: 'Objects' },
  28. symbols: { id: 'emoji_button.symbols', defaultMessage: 'Symbols' },
  29. flags: { id: 'emoji_button.flags', defaultMessage: 'Flags' },
  30. });
  31. const perLine = 8;
  32. const lines = 2;
  33. const DEFAULTS = [
  34. '+1',
  35. 'grinning',
  36. 'kissing_heart',
  37. 'heart_eyes',
  38. 'laughing',
  39. 'stuck_out_tongue_winking_eye',
  40. 'sweat_smile',
  41. 'joy',
  42. 'yum',
  43. 'disappointed',
  44. 'thinking_face',
  45. 'weary',
  46. 'sob',
  47. 'sunglasses',
  48. 'heart',
  49. 'ok_hand',
  50. ];
  51. const getFrequentlyUsedEmojis = createSelector([
  52. state => state.getIn(['settings', 'frequentlyUsedEmojis'], ImmutableMap()),
  53. ], emojiCounters => {
  54. let emojis = emojiCounters
  55. .keySeq()
  56. .sort((a, b) => emojiCounters.get(a) - emojiCounters.get(b))
  57. .reverse()
  58. .slice(0, perLine * lines)
  59. .toArray();
  60. if (emojis.length < DEFAULTS.length) {
  61. emojis = emojis.concat(DEFAULTS.slice(0, DEFAULTS.length - emojis.length));
  62. }
  63. return emojis;
  64. });
  65. const getCustomEmojis = createSelector([
  66. state => state.get('custom_emojis'),
  67. ], emojis => emojis.filter(e => e.get('visible_in_picker')).sort((a, b) => {
  68. const aShort = a.get('shortcode').toLowerCase();
  69. const bShort = b.get('shortcode').toLowerCase();
  70. if (aShort < bShort) {
  71. return -1;
  72. } else if (aShort > bShort ) {
  73. return 1;
  74. } else {
  75. return 0;
  76. }
  77. }));
  78. const mapStateToProps = state => ({
  79. custom_emojis: getCustomEmojis(state),
  80. skinTone: state.getIn(['settings', 'skinTone']),
  81. frequentlyUsedEmojis: getFrequentlyUsedEmojis(state),
  82. });
  83. const mapDispatchToProps = (dispatch, { onPickEmoji }) => ({
  84. onSkinTone: skinTone => {
  85. dispatch(changeSetting(['skinTone'], skinTone));
  86. },
  87. onPickEmoji: emoji => {
  88. dispatch(useEmoji(emoji));
  89. if (onPickEmoji) {
  90. onPickEmoji(emoji);
  91. }
  92. },
  93. });
  94. const assetHost = process.env.CDN_HOST || '';
  95. let EmojiPicker, Emoji; // load asynchronously
  96. const backgroundImageFn = () => `${assetHost}/emoji/sheet.png`;
  97. const listenerOptions = detectPassiveEvents.hasSupport ? { passive: true } : false;
  98. const categoriesSort = [
  99. 'recent',
  100. 'custom',
  101. 'people',
  102. 'nature',
  103. 'foods',
  104. 'activity',
  105. 'places',
  106. 'objects',
  107. 'symbols',
  108. 'flags',
  109. ];
  110. class ModifierPickerMenu extends React.PureComponent {
  111. static propTypes = {
  112. active: PropTypes.bool,
  113. onSelect: PropTypes.func.isRequired,
  114. onClose: PropTypes.func.isRequired,
  115. };
  116. handleClick = e => {
  117. this.props.onSelect(e.currentTarget.getAttribute('data-index') * 1);
  118. }
  119. componentWillReceiveProps (nextProps) {
  120. if (nextProps.active) {
  121. this.attachListeners();
  122. } else {
  123. this.removeListeners();
  124. }
  125. }
  126. componentWillUnmount () {
  127. this.removeListeners();
  128. }
  129. handleDocumentClick = e => {
  130. if (this.node && !this.node.contains(e.target)) {
  131. this.props.onClose();
  132. }
  133. }
  134. attachListeners () {
  135. document.addEventListener('click', this.handleDocumentClick, false);
  136. document.addEventListener('touchend', this.handleDocumentClick, listenerOptions);
  137. }
  138. removeListeners () {
  139. document.removeEventListener('click', this.handleDocumentClick, false);
  140. document.removeEventListener('touchend', this.handleDocumentClick, listenerOptions);
  141. }
  142. setRef = c => {
  143. this.node = c;
  144. }
  145. render () {
  146. const { active } = this.props;
  147. return (
  148. <div className='emoji-picker-dropdown__modifiers__menu' style={{ display: active ? 'block' : 'none' }} ref={this.setRef}>
  149. <button onClick={this.handleClick} data-index={1}><Emoji emoji='fist' set='twitter' size={22} sheetSize={32} skin={1} backgroundImageFn={backgroundImageFn} /></button>
  150. <button onClick={this.handleClick} data-index={2}><Emoji emoji='fist' set='twitter' size={22} sheetSize={32} skin={2} backgroundImageFn={backgroundImageFn} /></button>
  151. <button onClick={this.handleClick} data-index={3}><Emoji emoji='fist' set='twitter' size={22} sheetSize={32} skin={3} backgroundImageFn={backgroundImageFn} /></button>
  152. <button onClick={this.handleClick} data-index={4}><Emoji emoji='fist' set='twitter' size={22} sheetSize={32} skin={4} backgroundImageFn={backgroundImageFn} /></button>
  153. <button onClick={this.handleClick} data-index={5}><Emoji emoji='fist' set='twitter' size={22} sheetSize={32} skin={5} backgroundImageFn={backgroundImageFn} /></button>
  154. <button onClick={this.handleClick} data-index={6}><Emoji emoji='fist' set='twitter' size={22} sheetSize={32} skin={6} backgroundImageFn={backgroundImageFn} /></button>
  155. </div>
  156. );
  157. }
  158. }
  159. class ModifierPicker extends React.PureComponent {
  160. static propTypes = {
  161. active: PropTypes.bool,
  162. modifier: PropTypes.number,
  163. onChange: PropTypes.func,
  164. onClose: PropTypes.func,
  165. onOpen: PropTypes.func,
  166. };
  167. handleClick = () => {
  168. if (this.props.active) {
  169. this.props.onClose();
  170. } else {
  171. this.props.onOpen();
  172. }
  173. }
  174. handleSelect = modifier => {
  175. this.props.onChange(modifier);
  176. this.props.onClose();
  177. }
  178. render () {
  179. const { active, modifier } = this.props;
  180. return (
  181. <div className='emoji-picker-dropdown__modifiers'>
  182. <Emoji emoji='fist' set='twitter' size={22} sheetSize={32} skin={modifier} onClick={this.handleClick} backgroundImageFn={backgroundImageFn} />
  183. <ModifierPickerMenu active={active} onSelect={this.handleSelect} onClose={this.props.onClose} />
  184. </div>
  185. );
  186. }
  187. }
  188. @injectIntl
  189. class EmojiPickerMenu extends React.PureComponent {
  190. static propTypes = {
  191. custom_emojis: ImmutablePropTypes.list,
  192. frequentlyUsedEmojis: PropTypes.arrayOf(PropTypes.string),
  193. loading: PropTypes.bool,
  194. onClose: PropTypes.func.isRequired,
  195. onPick: PropTypes.func.isRequired,
  196. style: PropTypes.object,
  197. placement: PropTypes.string,
  198. arrowOffsetLeft: PropTypes.string,
  199. arrowOffsetTop: PropTypes.string,
  200. intl: PropTypes.object.isRequired,
  201. skinTone: PropTypes.number.isRequired,
  202. onSkinTone: PropTypes.func.isRequired,
  203. };
  204. static defaultProps = {
  205. style: {},
  206. loading: true,
  207. frequentlyUsedEmojis: [],
  208. };
  209. state = {
  210. modifierOpen: false,
  211. placement: null,
  212. };
  213. handleDocumentClick = e => {
  214. if (this.node && !this.node.contains(e.target)) {
  215. this.props.onClose();
  216. }
  217. }
  218. componentDidMount () {
  219. document.addEventListener('click', this.handleDocumentClick, false);
  220. document.addEventListener('touchend', this.handleDocumentClick, listenerOptions);
  221. }
  222. componentWillUnmount () {
  223. document.removeEventListener('click', this.handleDocumentClick, false);
  224. document.removeEventListener('touchend', this.handleDocumentClick, listenerOptions);
  225. }
  226. setRef = c => {
  227. this.node = c;
  228. }
  229. getI18n = () => {
  230. const { intl } = this.props;
  231. return {
  232. search: intl.formatMessage(messages.emoji_search),
  233. notfound: intl.formatMessage(messages.emoji_not_found),
  234. categories: {
  235. search: intl.formatMessage(messages.search_results),
  236. recent: intl.formatMessage(messages.recent),
  237. people: intl.formatMessage(messages.people),
  238. nature: intl.formatMessage(messages.nature),
  239. foods: intl.formatMessage(messages.food),
  240. activity: intl.formatMessage(messages.activity),
  241. places: intl.formatMessage(messages.travel),
  242. objects: intl.formatMessage(messages.objects),
  243. symbols: intl.formatMessage(messages.symbols),
  244. flags: intl.formatMessage(messages.flags),
  245. custom: intl.formatMessage(messages.custom),
  246. },
  247. };
  248. }
  249. handleClick = emoji => {
  250. if (!emoji.native) {
  251. emoji.native = emoji.colons;
  252. }
  253. this.props.onClose();
  254. this.props.onPick(emoji);
  255. }
  256. handleModifierOpen = () => {
  257. this.setState({ modifierOpen: true });
  258. }
  259. handleModifierClose = () => {
  260. this.setState({ modifierOpen: false });
  261. }
  262. handleModifierChange = modifier => {
  263. this.props.onSkinTone(modifier);
  264. }
  265. render () {
  266. const { loading, style, intl, custom_emojis, skinTone, frequentlyUsedEmojis } = this.props;
  267. if (loading) {
  268. return <div style={{ width: 299 }} />;
  269. }
  270. const title = intl.formatMessage(messages.emoji);
  271. const { modifierOpen } = this.state;
  272. return (
  273. <div className={classNames('emoji-picker-dropdown__menu', { selecting: modifierOpen })} style={style} ref={this.setRef}>
  274. <EmojiPicker
  275. perLine={8}
  276. emojiSize={22}
  277. sheetSize={32}
  278. custom={buildCustomEmojis(custom_emojis)}
  279. color=''
  280. emoji=''
  281. set='twitter'
  282. title={title}
  283. i18n={this.getI18n()}
  284. onClick={this.handleClick}
  285. include={categoriesSort}
  286. recent={frequentlyUsedEmojis}
  287. skin={skinTone}
  288. showPreview={false}
  289. backgroundImageFn={backgroundImageFn}
  290. emojiTooltip
  291. />
  292. <ModifierPicker
  293. active={modifierOpen}
  294. modifier={skinTone}
  295. onOpen={this.handleModifierOpen}
  296. onClose={this.handleModifierClose}
  297. onChange={this.handleModifierChange}
  298. />
  299. </div>
  300. );
  301. }
  302. }
  303. @connect(mapStateToProps, mapDispatchToProps)
  304. @injectIntl
  305. export default class EmojiPickerDropdown extends React.PureComponent {
  306. static propTypes = {
  307. custom_emojis: ImmutablePropTypes.list,
  308. frequentlyUsedEmojis: PropTypes.arrayOf(PropTypes.string),
  309. intl: PropTypes.object.isRequired,
  310. onPickEmoji: PropTypes.func.isRequired,
  311. onSkinTone: PropTypes.func.isRequired,
  312. skinTone: PropTypes.number.isRequired,
  313. };
  314. state = {
  315. active: false,
  316. loading: false,
  317. };
  318. setRef = (c) => {
  319. this.dropdown = c;
  320. }
  321. onShowDropdown = ({ target }) => {
  322. this.setState({ active: true });
  323. if (!EmojiPicker) {
  324. this.setState({ loading: true });
  325. EmojiPickerAsync().then(EmojiMart => {
  326. EmojiPicker = EmojiMart.Picker;
  327. Emoji = EmojiMart.Emoji;
  328. this.setState({ loading: false });
  329. }).catch(() => {
  330. this.setState({ loading: false });
  331. });
  332. }
  333. const { top } = target.getBoundingClientRect();
  334. this.setState({ placement: top * 2 < innerHeight ? 'bottom' : 'top' });
  335. }
  336. onHideDropdown = () => {
  337. this.setState({ active: false });
  338. }
  339. onToggle = (e) => {
  340. if (!this.state.loading && (!e.key || e.key === 'Enter')) {
  341. if (this.state.active) {
  342. this.onHideDropdown();
  343. } else {
  344. this.onShowDropdown(e);
  345. }
  346. }
  347. }
  348. handleKeyDown = e => {
  349. if (e.key === 'Escape') {
  350. this.onHideDropdown();
  351. }
  352. }
  353. setTargetRef = c => {
  354. this.target = c;
  355. }
  356. findTarget = () => {
  357. return this.target;
  358. }
  359. render () {
  360. const { intl, onPickEmoji, onSkinTone, skinTone, frequentlyUsedEmojis } = this.props;
  361. const title = intl.formatMessage(messages.emoji);
  362. const { active, loading, placement } = this.state;
  363. return (
  364. <div className='emoji-picker-dropdown' onKeyDown={this.handleKeyDown}>
  365. <div ref={this.setTargetRef} className='emoji-button' title={title} aria-label={title} aria-expanded={active} role='button' onClick={this.onToggle} onKeyDown={this.onToggle} tabIndex={0}>
  366. <img
  367. className={classNames('emojione', { 'pulse-loading': active && loading })}
  368. alt='🙂'
  369. src={`${assetHost}/emoji/1f602.svg`}
  370. />
  371. </div>
  372. <Overlay show={active} placement={placement} target={this.findTarget}>
  373. <EmojiPickerMenu
  374. custom_emojis={this.props.custom_emojis}
  375. loading={loading}
  376. onClose={this.onHideDropdown}
  377. onPick={onPickEmoji}
  378. onSkinTone={onSkinTone}
  379. skinTone={skinTone}
  380. frequentlyUsedEmojis={frequentlyUsedEmojis}
  381. />
  382. </Overlay>
  383. </div>
  384. );
  385. }
  386. }