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
		Add a link
		
	
		Reference in a new issue