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 ( +
+ {emoji.native + + {emoji.colons} +
+ ); + } + +} 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 = ; + key = suggestion.id; + } else { + inner = ; + key = suggestion; + } + + return ( +
+ {inner} +
+ ); + } + 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 {