Browse Source

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
master
Eugen Rochko 6 years ago
committed by GitHub
parent
commit
488584bfc1
8 changed files with 90 additions and 17 deletions
  1. +3
    -0
      app/javascript/mastodon/actions/compose.js
  2. +14
    -0
      app/javascript/mastodon/actions/emojis.js
  3. +13
    -5
      app/javascript/mastodon/actions/settings.js
  4. +8
    -2
      app/javascript/mastodon/features/compose/components/emoji_picker_dropdown.js
  5. +26
    -1
      app/javascript/mastodon/features/compose/containers/emoji_picker_dropdown_container.js
  6. +22
    -5
      app/javascript/mastodon/reducers/settings.js
  7. +1
    -1
      package.json
  8. +3
    -3
      yarn.lock

+ 3
- 0
app/javascript/mastodon/actions/compose.js View File

@@ -1,6 +1,7 @@
import api from '../api'; import api from '../api';
import { throttle } from 'lodash'; import { throttle } from 'lodash';
import { search as emojiSearch } from '../features/emoji/emoji_mart_search_light'; import { search as emojiSearch } from '../features/emoji/emoji_mart_search_light';
import { useEmoji } from './emojis';


import { import {
updateTimeline, updateTimeline,
@@ -305,6 +306,8 @@ export function selectComposeSuggestion(position, token, suggestion) {
if (typeof suggestion === 'object' && suggestion.id) { if (typeof suggestion === 'object' && suggestion.id) {
completion = suggestion.native || suggestion.colons; completion = suggestion.native || suggestion.colons;
startPosition = position - 1; startPosition = position - 1;

dispatch(useEmoji(suggestion));
} else { } else {
completion = getState().getIn(['accounts', suggestion, 'acct']); completion = getState().getIn(['accounts', suggestion, 'acct']);
startPosition = position; startPosition = position;


+ 14
- 0
app/javascript/mastodon/actions/emojis.js View 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());
};
};

+ 13
- 5
app/javascript/mastodon/actions/settings.js View File

@@ -1,6 +1,8 @@
import axios from 'axios'; import axios from 'axios';
import { debounce } from 'lodash';


export const SETTING_CHANGE = 'SETTING_CHANGE'; export const SETTING_CHANGE = 'SETTING_CHANGE';
export const SETTING_SAVE = 'SETTING_SAVE';


export function changeSetting(key, value) { export function changeSetting(key, value) {
return dispatch => { 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() { export function saveSettings() {
return (_, getState) => {
axios.put('/api/web/settings', {
data: getState().get('settings').toJS(),
});
};
return (dispatch, getState) => debouncedSave(dispatch, getState);
}; };

+ 8
- 2
app/javascript/mastodon/features/compose/components/emoji_picker_dropdown.js View File

@@ -146,6 +146,7 @@ class EmojiPickerMenu extends React.PureComponent {


static propTypes = { static propTypes = {
custom_emojis: ImmutablePropTypes.list, custom_emojis: ImmutablePropTypes.list,
frequentlyUsedEmojis: PropTypes.arrayOf(PropTypes.string),
loading: PropTypes.bool, loading: PropTypes.bool,
onClose: PropTypes.func.isRequired, onClose: PropTypes.func.isRequired,
onPick: PropTypes.func.isRequired, onPick: PropTypes.func.isRequired,
@@ -163,6 +164,7 @@ class EmojiPickerMenu extends React.PureComponent {
style: {}, style: {},
loading: true, loading: true,
placement: 'bottom', placement: 'bottom',
frequentlyUsedEmojis: [],
}; };


state = { state = {
@@ -233,7 +235,7 @@ class EmojiPickerMenu extends React.PureComponent {
} }


render () { render () {
const { loading, style, intl, custom_emojis, autoPlay, skinTone } = this.props;
const { loading, style, intl, custom_emojis, autoPlay, skinTone, frequentlyUsedEmojis } = this.props;


if (loading) { if (loading) {
return <div style={{ width: 299 }} />; return <div style={{ width: 299 }} />;
@@ -256,9 +258,11 @@ class EmojiPickerMenu extends React.PureComponent {
i18n={this.getI18n()} i18n={this.getI18n()}
onClick={this.handleClick} onClick={this.handleClick}
include={categoriesSort} include={categoriesSort}
recent={frequentlyUsedEmojis}
skin={skinTone} skin={skinTone}
showPreview={false} showPreview={false}
backgroundImageFn={backgroundImageFn} backgroundImageFn={backgroundImageFn}
emojiTooltip
/> />


<ModifierPicker <ModifierPicker
@@ -279,6 +283,7 @@ export default class EmojiPickerDropdown extends React.PureComponent {


static propTypes = { static propTypes = {
custom_emojis: ImmutablePropTypes.list, custom_emojis: ImmutablePropTypes.list,
frequentlyUsedEmojis: PropTypes.arrayOf(PropTypes.string),
autoPlay: PropTypes.bool, autoPlay: PropTypes.bool,
intl: PropTypes.object.isRequired, intl: PropTypes.object.isRequired,
onPickEmoji: PropTypes.func.isRequired, onPickEmoji: PropTypes.func.isRequired,
@@ -341,7 +346,7 @@ export default class EmojiPickerDropdown extends React.PureComponent {
} }


render () { 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 title = intl.formatMessage(messages.emoji);
const { active, loading } = this.state; const { active, loading } = this.state;


@@ -364,6 +369,7 @@ export default class EmojiPickerDropdown extends React.PureComponent {
autoPlay={autoPlay} autoPlay={autoPlay}
onSkinTone={onSkinTone} onSkinTone={onSkinTone}
skinTone={skinTone} skinTone={skinTone}
frequentlyUsedEmojis={frequentlyUsedEmojis}
/> />
</Overlay> </Overlay>
</div> </div>


+ 26
- 1
app/javascript/mastodon/features/compose/containers/emoji_picker_dropdown_container.js View File

@@ -1,17 +1,42 @@
import { connect } from 'react-redux'; import { connect } from 'react-redux';
import EmojiPickerDropdown from '../components/emoji_picker_dropdown'; import EmojiPickerDropdown from '../components/emoji_picker_dropdown';
import { changeSetting } from '../../../actions/settings'; 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 => ({ const mapStateToProps = state => ({
custom_emojis: state.get('custom_emojis'), custom_emojis: state.get('custom_emojis'),
autoPlay: state.getIn(['meta', 'auto_play_gif']), autoPlay: state.getIn(['meta', 'auto_play_gif']),
skinTone: state.getIn(['settings', 'skinTone']), skinTone: state.getIn(['settings', 'skinTone']),
frequentlyUsedEmojis: getFrequentlyUsedEmojis(state),
}); });


const mapDispatchToProps = dispatch => ({
const mapDispatchToProps = (dispatch, { onPickEmoji }) => ({
onSkinTone: skinTone => { onSkinTone: skinTone => {
dispatch(changeSetting(['skinTone'], skinTone)); dispatch(changeSetting(['skinTone'], skinTone));
}, },

onPickEmoji: emoji => {
dispatch(useEmoji(emoji));

if (onPickEmoji) {
onPickEmoji(emoji);
}
},
}); });


export default connect(mapStateToProps, mapDispatchToProps)(EmojiPickerDropdown); export default connect(mapStateToProps, mapDispatchToProps)(EmojiPickerDropdown);

+ 22
- 5
app/javascript/mastodon/reducers/settings.js View File

@@ -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 { COLUMN_ADD, COLUMN_REMOVE, COLUMN_MOVE } from '../actions/columns';
import { STORE_HYDRATE } from '../actions/store'; import { STORE_HYDRATE } from '../actions/store';
import { EMOJI_USE } from '../actions/emojis';
import { Map as ImmutableMap, fromJS } from 'immutable'; import { Map as ImmutableMap, fromJS } from 'immutable';
import uuid from '../uuid'; import uuid from '../uuid';


const initialState = ImmutableMap({ const initialState = ImmutableMap({
saved: true,

onboarded: false, onboarded: false,


skinTone: 1, skinTone: 1,
@@ -74,21 +77,35 @@ const moveColumn = (state, uuid, direction) => {
newColumns = columns.splice(index, 1); newColumns = columns.splice(index, 1);
newColumns = newColumns.splice(newIndex, 0, columns.get(index)); 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) { export default function settings(state = initialState, action) {
switch(action.type) { switch(action.type) {
case STORE_HYDRATE: case STORE_HYDRATE:
return hydrate(state, action.state.get('settings')); return hydrate(state, action.state.get('settings'));
case SETTING_CHANGE: case SETTING_CHANGE:
return state.setIn(action.key, action.value);
return state
.setIn(action.key, action.value)
.set('saved', false);
case COLUMN_ADD: 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: 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: case COLUMN_MOVE:
return moveColumn(state, action.uuid, action.direction); return moveColumn(state, action.uuid, action.direction);
case EMOJI_USE:
return updateFrequentEmojis(state, action.emoji);
case SETTING_SAVE:
return state.set('saved', true);
default: default:
return state; return state;
} }


+ 1
- 1
package.json View File

@@ -45,7 +45,7 @@
"css-loader": "^0.28.4", "css-loader": "^0.28.4",
"detect-passive-events": "^1.0.2", "detect-passive-events": "^1.0.2",
"dotenv": "^4.0.0", "dotenv": "^4.0.0",
"emoji-mart": "^2.1.1",
"emoji-mart": "Gargron/emoji-mart#build",
"es6-symbol": "^3.1.1", "es6-symbol": "^3.1.1",
"escape-html": "^1.0.3", "escape-html": "^1.0.3",
"express": "^4.15.2", "express": "^4.15.2",


+ 3
- 3
yarn.lock View File

@@ -2191,9 +2191,9 @@ elliptic@^6.0.0:
minimalistic-assert "^1.0.0" minimalistic-assert "^1.0.0"
minimalistic-crypto-utils "^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: emoji-regex@^6.1.0:
version "6.4.3" version "6.4.3"


Loading…
Cancel
Save