about summary refs log tree commit diff
path: root/app/javascript
diff options
context:
space:
mode:
authorEugen Rochko <eugen@zeonfederated.com>2017-09-23 14:47:32 +0200
committerGitHub <noreply@github.com>2017-09-23 14:47:32 +0200
commit1e02ba111ae38ab758135b5b2b46f34c672ca02e (patch)
tree62c916782bf9340837a12fcb0c79bb9d481b4ce8 /app/javascript
parent66126f302167d21e4bf247e660f595ff0beaaf20 (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/javascript')
-rw-r--r--app/javascript/mastodon/actions/compose.js35
-rw-r--r--app/javascript/mastodon/components/autosuggest_emoji.js37
-rw-r--r--app/javascript/mastodon/components/autosuggest_textarea.js43
-rw-r--r--app/javascript/mastodon/emoji.js21
-rw-r--r--app/javascript/mastodon/features/compose/components/emoji_picker_dropdown.js37
-rw-r--r--app/javascript/mastodon/features/ui/util/async-components.js4
-rw-r--r--app/javascript/mastodon/reducers/accounts.js2
-rw-r--r--app/javascript/mastodon/reducers/accounts_counters.js2
-rw-r--r--app/javascript/mastodon/reducers/compose.js2
-rw-r--r--app/javascript/mastodon/reducers/custom_emojis.js5
-rw-r--r--app/javascript/styles/components.scss45
11 files changed, 133 insertions, 100 deletions
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 {