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
@ -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
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 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) => {
|
return (dispatch, getState) => debouncedSave(dispatch, getState);
|
||||||
axios.put('/api/web/settings', {
|
|
||||||
data: getState().get('settings').toJS(),
|
|
||||||
});
|
|
||||||
};
|
|
||||||
};
|
};
|
||||||
|
@ -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>
|
||||||
|
@ -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);
|
||||||
|
@ -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;
|
||||||
}
|
}
|
||||||
|
@ -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",
|
||||||
|
@ -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:
|
emoji-mart@Gargron/emoji-mart#build:
|
||||||
version "2.1.1"
|
version "2.1.2"
|
||||||
resolved "https://registry.yarnpkg.com/emoji-mart/-/emoji-mart-2.1.1.tgz#4bce8ec9d9fd0d8adfd2517e7e296871c40762ac"
|
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…
Reference in New Issue
Block a user