Track frequently used emojis in web UI (#5275)
* Track frequently used emojis in web UI * Persist emoji usage, but debounce commits to the settings API * Fix #5144 - Add tooltips to picker * Display only 2 lines of frequently used emojis
This commit is contained in:
		
							parent
							
								
									0717d9b3e6
								
							
						
					
					
						commit
						488584bfc1
					
				
					 8 changed files with 90 additions and 17 deletions
				
			
		| 
						 | 
				
			
			@ -1,6 +1,7 @@
 | 
			
		|||
import api from '../api';
 | 
			
		||||
import { throttle } from 'lodash';
 | 
			
		||||
import { search as emojiSearch } from '../features/emoji/emoji_mart_search_light';
 | 
			
		||||
import { useEmoji } from './emojis';
 | 
			
		||||
 | 
			
		||||
import {
 | 
			
		||||
  updateTimeline,
 | 
			
		||||
| 
						 | 
				
			
			@ -305,6 +306,8 @@ export function selectComposeSuggestion(position, token, suggestion) {
 | 
			
		|||
    if (typeof suggestion === 'object' && suggestion.id) {
 | 
			
		||||
      completion    = suggestion.native || suggestion.colons;
 | 
			
		||||
      startPosition = position - 1;
 | 
			
		||||
 | 
			
		||||
      dispatch(useEmoji(suggestion));
 | 
			
		||||
    } else {
 | 
			
		||||
      completion    = getState().getIn(['accounts', suggestion, 'acct']);
 | 
			
		||||
      startPosition = position;
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
							
								
								
									
										14
									
								
								app/javascript/mastodon/actions/emojis.js
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										14
									
								
								app/javascript/mastodon/actions/emojis.js
									
										
									
									
									
										Normal file
									
								
							| 
						 | 
				
			
			@ -0,0 +1,14 @@
 | 
			
		|||
import { saveSettings } from './settings';
 | 
			
		||||
 | 
			
		||||
export const EMOJI_USE = 'EMOJI_USE';
 | 
			
		||||
 | 
			
		||||
export function useEmoji(emoji) {
 | 
			
		||||
  return dispatch => {
 | 
			
		||||
    dispatch({
 | 
			
		||||
      type: EMOJI_USE,
 | 
			
		||||
      emoji,
 | 
			
		||||
    });
 | 
			
		||||
 | 
			
		||||
    dispatch(saveSettings());
 | 
			
		||||
  };
 | 
			
		||||
};
 | 
			
		||||
| 
						 | 
				
			
			@ -1,6 +1,8 @@
 | 
			
		|||
import axios from 'axios';
 | 
			
		||||
import { debounce } from 'lodash';
 | 
			
		||||
 | 
			
		||||
export const SETTING_CHANGE = 'SETTING_CHANGE';
 | 
			
		||||
export const SETTING_SAVE   = 'SETTING_SAVE';
 | 
			
		||||
 | 
			
		||||
export function changeSetting(key, value) {
 | 
			
		||||
  return dispatch => {
 | 
			
		||||
| 
						 | 
				
			
			@ -14,10 +16,16 @@ export function changeSetting(key, value) {
 | 
			
		|||
  };
 | 
			
		||||
};
 | 
			
		||||
 | 
			
		||||
const debouncedSave = debounce((dispatch, getState) => {
 | 
			
		||||
  if (getState().getIn(['settings', 'saved'])) {
 | 
			
		||||
    return;
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  const data = getState().get('settings').filter((_, key) => key !== 'saved').toJS();
 | 
			
		||||
 | 
			
		||||
  axios.put('/api/web/settings', { data }).then(() => dispatch({ type: SETTING_SAVE }));
 | 
			
		||||
}, 5000, { trailing: true });
 | 
			
		||||
 | 
			
		||||
export function saveSettings() {
 | 
			
		||||
  return (_, getState) => {
 | 
			
		||||
    axios.put('/api/web/settings', {
 | 
			
		||||
      data: getState().get('settings').toJS(),
 | 
			
		||||
    });
 | 
			
		||||
  };
 | 
			
		||||
  return (dispatch, getState) => debouncedSave(dispatch, getState);
 | 
			
		||||
};
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -146,6 +146,7 @@ class EmojiPickerMenu extends React.PureComponent {
 | 
			
		|||
 | 
			
		||||
  static propTypes = {
 | 
			
		||||
    custom_emojis: ImmutablePropTypes.list,
 | 
			
		||||
    frequentlyUsedEmojis: PropTypes.arrayOf(PropTypes.string),
 | 
			
		||||
    loading: PropTypes.bool,
 | 
			
		||||
    onClose: PropTypes.func.isRequired,
 | 
			
		||||
    onPick: PropTypes.func.isRequired,
 | 
			
		||||
| 
						 | 
				
			
			@ -163,6 +164,7 @@ class EmojiPickerMenu extends React.PureComponent {
 | 
			
		|||
    style: {},
 | 
			
		||||
    loading: true,
 | 
			
		||||
    placement: 'bottom',
 | 
			
		||||
    frequentlyUsedEmojis: [],
 | 
			
		||||
  };
 | 
			
		||||
 | 
			
		||||
  state = {
 | 
			
		||||
| 
						 | 
				
			
			@ -233,7 +235,7 @@ class EmojiPickerMenu extends React.PureComponent {
 | 
			
		|||
  }
 | 
			
		||||
 | 
			
		||||
  render () {
 | 
			
		||||
    const { loading, style, intl, custom_emojis, autoPlay, skinTone } = this.props;
 | 
			
		||||
    const { loading, style, intl, custom_emojis, autoPlay, skinTone, frequentlyUsedEmojis } = this.props;
 | 
			
		||||
 | 
			
		||||
    if (loading) {
 | 
			
		||||
      return <div style={{ width: 299 }} />;
 | 
			
		||||
| 
						 | 
				
			
			@ -256,9 +258,11 @@ class EmojiPickerMenu extends React.PureComponent {
 | 
			
		|||
          i18n={this.getI18n()}
 | 
			
		||||
          onClick={this.handleClick}
 | 
			
		||||
          include={categoriesSort}
 | 
			
		||||
          recent={frequentlyUsedEmojis}
 | 
			
		||||
          skin={skinTone}
 | 
			
		||||
          showPreview={false}
 | 
			
		||||
          backgroundImageFn={backgroundImageFn}
 | 
			
		||||
          emojiTooltip
 | 
			
		||||
        />
 | 
			
		||||
 | 
			
		||||
        <ModifierPicker
 | 
			
		||||
| 
						 | 
				
			
			@ -279,6 +283,7 @@ export default class EmojiPickerDropdown extends React.PureComponent {
 | 
			
		|||
 | 
			
		||||
  static propTypes = {
 | 
			
		||||
    custom_emojis: ImmutablePropTypes.list,
 | 
			
		||||
    frequentlyUsedEmojis: PropTypes.arrayOf(PropTypes.string),
 | 
			
		||||
    autoPlay: PropTypes.bool,
 | 
			
		||||
    intl: PropTypes.object.isRequired,
 | 
			
		||||
    onPickEmoji: PropTypes.func.isRequired,
 | 
			
		||||
| 
						 | 
				
			
			@ -341,7 +346,7 @@ export default class EmojiPickerDropdown extends React.PureComponent {
 | 
			
		|||
  }
 | 
			
		||||
 | 
			
		||||
  render () {
 | 
			
		||||
    const { intl, onPickEmoji, autoPlay, onSkinTone, skinTone } = this.props;
 | 
			
		||||
    const { intl, onPickEmoji, autoPlay, onSkinTone, skinTone, frequentlyUsedEmojis } = this.props;
 | 
			
		||||
    const title = intl.formatMessage(messages.emoji);
 | 
			
		||||
    const { active, loading } = this.state;
 | 
			
		||||
 | 
			
		||||
| 
						 | 
				
			
			@ -364,6 +369,7 @@ export default class EmojiPickerDropdown extends React.PureComponent {
 | 
			
		|||
            autoPlay={autoPlay}
 | 
			
		||||
            onSkinTone={onSkinTone}
 | 
			
		||||
            skinTone={skinTone}
 | 
			
		||||
            frequentlyUsedEmojis={frequentlyUsedEmojis}
 | 
			
		||||
          />
 | 
			
		||||
        </Overlay>
 | 
			
		||||
      </div>
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -1,17 +1,42 @@
 | 
			
		|||
import { connect } from 'react-redux';
 | 
			
		||||
import EmojiPickerDropdown from '../components/emoji_picker_dropdown';
 | 
			
		||||
import { changeSetting } from '../../../actions/settings';
 | 
			
		||||
import { createSelector } from 'reselect';
 | 
			
		||||
import { Map as ImmutableMap } from 'immutable';
 | 
			
		||||
import { useEmoji } from '../../../actions/emojis';
 | 
			
		||||
 | 
			
		||||
const perLine = 8;
 | 
			
		||||
const lines   = 2;
 | 
			
		||||
 | 
			
		||||
const getFrequentlyUsedEmojis = createSelector([
 | 
			
		||||
  state => state.getIn(['settings', 'frequentlyUsedEmojis'], ImmutableMap()),
 | 
			
		||||
], emojiCounters => emojiCounters
 | 
			
		||||
    .keySeq()
 | 
			
		||||
    .sort((a, b) => emojiCounters.get(a) - emojiCounters.get(b))
 | 
			
		||||
    .reverse()
 | 
			
		||||
    .slice(0, perLine * lines)
 | 
			
		||||
    .toArray()
 | 
			
		||||
);
 | 
			
		||||
 | 
			
		||||
const mapStateToProps = state => ({
 | 
			
		||||
  custom_emojis: state.get('custom_emojis'),
 | 
			
		||||
  autoPlay: state.getIn(['meta', 'auto_play_gif']),
 | 
			
		||||
  skinTone: state.getIn(['settings', 'skinTone']),
 | 
			
		||||
  frequentlyUsedEmojis: getFrequentlyUsedEmojis(state),
 | 
			
		||||
});
 | 
			
		||||
 | 
			
		||||
const mapDispatchToProps = dispatch => ({
 | 
			
		||||
const mapDispatchToProps = (dispatch, { onPickEmoji }) => ({
 | 
			
		||||
  onSkinTone: skinTone => {
 | 
			
		||||
    dispatch(changeSetting(['skinTone'], skinTone));
 | 
			
		||||
  },
 | 
			
		||||
 | 
			
		||||
  onPickEmoji: emoji => {
 | 
			
		||||
    dispatch(useEmoji(emoji));
 | 
			
		||||
 | 
			
		||||
    if (onPickEmoji) {
 | 
			
		||||
      onPickEmoji(emoji);
 | 
			
		||||
    }
 | 
			
		||||
  },
 | 
			
		||||
});
 | 
			
		||||
 | 
			
		||||
export default connect(mapStateToProps, mapDispatchToProps)(EmojiPickerDropdown);
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -1,10 +1,13 @@
 | 
			
		|||
import { SETTING_CHANGE } from '../actions/settings';
 | 
			
		||||
import { SETTING_CHANGE, SETTING_SAVE } from '../actions/settings';
 | 
			
		||||
import { COLUMN_ADD, COLUMN_REMOVE, COLUMN_MOVE } from '../actions/columns';
 | 
			
		||||
import { STORE_HYDRATE } from '../actions/store';
 | 
			
		||||
import { EMOJI_USE } from '../actions/emojis';
 | 
			
		||||
import { Map as ImmutableMap, fromJS } from 'immutable';
 | 
			
		||||
import uuid from '../uuid';
 | 
			
		||||
 | 
			
		||||
const initialState = ImmutableMap({
 | 
			
		||||
  saved: true,
 | 
			
		||||
 | 
			
		||||
  onboarded: false,
 | 
			
		||||
 | 
			
		||||
  skinTone: 1,
 | 
			
		||||
| 
						 | 
				
			
			@ -74,21 +77,35 @@ const moveColumn = (state, uuid, direction) => {
 | 
			
		|||
  newColumns = columns.splice(index, 1);
 | 
			
		||||
  newColumns = newColumns.splice(newIndex, 0, columns.get(index));
 | 
			
		||||
 | 
			
		||||
  return state.set('columns', newColumns);
 | 
			
		||||
  return state
 | 
			
		||||
    .set('columns', newColumns)
 | 
			
		||||
    .set('saved', false);
 | 
			
		||||
};
 | 
			
		||||
 | 
			
		||||
const updateFrequentEmojis = (state, emoji) => state.update('frequentlyUsedEmojis', ImmutableMap(), map => map.update(emoji.id, 0, count => count + 1)).set('saved', false);
 | 
			
		||||
 | 
			
		||||
export default function settings(state = initialState, action) {
 | 
			
		||||
  switch(action.type) {
 | 
			
		||||
  case STORE_HYDRATE:
 | 
			
		||||
    return hydrate(state, action.state.get('settings'));
 | 
			
		||||
  case SETTING_CHANGE:
 | 
			
		||||
    return state.setIn(action.key, action.value);
 | 
			
		||||
    return state
 | 
			
		||||
      .setIn(action.key, action.value)
 | 
			
		||||
      .set('saved', false);
 | 
			
		||||
  case COLUMN_ADD:
 | 
			
		||||
    return state.update('columns', list => list.push(fromJS({ id: action.id, uuid: uuid(), params: action.params })));
 | 
			
		||||
    return state
 | 
			
		||||
      .update('columns', list => list.push(fromJS({ id: action.id, uuid: uuid(), params: action.params })))
 | 
			
		||||
      .set('saved', false);
 | 
			
		||||
  case COLUMN_REMOVE:
 | 
			
		||||
    return state.update('columns', list => list.filterNot(item => item.get('uuid') === action.uuid));
 | 
			
		||||
    return state
 | 
			
		||||
      .update('columns', list => list.filterNot(item => item.get('uuid') === action.uuid))
 | 
			
		||||
      .set('saved', false);
 | 
			
		||||
  case COLUMN_MOVE:
 | 
			
		||||
    return moveColumn(state, action.uuid, action.direction);
 | 
			
		||||
  case EMOJI_USE:
 | 
			
		||||
    return updateFrequentEmojis(state, action.emoji);
 | 
			
		||||
  case SETTING_SAVE:
 | 
			
		||||
    return state.set('saved', true);
 | 
			
		||||
  default:
 | 
			
		||||
    return state;
 | 
			
		||||
  }
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -45,7 +45,7 @@
 | 
			
		|||
    "css-loader": "^0.28.4",
 | 
			
		||||
    "detect-passive-events": "^1.0.2",
 | 
			
		||||
    "dotenv": "^4.0.0",
 | 
			
		||||
    "emoji-mart": "^2.1.1",
 | 
			
		||||
    "emoji-mart": "Gargron/emoji-mart#build",
 | 
			
		||||
    "es6-symbol": "^3.1.1",
 | 
			
		||||
    "escape-html": "^1.0.3",
 | 
			
		||||
    "express": "^4.15.2",
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -2191,9 +2191,9 @@ elliptic@^6.0.0:
 | 
			
		|||
    minimalistic-assert "^1.0.0"
 | 
			
		||||
    minimalistic-crypto-utils "^1.0.0"
 | 
			
		||||
 | 
			
		||||
emoji-mart@^2.1.1:
 | 
			
		||||
  version "2.1.1"
 | 
			
		||||
  resolved "https://registry.yarnpkg.com/emoji-mart/-/emoji-mart-2.1.1.tgz#4bce8ec9d9fd0d8adfd2517e7e296871c40762ac"
 | 
			
		||||
emoji-mart@Gargron/emoji-mart#build:
 | 
			
		||||
  version "2.1.2"
 | 
			
		||||
  resolved "https://codeload.github.com/Gargron/emoji-mart/tar.gz/c28a721169d95eb40031a4dae5a79fa8a12a66c7"
 | 
			
		||||
 | 
			
		||||
emoji-regex@^6.1.0:
 | 
			
		||||
  version "6.4.3"
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
		Loading…
	
	Add table
		
		Reference in a new issue