* 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 oncetags/v2.0.0rc1
| @@ -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 | |||
| @@ -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, | |||
| }); | |||
| @@ -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> | |||
| ); | |||
| } | |||
| } | |||
| @@ -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> | |||
| ); | |||
| @@ -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, | |||
| }); | |||
| }); | |||
| @@ -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} | |||
| /> | |||
| @@ -1,7 +1,3 @@ | |||
| export function EmojiPicker () { | |||
| return import(/* webpackChunkName: "emoji_picker" */'emoji-mart'); | |||
| } | |||
| export function Compose () { | |||
| return import(/* webpackChunkName: "features/compose" */'../../compose'); | |||
| } | |||
| @@ -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: | |||
| @@ -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: | |||
| @@ -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: | |||
| @@ -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; | |||
| @@ -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 { | |||
| @@ -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 | |||
| @@ -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 | |||
| @@ -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 | |||
| @@ -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 | |||
| @@ -1,20 +0,0 @@ | |||
| require 'rails_helper' | |||
| RSpec.describe EmojiHelper, type: :helper do | |||
| describe '#emojify' do | |||
| it 'converts shortcodes to unicode' do | |||
| text = ':book: Book' | |||
| expect(emojify(text)).to eq '📖 Book' | |||
| end | |||
| it 'converts composite emoji shortcodes to unicode' do | |||
| text = ':couple_ww:' | |||
| expect(emojify(text)).to eq '👩❤👩' | |||
| end | |||
| it 'does not convert shortcodes that are part of a string into unicode' do | |||
| text = ':see_no_evil::hear_no_evil::speak_no_evil:' | |||
| expect(emojify(text)).to eq text | |||
| end | |||
| end | |||
| end | |||
| @@ -1,15 +0,0 @@ | |||
| require 'rails_helper' | |||
| RSpec.describe Emoji do | |||
| describe '#unicode' do | |||
| it 'returns a unicode for a shortcode' do | |||
| expect(Emoji.instance.unicode(':joy:')).to eq '😂' | |||
| end | |||
| end | |||
| describe '#names' do | |||
| it 'returns an array' do | |||
| expect(Emoji.instance.names).to be_an Array | |||
| end | |||
| end | |||
| end | |||