about summary refs log tree commit diff
path: root/app/javascript
diff options
context:
space:
mode:
authorEugen Rochko <eugen@zeonfederated.com>2017-10-08 21:47:15 +0200
committerGitHub <noreply@github.com>2017-10-08 21:47:15 +0200
commit488584bfc15ace3a097947f5190b73354aaa19e9 (patch)
tree62626f95ab5a3451c5be87d3ee2906d2e19ab45e /app/javascript
parent0717d9b3e6904a4dcd5d2dc9e680cc5b21c50e51 (diff)
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
Diffstat (limited to 'app/javascript')
-rw-r--r--app/javascript/mastodon/actions/compose.js3
-rw-r--r--app/javascript/mastodon/actions/emojis.js14
-rw-r--r--app/javascript/mastodon/actions/settings.js18
-rw-r--r--app/javascript/mastodon/features/compose/components/emoji_picker_dropdown.js10
-rw-r--r--app/javascript/mastodon/features/compose/containers/emoji_picker_dropdown_container.js27
-rw-r--r--app/javascript/mastodon/reducers/settings.js27
6 files changed, 86 insertions, 13 deletions
diff --git a/app/javascript/mastodon/actions/compose.js b/app/javascript/mastodon/actions/compose.js
index 560c00720..8a35049b3 100644
--- a/app/javascript/mastodon/actions/compose.js
+++ b/app/javascript/mastodon/actions/compose.js
@@ -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;
diff --git a/app/javascript/mastodon/actions/emojis.js b/app/javascript/mastodon/actions/emojis.js
new file mode 100644
index 000000000..7cd9d4b7b
--- /dev/null
+++ b/app/javascript/mastodon/actions/emojis.js
@@ -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());
+  };
+};
diff --git a/app/javascript/mastodon/actions/settings.js b/app/javascript/mastodon/actions/settings.js
index f9d304c96..79adca18c 100644
--- a/app/javascript/mastodon/actions/settings.js
+++ b/app/javascript/mastodon/actions/settings.js
@@ -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);
 };
diff --git a/app/javascript/mastodon/features/compose/components/emoji_picker_dropdown.js b/app/javascript/mastodon/features/compose/components/emoji_picker_dropdown.js
index 9be8909d8..dffa04ff0 100644
--- a/app/javascript/mastodon/features/compose/components/emoji_picker_dropdown.js
+++ b/app/javascript/mastodon/features/compose/components/emoji_picker_dropdown.js
@@ -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>
diff --git a/app/javascript/mastodon/features/compose/containers/emoji_picker_dropdown_container.js b/app/javascript/mastodon/features/compose/containers/emoji_picker_dropdown_container.js
index 56cc6c3b1..4fa93f6b0 100644
--- a/app/javascript/mastodon/features/compose/containers/emoji_picker_dropdown_container.js
+++ b/app/javascript/mastodon/features/compose/containers/emoji_picker_dropdown_container.js
@@ -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);
diff --git a/app/javascript/mastodon/reducers/settings.js b/app/javascript/mastodon/reducers/settings.js
index 3063ddadd..a9f3f9529 100644
--- a/app/javascript/mastodon/reducers/settings.js
+++ b/app/javascript/mastodon/reducers/settings.js
@@ -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;
   }