diff options
author | Eugen Rochko <eugen@zeonfederated.com> | 2017-09-23 14:47:32 +0200 |
---|---|---|
committer | GitHub <noreply@github.com> | 2017-09-23 14:47:32 +0200 |
commit | 1e02ba111ae38ab758135b5b2b46f34c672ca02e (patch) | |
tree | 62c916782bf9340837a12fcb0c79bb9d481b4ce8 /app | |
parent | 66126f302167d21e4bf247e660f595ff0beaaf20 (diff) |
Add emoji autosuggest (#5053)
* Add emoji autosuggest Some credit goes to glitch-soc/mastodon#149 * Remove server-side shortcode->unicode conversion * Insert shortcode when suggestion is custom emoji * Remove remnant of server-side emojis * Update style of autosuggestions * Fix wrong emoji filenames generated in autosuggest item * Do not lazy load emoji picker, as that no longer works * Fix custom emoji autosuggest * Fix multiple "Custom" categories getting added to emoji index, only add once
Diffstat (limited to 'app')
-rw-r--r-- | app/helpers/emoji_helper.rb | 24 | ||||
-rw-r--r-- | app/javascript/mastodon/actions/compose.js | 35 | ||||
-rw-r--r-- | app/javascript/mastodon/components/autosuggest_emoji.js | 37 | ||||
-rw-r--r-- | app/javascript/mastodon/components/autosuggest_textarea.js | 43 | ||||
-rw-r--r-- | app/javascript/mastodon/emoji.js | 21 | ||||
-rw-r--r-- | app/javascript/mastodon/features/compose/components/emoji_picker_dropdown.js | 37 | ||||
-rw-r--r-- | app/javascript/mastodon/features/ui/util/async-components.js | 4 | ||||
-rw-r--r-- | app/javascript/mastodon/reducers/accounts.js | 2 | ||||
-rw-r--r-- | app/javascript/mastodon/reducers/accounts_counters.js | 2 | ||||
-rw-r--r-- | app/javascript/mastodon/reducers/compose.js | 2 | ||||
-rw-r--r-- | app/javascript/mastodon/reducers/custom_emojis.js | 5 | ||||
-rw-r--r-- | app/javascript/styles/components.scss | 45 | ||||
-rw-r--r-- | app/lib/emoji.rb | 40 | ||||
-rw-r--r-- | app/models/account.rb | 4 | ||||
-rw-r--r-- | app/models/status.rb | 4 | ||||
-rwxr-xr-x | app/views/layouts/application.html.haml | 1 |
16 files changed, 133 insertions, 173 deletions
diff --git a/app/helpers/emoji_helper.rb b/app/helpers/emoji_helper.rb deleted file mode 100644 index 848c03fce..000000000 --- a/app/helpers/emoji_helper.rb +++ /dev/null @@ -1,24 +0,0 @@ -# frozen_string_literal: true - -module EmojiHelper - def emojify(text) - return text if text.blank? - - text.gsub(emoji_pattern) do |match| - emoji = Emoji.instance.unicode($1) # rubocop:disable Style/PerlBackrefs - - if emoji - emoji - else - match - end - end - end - - def emoji_pattern - @emoji_pattern ||= - /(?<=[^[:alnum:]:]|\n|^) - (#{Emoji.instance.names.map { |name| Regexp.escape(name) }.join('|')}) - (?=[^[:alnum:]:]|$)/x - end -end diff --git a/app/javascript/mastodon/actions/compose.js b/app/javascript/mastodon/actions/compose.js index 1f26907f2..9f10a8c15 100644 --- a/app/javascript/mastodon/actions/compose.js +++ b/app/javascript/mastodon/actions/compose.js @@ -1,4 +1,5 @@ import api from '../api'; +import { emojiIndex } from 'emoji-mart'; import { updateTimeline, @@ -210,19 +211,33 @@ export function clearComposeSuggestions() { export function fetchComposeSuggestions(token) { return (dispatch, getState) => { + if (token[0] === ':') { + const results = emojiIndex.search(token.replace(':', ''), { maxResults: 3 }); + dispatch(readyComposeSuggestionsEmojis(token, results)); + return; + } + api(getState).get('/api/v1/accounts/search', { params: { - q: token, + q: token.slice(1), resolve: false, limit: 4, }, }).then(response => { - dispatch(readyComposeSuggestions(token, response.data)); + dispatch(readyComposeSuggestionsAccounts(token, response.data)); }); }; }; -export function readyComposeSuggestions(token, accounts) { +export function readyComposeSuggestionsEmojis(token, emojis) { + return { + type: COMPOSE_SUGGESTIONS_READY, + token, + emojis, + }; +}; + +export function readyComposeSuggestionsAccounts(token, accounts) { return { type: COMPOSE_SUGGESTIONS_READY, token, @@ -230,13 +245,21 @@ export function readyComposeSuggestions(token, accounts) { }; }; -export function selectComposeSuggestion(position, token, accountId) { +export function selectComposeSuggestion(position, token, suggestion) { return (dispatch, getState) => { - const completion = getState().getIn(['accounts', accountId, 'acct']); + let completion, startPosition; + + if (typeof suggestion === 'object' && suggestion.id) { + completion = suggestion.native || suggestion.colons; + startPosition = position - 1; + } else { + completion = getState().getIn(['accounts', suggestion, 'acct']); + startPosition = position; + } dispatch({ type: COMPOSE_SUGGESTION_SELECT, - position, + position: startPosition, token, completion, }); diff --git a/app/javascript/mastodon/components/autosuggest_emoji.js b/app/javascript/mastodon/components/autosuggest_emoji.js new file mode 100644 index 000000000..e2866e8e4 --- /dev/null +++ b/app/javascript/mastodon/components/autosuggest_emoji.js @@ -0,0 +1,37 @@ +import React from 'react'; +import PropTypes from 'prop-types'; +import { unicodeMapping } from '../emojione_light'; + +const assetHost = process.env.CDN_HOST || ''; + +export default class AutosuggestEmoji extends React.PureComponent { + + static propTypes = { + emoji: PropTypes.object.isRequired, + }; + + render () { + const { emoji } = this.props; + let url; + + if (emoji.custom) { + url = emoji.imageUrl; + } else { + const [ filename ] = unicodeMapping[emoji.native]; + url = `${assetHost}/emoji/${filename}.svg`; + } + + return ( + <div className='autosuggest-emoji'> + <img + className='emojione' + src={url} + alt={emoji.native || emoji.colons} + /> + + {emoji.colons} + </div> + ); + } + +} diff --git a/app/javascript/mastodon/components/autosuggest_textarea.js b/app/javascript/mastodon/components/autosuggest_textarea.js index 30e3049df..daeb6fd53 100644 --- a/app/javascript/mastodon/components/autosuggest_textarea.js +++ b/app/javascript/mastodon/components/autosuggest_textarea.js @@ -1,10 +1,12 @@ import React from 'react'; import AutosuggestAccountContainer from '../features/compose/containers/autosuggest_account_container'; +import AutosuggestEmoji from './autosuggest_emoji'; import ImmutablePropTypes from 'react-immutable-proptypes'; import PropTypes from 'prop-types'; import { isRtl } from '../rtl'; import ImmutablePureComponent from 'react-immutable-pure-component'; import Textarea from 'react-textarea-autosize'; +import classNames from 'classnames'; const textAtCursorMatchesToken = (str, caretPosition) => { let word; @@ -18,11 +20,11 @@ const textAtCursorMatchesToken = (str, caretPosition) => { word = str.slice(left, right + caretPosition); } - if (!word || word.trim().length < 2 || word[0] !== '@') { + if (!word || word.trim().length < 2 || ['@', ':'].indexOf(word[0]) === -1) { return [null, null]; } - word = word.trim().toLowerCase().slice(1); + word = word.trim().toLowerCase(); if (word.length > 0) { return [left + 1, word]; @@ -128,7 +130,7 @@ export default class AutosuggestTextarea extends ImmutablePureComponent { } onSuggestionClick = (e) => { - const suggestion = e.currentTarget.getAttribute('data-index'); + const suggestion = this.props.suggestions.get(e.currentTarget.getAttribute('data-index')); e.preventDefault(); this.props.onSuggestionSelected(this.state.tokenStart, this.state.lastToken, suggestion); this.textarea.focus(); @@ -151,9 +153,28 @@ export default class AutosuggestTextarea extends ImmutablePureComponent { } } + renderSuggestion = (suggestion, i) => { + const { selectedSuggestion } = this.state; + let inner, key; + + if (typeof suggestion === 'object') { + inner = <AutosuggestEmoji emoji={suggestion} />; + key = suggestion.id; + } else { + inner = <AutosuggestAccountContainer id={suggestion} />; + key = suggestion; + } + + return ( + <div role='button' tabIndex='0' key={key} data-index={i} className={classNames('autosuggest-textarea__suggestions__item', { selected: i === selectedSuggestion })} onMouseDown={this.onSuggestionClick}> + {inner} + </div> + ); + } + render () { const { value, suggestions, disabled, placeholder, onKeyUp, autoFocus } = this.props; - const { suggestionsHidden, selectedSuggestion } = this.state; + const { suggestionsHidden } = this.state; const style = { direction: 'ltr' }; if (isRtl(value)) { @@ -164,6 +185,7 @@ export default class AutosuggestTextarea extends ImmutablePureComponent { <div className='autosuggest-textarea'> <label> <span style={{ display: 'none' }}>{placeholder}</span> + <Textarea inputRef={this.setTextarea} className='autosuggest-textarea__textarea' @@ -181,18 +203,7 @@ export default class AutosuggestTextarea extends ImmutablePureComponent { </label> <div className={`autosuggest-textarea__suggestions ${suggestionsHidden || suggestions.isEmpty() ? '' : 'autosuggest-textarea__suggestions--visible'}`}> - {suggestions.map((suggestion, i) => ( - <div - role='button' - tabIndex='0' - key={suggestion} - data-index={suggestion} - className={`autosuggest-textarea__suggestions__item ${i === selectedSuggestion ? 'selected' : ''}`} - onMouseDown={this.onSuggestionClick} - > - <AutosuggestAccountContainer id={suggestion} /> - </div> - ))} + {suggestions.map(this.renderSuggestion)} </div> </div> ); diff --git a/app/javascript/mastodon/emoji.js b/app/javascript/mastodon/emoji.js index 39123768a..d75f6f598 100644 --- a/app/javascript/mastodon/emoji.js +++ b/app/javascript/mastodon/emoji.js @@ -48,25 +48,6 @@ const emojify = (str, customEmojis = {}) => { export default emojify; -export const toCodePoint = (unicodeSurrogates, sep = '-') => { - let r = [], c = 0, p = 0, i = 0; - - while (i < unicodeSurrogates.length) { - c = unicodeSurrogates.charCodeAt(i++); - - if (p) { - r.push((0x10000 + ((p - 0xD800) << 10) + (c - 0xDC00)).toString(16)); - p = 0; - } else if (0xD800 <= c && c <= 0xDBFF) { - p = c; - } else { - r.push(c.toString(16)); - } - } - - return r.join(sep); -}; - export const buildCustomEmojis = customEmojis => { const emojis = []; @@ -76,12 +57,14 @@ export const buildCustomEmojis = customEmojis => { const name = shortcode.replace(':', ''); emojis.push({ + id: name, name, short_names: [name], text: '', emoticons: [], keywords: [name], imageUrl: url, + custom: true, }); }); 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 f55d59e03..3cac9b7a6 100644 --- a/app/javascript/mastodon/features/compose/components/emoji_picker_dropdown.js +++ b/app/javascript/mastodon/features/compose/components/emoji_picker_dropdown.js @@ -1,11 +1,10 @@ import React from 'react'; import PropTypes from 'prop-types'; import { defineMessages, injectIntl } from 'react-intl'; -import { EmojiPicker as EmojiPickerAsync } from '../../ui/util/async-components'; +import { Picker, Emoji } from 'emoji-mart'; import { Overlay } from 'react-overlays'; import classNames from 'classnames'; import ImmutablePropTypes from 'react-immutable-proptypes'; -import { buildCustomEmojis } from '../../../emoji'; const messages = defineMessages({ emoji: { id: 'emoji_button.label', defaultMessage: 'Insert emoji' }, @@ -26,8 +25,6 @@ const messages = defineMessages({ const assetHost = process.env.CDN_HOST || ''; -let EmojiPicker, Emoji; // load asynchronously - const backgroundImageFn = () => `${assetHost}/emoji/sheet.png`; class ModifierPickerMenu extends React.PureComponent { @@ -133,7 +130,6 @@ class EmojiPickerMenu extends React.PureComponent { static propTypes = { custom_emojis: ImmutablePropTypes.list, - loading: PropTypes.bool, onClose: PropTypes.func.isRequired, onPick: PropTypes.func.isRequired, style: PropTypes.object, @@ -145,7 +141,6 @@ class EmojiPickerMenu extends React.PureComponent { static defaultProps = { style: {}, - loading: true, placement: 'bottom', }; @@ -220,19 +215,13 @@ class EmojiPickerMenu extends React.PureComponent { } render () { - const { loading, style, intl } = this.props; - - if (loading) { - return <div style={{ width: 299 }} />; - } - + const { style, intl } = this.props; const title = intl.formatMessage(messages.emoji); const { modifierOpen, modifier } = this.state; return ( <div className={classNames('emoji-picker-dropdown__menu', { selecting: modifierOpen })} style={style} ref={this.setRef}> - <EmojiPicker - custom={buildCustomEmojis(this.props.custom_emojis)} + <Picker perLine={8} emojiSize={22} sheetSize={32} @@ -270,7 +259,6 @@ export default class EmojiPickerDropdown extends React.PureComponent { state = { active: false, - loading: false, }; setRef = (c) => { @@ -279,18 +267,6 @@ export default class EmojiPickerDropdown extends React.PureComponent { onShowDropdown = () => { this.setState({ active: true }); - - if (!EmojiPicker) { - this.setState({ loading: true }); - - EmojiPickerAsync().then(EmojiMart => { - EmojiPicker = EmojiMart.Picker; - Emoji = EmojiMart.Emoji; - this.setState({ loading: false }); - }).catch(() => { - this.setState({ loading: false }); - }); - } } onHideDropdown = () => { @@ -298,7 +274,7 @@ export default class EmojiPickerDropdown extends React.PureComponent { } onToggle = (e) => { - if (!this.state.loading && (!e.key || e.key === 'Enter')) { + if (!e.key || e.key === 'Enter') { if (this.state.active) { this.onHideDropdown(); } else { @@ -324,13 +300,13 @@ export default class EmojiPickerDropdown extends React.PureComponent { render () { const { intl, onPickEmoji } = this.props; const title = intl.formatMessage(messages.emoji); - const { active, loading } = this.state; + const { active } = this.state; return ( <div className='emoji-picker-dropdown' onKeyDown={this.handleKeyDown}> <div ref={this.setTargetRef} className='emoji-button' title={title} aria-label={title} aria-expanded={active} role='button' onClick={this.onToggle} onKeyDown={this.onToggle} tabIndex={0}> <img - className={classNames('emojione', { 'pulse-loading': active && loading })} + className='emojione' alt='🙂' src={`${assetHost}/emoji/1f602.svg`} /> @@ -339,7 +315,6 @@ export default class EmojiPickerDropdown extends React.PureComponent { <Overlay show={active} placement='bottom' target={this.findTarget}> <EmojiPickerMenu custom_emojis={this.props.custom_emojis} - loading={loading} onClose={this.onHideDropdown} onPick={onPickEmoji} /> diff --git a/app/javascript/mastodon/features/ui/util/async-components.js b/app/javascript/mastodon/features/ui/util/async-components.js index bd2fca337..b8c5e885a 100644 --- a/app/javascript/mastodon/features/ui/util/async-components.js +++ b/app/javascript/mastodon/features/ui/util/async-components.js @@ -1,7 +1,3 @@ -export function EmojiPicker () { - return import(/* webpackChunkName: "emoji_picker" */'emoji-mart'); -} - export function Compose () { return import(/* webpackChunkName: "features/compose" */'../../compose'); } diff --git a/app/javascript/mastodon/reducers/accounts.js b/app/javascript/mastodon/reducers/accounts.js index 6442d13be..5391a93ae 100644 --- a/app/javascript/mastodon/reducers/accounts.js +++ b/app/javascript/mastodon/reducers/accounts.js @@ -110,7 +110,7 @@ export default function accounts(state = initialState, action) { case BLOCKS_EXPAND_SUCCESS: case MUTES_FETCH_SUCCESS: case MUTES_EXPAND_SUCCESS: - return normalizeAccounts(state, action.accounts); + return action.accounts ? normalizeAccounts(state, action.accounts) : state; case NOTIFICATIONS_REFRESH_SUCCESS: case NOTIFICATIONS_EXPAND_SUCCESS: case SEARCH_FETCH_SUCCESS: diff --git a/app/javascript/mastodon/reducers/accounts_counters.js b/app/javascript/mastodon/reducers/accounts_counters.js index 4423e1b50..1ed0fe3e3 100644 --- a/app/javascript/mastodon/reducers/accounts_counters.js +++ b/app/javascript/mastodon/reducers/accounts_counters.js @@ -106,7 +106,7 @@ export default function accountsCounters(state = initialState, action) { case BLOCKS_EXPAND_SUCCESS: case MUTES_FETCH_SUCCESS: case MUTES_EXPAND_SUCCESS: - return normalizeAccounts(state, action.accounts); + return action.accounts ? normalizeAccounts(state, action.accounts) : state; case NOTIFICATIONS_REFRESH_SUCCESS: case NOTIFICATIONS_EXPAND_SUCCESS: case SEARCH_FETCH_SUCCESS: diff --git a/app/javascript/mastodon/reducers/compose.js b/app/javascript/mastodon/reducers/compose.js index 526dbd0c5..9d39584fc 100644 --- a/app/javascript/mastodon/reducers/compose.js +++ b/app/javascript/mastodon/reducers/compose.js @@ -245,7 +245,7 @@ export default function compose(state = initialState, action) { case COMPOSE_SUGGESTIONS_CLEAR: return state.update('suggestions', ImmutableList(), list => list.clear()).set('suggestion_token', null); case COMPOSE_SUGGESTIONS_READY: - return state.set('suggestions', ImmutableList(action.accounts.map(item => item.id))).set('suggestion_token', action.token); + return state.set('suggestions', ImmutableList(action.accounts ? action.accounts.map(item => item.id) : action.emojis)).set('suggestion_token', action.token); case COMPOSE_SUGGESTION_SELECT: return insertSuggestion(state, action.position, action.token, action.completion); case TIMELINE_DELETE: diff --git a/app/javascript/mastodon/reducers/custom_emojis.js b/app/javascript/mastodon/reducers/custom_emojis.js index 15bba7bcc..d80c0d156 100644 --- a/app/javascript/mastodon/reducers/custom_emojis.js +++ b/app/javascript/mastodon/reducers/custom_emojis.js @@ -1,11 +1,14 @@ import { List as ImmutableList } from 'immutable'; import { STORE_HYDRATE } from '../actions/store'; +import { emojiIndex } from 'emoji-mart'; +import { buildCustomEmojis } from '../emoji'; const initialState = ImmutableList(); -export default function statuses(state = initialState, action) { +export default function custom_emojis(state = initialState, action) { switch(action.type) { case STORE_HYDRATE: + emojiIndex.search('', { custom: buildCustomEmojis(action.state.get('custom_emojis', [])) }); return action.state.get('custom_emojis'); default: return state; diff --git a/app/javascript/styles/components.scss b/app/javascript/styles/components.scss index 595ab3658..755c9eb35 100644 --- a/app/javascript/styles/components.scss +++ b/app/javascript/styles/components.scss @@ -1880,15 +1880,18 @@ } .autosuggest-textarea__suggestions { + box-sizing: border-box; display: none; position: absolute; top: 100%; width: 100%; z-index: 99; - box-shadow: 0 0 15px rgba($base-shadow-color, 0.4); + box-shadow: 4px 4px 6px rgba($base-shadow-color, 0.4); background: $ui-secondary-color; + border-radius: 0 0 4px 4px; color: $ui-base-color; font-size: 14px; + padding: 6px; &.autosuggest-textarea__suggestions--visible { display: block; @@ -1898,34 +1901,36 @@ .autosuggest-textarea__suggestions__item { padding: 10px; cursor: pointer; + border-radius: 4px; - &:hover { - background: darken($ui-secondary-color, 10%); - } - + &:hover, + &:focus, + &:active, &.selected { - background: $ui-highlight-color; - color: $base-border-color; + background: darken($ui-secondary-color, 10%); } } -.autosuggest-account { - overflow: hidden; +.autosuggest-account, +.autosuggest-emoji { + display: flex; + flex-direction: row; + align-items: center; + justify-content: flex-start; + line-height: 18px; + font-size: 14px; } -.autosuggest-account-icon { - float: left; - margin-right: 5px; +.autosuggest-account-icon, +.autosuggest-emoji img { + display: block; + margin-right: 8px; + width: 16px; + height: 16px; } -.autosuggest-status { - overflow: hidden; - white-space: nowrap; - text-overflow: ellipsis; - - strong { - font-weight: 500; - } +.autosuggest-account .display-name__account { + color: lighten($ui-base-color, 36%); } .character-counter__wrapper { diff --git a/app/lib/emoji.rb b/app/lib/emoji.rb deleted file mode 100644 index 45b7f53de..000000000 --- a/app/lib/emoji.rb +++ /dev/null @@ -1,40 +0,0 @@ -# frozen_string_literal: true - -require 'singleton' - -class Emoji - include Singleton - - def initialize - data = Oj.load(File.open(Rails.root.join('lib', 'assets', 'emoji.json'))) - - @map = {} - - data.each do |_, emoji| - keys = [emoji['shortname']] + emoji['aliases'] - unicode = codepoint_to_unicode(emoji['unicode']) - - keys.each do |key| - @map[key] = unicode - end - end - end - - def unicode(shortcode) - @map[shortcode] - end - - def names - @map.keys - end - - private - - def codepoint_to_unicode(codepoint) - if codepoint.include?('-') - codepoint.split('-').map(&:hex).pack('U*') - else - [codepoint.hex].pack('U') - end - end -end diff --git a/app/models/account.rb b/app/models/account.rb index f175c7081..0b025d1be 100644 --- a/app/models/account.rb +++ b/app/models/account.rb @@ -52,7 +52,6 @@ class Account < ApplicationRecord include AccountInteractions include Attachmentable include Remotable - include EmojiHelper enum protocol: [:ostatus, :activitypub] @@ -269,9 +268,6 @@ class Account < ApplicationRecord def prepare_contents display_name&.strip! note&.strip! - - self.display_name = emojify(display_name) - self.note = emojify(note) end def generate_keys diff --git a/app/models/status.rb b/app/models/status.rb index f11064cbd..ea4c097bf 100644 --- a/app/models/status.rb +++ b/app/models/status.rb @@ -30,7 +30,6 @@ class Status < ApplicationRecord include Streamable include Cacheable include StatusThreadingConcern - include EmojiHelper enum visibility: [:public, :unlisted, :private, :direct], _suffix: :visibility @@ -267,9 +266,6 @@ class Status < ApplicationRecord def prepare_contents text&.strip! spoiler_text&.strip! - - self.text = emojify(text) - self.spoiler_text = emojify(spoiler_text) end def set_reblog diff --git a/app/views/layouts/application.html.haml b/app/views/layouts/application.html.haml index bd846552b..8c6fc27df 100755 --- a/app/views/layouts/application.html.haml +++ b/app/views/layouts/application.html.haml @@ -28,7 +28,6 @@ %link{ href: asset_pack_path('features/notifications.js'), crossorigin: 'anonymous', rel: 'preload', as: 'script' }/ %link{ href: asset_pack_path('features/community_timeline.js'), crossorigin: 'anonymous', rel: 'preload', as: 'script' }/ %link{ href: asset_pack_path('features/public_timeline.js'), crossorigin: 'anonymous', rel: 'preload', as: 'script' }/ - %link{ href: asset_pack_path('emoji_picker.js'), crossorigin: 'anonymous', rel: 'preload', as: 'script' }/ = javascript_pack_tag "locale_#{I18n.locale}", integrity: true, crossorigin: 'anonymous' = csrf_meta_tags |