about summary refs log tree commit diff
diff options
context:
space:
mode:
-rw-r--r--app/controllers/api/v1/custom_emojis_controller.rb2
-rw-r--r--app/javascript/flavours/glitch/actions/search.js7
-rw-r--r--app/javascript/flavours/glitch/features/compose/components/search.js18
-rw-r--r--app/javascript/flavours/glitch/features/compose/components/search_results.js16
-rw-r--r--app/javascript/flavours/glitch/features/compose/containers/search_results_container.js1
-rw-r--r--app/javascript/flavours/glitch/features/emoji_picker/index.js30
-rw-r--r--app/javascript/flavours/glitch/features/ui/components/columns_area.js6
-rw-r--r--app/javascript/flavours/glitch/features/ui/index.js2
-rw-r--r--app/javascript/flavours/glitch/reducers/search.js3
-rw-r--r--app/javascript/flavours/glitch/styles/components/drawer.scss78
-rw-r--r--app/javascript/flavours/glitch/styles/components/search.scss32
-rw-r--r--app/javascript/flavours/glitch/styles/components/single_column.scss2
-rw-r--r--app/javascript/flavours/glitch/util/emoji/index.js3
-rw-r--r--app/javascript/mastodon/actions/search.js5
-rw-r--r--app/javascript/mastodon/features/compose/components/emoji_picker_dropdown.js30
-rw-r--r--app/javascript/mastodon/features/compose/components/search_results.js14
-rw-r--r--app/javascript/mastodon/features/compose/containers/search_results_container.js1
-rw-r--r--app/javascript/mastodon/features/emoji/emoji.js3
-rw-r--r--app/javascript/mastodon/features/ui/components/columns_area.js6
-rw-r--r--app/javascript/mastodon/reducers/search.js3
-rw-r--r--app/javascript/styles/mastodon/components.scss5
-rw-r--r--app/models/custom_emoji.rb2
-rw-r--r--app/models/custom_emoji_category.rb15
-rw-r--r--app/serializers/activitypub/update_poll_serializer.rb2
-rw-r--r--app/serializers/rest/custom_emoji_serializer.rb10
-rw-r--r--db/migrate/20190627222225_create_custom_emoji_categories.rb9
-rw-r--r--db/migrate/20190627222826_add_category_id_to_custom_emojis.rb5
-rw-r--r--db/schema.rb10
-rw-r--r--lib/mastodon/emoji_cli.rb6
-rw-r--r--spec/fabricators/custom_emoji_category_fabricator.rb3
-rw-r--r--spec/models/custom_emoji_category_spec.rb5
-rw-r--r--spec/serializers/activitypub/update_poll_spec.rb27
-rw-r--r--spec/workers/activitypub/distribute_poll_update_worker_spec.rb22
-rw-r--r--yarn.lock4
34 files changed, 248 insertions, 139 deletions
diff --git a/app/controllers/api/v1/custom_emojis_controller.rb b/app/controllers/api/v1/custom_emojis_controller.rb
index 1bb19a09d..b6877fb3c 100644
--- a/app/controllers/api/v1/custom_emojis_controller.rb
+++ b/app/controllers/api/v1/custom_emojis_controller.rb
@@ -7,7 +7,7 @@ class Api::V1::CustomEmojisController < Api::BaseController
 
   def index
     render_cached_json('api:v1:custom_emojis', expires_in: 1.minute) do
-      ActiveModelSerializers::SerializableResource.new(CustomEmoji.local.where(disabled: false), each_serializer: REST::CustomEmojiSerializer)
+      ActiveModelSerializers::SerializableResource.new(CustomEmoji.local.where(disabled: false).includes(:category), each_serializer: REST::CustomEmojiSerializer)
     end
   end
 end
diff --git a/app/javascript/flavours/glitch/actions/search.js b/app/javascript/flavours/glitch/actions/search.js
index b2d24e10b..9ce77b24b 100644
--- a/app/javascript/flavours/glitch/actions/search.js
+++ b/app/javascript/flavours/glitch/actions/search.js
@@ -48,7 +48,7 @@ export function submitSearch() {
         dispatch(importFetchedStatuses(response.data.statuses));
       }
 
-      dispatch(fetchSearchSuccess(response.data));
+      dispatch(fetchSearchSuccess(response.data, value));
       dispatch(fetchRelationships(response.data.accounts.map(item => item.id)));
     }).catch(error => {
       dispatch(fetchSearchFail(error));
@@ -62,12 +62,11 @@ export function fetchSearchRequest() {
   };
 };
 
-export function fetchSearchSuccess(results) {
+export function fetchSearchSuccess(results, searchTerm) {
   return {
     type: SEARCH_FETCH_SUCCESS,
     results,
-    accounts: results.accounts,
-    statuses: results.statuses,
+    searchTerm,
   };
 };
 
diff --git a/app/javascript/flavours/glitch/features/compose/components/search.js b/app/javascript/flavours/glitch/features/compose/components/search.js
index 1d96933ea..b25555d0f 100644
--- a/app/javascript/flavours/glitch/features/compose/components/search.js
+++ b/app/javascript/flavours/glitch/features/compose/components/search.js
@@ -36,7 +36,7 @@ class SearchPopout extends React.PureComponent {
       <div style={{ ...style, position: 'absolute', width: 285, zIndex: 2 }}>
         <Motion defaultStyle={{ opacity: 0, scaleX: 0.85, scaleY: 0.75 }} style={{ opacity: spring(1, { damping: 35, stiffness: 400 }), scaleX: spring(1, { damping: 35, stiffness: 400 }), scaleY: spring(1, { damping: 35, stiffness: 400 }) }}>
           {({ opacity, scaleX, scaleY }) => (
-            <div className='drawer--search--popout' style={{ opacity: opacity, transform: `scale(${scaleX}, ${scaleY})` }}>
+            <div className='search-popout' style={{ opacity: opacity, transform: `scale(${scaleX}, ${scaleY})` }}>
               <h4><FormattedMessage id='search_popout.search_format' defaultMessage='Advanced search format' /></h4>
 
               <ul>
@@ -128,14 +128,14 @@ class Search extends React.PureComponent {
   render () {
     const { intl, value, submitted } = this.props;
     const { expanded } = this.state;
-    const active = value.length > 0 || submitted;
-    const computedClass = classNames('drawer--search', { active });
+    const hasValue = value.length > 0 || submitted;
 
     return (
-      <div className={computedClass}>
+      <div className='search'>
         <label>
           <span style={{ display: 'none' }}>{intl.formatMessage(messages.placeholder)}</span>
           <input
+            className='search__input'
             type='text'
             placeholder={intl.formatMessage(messages.placeholder)}
             value={value || ''}
@@ -145,17 +145,19 @@ class Search extends React.PureComponent {
             onBlur={this.handleBlur}
           />
         </label>
+
         <div
           aria-label={intl.formatMessage(messages.placeholder)}
-          className='icon'
+          className='search__icon'
           onClick={this.handleClear}
           role='button'
           tabIndex='0'
         >
-          <Icon icon='search' />
-          <Icon icon='times-circle' />
+          <Icon icon='search' className={hasValue ? '' : 'active'} />
+          <Icon icon='times-circle' className={hasValue ? 'active' : ''} />
         </div>
-        <Overlay show={expanded && !active} placement='bottom' target={this}>
+
+        <Overlay show={expanded && !hasValue} placement='bottom' target={this}>
           <SearchPopout />
         </Overlay>
       </div>
diff --git a/app/javascript/flavours/glitch/features/compose/components/search_results.js b/app/javascript/flavours/glitch/features/compose/components/search_results.js
index 69df8cdc9..dd99f3430 100644
--- a/app/javascript/flavours/glitch/features/compose/components/search_results.js
+++ b/app/javascript/flavours/glitch/features/compose/components/search_results.js
@@ -7,6 +7,7 @@ import StatusContainer from 'flavours/glitch/containers/status_container';
 import ImmutablePureComponent from 'react-immutable-pure-component';
 import Hashtag from 'flavours/glitch/components/hashtag';
 import Icon from 'flavours/glitch/components/icon';
+import { searchEnabled } from 'flavours/glitch/util/initial_state';
 
 const messages = defineMessages({
   dismissSuggestion: { id: 'suggestions.dismiss', defaultMessage: 'Dismiss suggestion' },
@@ -20,6 +21,7 @@ class SearchResults extends ImmutablePureComponent {
     suggestions: ImmutablePropTypes.list.isRequired,
     fetchSuggestions: PropTypes.func.isRequired,
     dismissSuggestion: PropTypes.func.isRequired,
+    searchTerm: PropTypes.string,
     intl: PropTypes.object.isRequired,
   };
 
@@ -27,8 +29,8 @@ class SearchResults extends ImmutablePureComponent {
     this.props.fetchSuggestions();
   }
 
-  render() {
-    const { intl, results, suggestions, dismissSuggestion } = this.props;
+  render () {
+    const { intl, results, suggestions, dismissSuggestion, searchTerm } = this.props;
 
     if (results.isEmpty() && !suggestions.isEmpty()) {
       return (
@@ -51,6 +53,16 @@ class SearchResults extends ImmutablePureComponent {
           </div>
         </div>
       );
+    } else if(results.get('statuses') && results.get('statuses').size === 0 && !searchEnabled && !(searchTerm.startsWith('@') || searchTerm.startsWith('#') || searchTerm.includes(' '))) {
+      statuses = (
+        <section>
+          <h5><Icon id='quote-right' fixedWidth /><FormattedMessage id='search_results.statuses' defaultMessage='Toots' /></h5>
+
+          <div className='search-results__info'>
+            <FormattedMessage id='search_results.statuses_fts_disabled' defaultMessage='Searching toots by their content is not enabled on this Mastodon server.' />
+          </div>
+        </section>
+      );
     }
 
     let accounts, statuses, hashtags;
diff --git a/app/javascript/flavours/glitch/features/compose/containers/search_results_container.js b/app/javascript/flavours/glitch/features/compose/containers/search_results_container.js
index f9637861a..e4d5f3420 100644
--- a/app/javascript/flavours/glitch/features/compose/containers/search_results_container.js
+++ b/app/javascript/flavours/glitch/features/compose/containers/search_results_container.js
@@ -5,6 +5,7 @@ import { fetchSuggestions, dismissSuggestion } from '../../../actions/suggestion
 const mapStateToProps = state => ({
   results: state.getIn(['search', 'results']),
   suggestions: state.getIn(['suggestions', 'items']),
+  searchTerm: state.getIn(['search', 'searchTerm']),
 });
 
 const mapDispatchToProps = dispatch => ({
diff --git a/app/javascript/flavours/glitch/features/emoji_picker/index.js b/app/javascript/flavours/glitch/features/emoji_picker/index.js
index a78117971..9821502d3 100644
--- a/app/javascript/flavours/glitch/features/emoji_picker/index.js
+++ b/app/javascript/flavours/glitch/features/emoji_picker/index.js
@@ -11,7 +11,7 @@ import Overlay from 'react-overlays/lib/Overlay';
 import classNames from 'classnames';
 import ImmutablePropTypes from 'react-immutable-proptypes';
 import detectPassiveEvents from 'detect-passive-events';
-import { buildCustomEmojis } from 'flavours/glitch/util/emoji';
+import { buildCustomEmojis, categoriesFromEmojis } from 'flavours/glitch/util/emoji';
 
 const messages = defineMessages({
   emoji: { id: 'emoji_button.label', defaultMessage: 'Insert emoji' },
@@ -110,19 +110,6 @@ let EmojiPicker, Emoji; // load asynchronously
 const backgroundImageFn = () => `${assetHost}/emoji/sheet_10.png`;
 const listenerOptions = detectPassiveEvents.hasSupport ? { passive: true } : false;
 
-const categoriesSort = [
-  'recent',
-  'custom',
-  'people',
-  'nature',
-  'foods',
-  'activity',
-  'places',
-  'objects',
-  'symbols',
-  'flags',
-];
-
 class ModifierPickerMenu extends React.PureComponent {
 
   static propTypes = {
@@ -320,8 +307,23 @@ class EmojiPickerMenu extends React.PureComponent {
     }
 
     const title = intl.formatMessage(messages.emoji);
+
     const { modifierOpen } = this.state;
 
+    const categoriesSort = [
+      'recent',
+      'people',
+      'nature',
+      'foods',
+      'activity',
+      'places',
+      'objects',
+      'symbols',
+      'flags',
+    ];
+
+    categoriesSort.splice(1, 0, ...Array.from(categoriesFromEmojis(custom_emojis)).sort());
+
     return (
       <div className={classNames('emoji-picker-dropdown__menu', { selecting: modifierOpen })} style={style} ref={this.setRef}>
         <EmojiPicker
diff --git a/app/javascript/flavours/glitch/features/ui/components/columns_area.js b/app/javascript/flavours/glitch/features/ui/components/columns_area.js
index 3a188ca87..30097f064 100644
--- a/app/javascript/flavours/glitch/features/ui/components/columns_area.js
+++ b/app/javascript/flavours/glitch/features/ui/components/columns_area.js
@@ -112,6 +112,11 @@ export default class ColumnsArea extends ImmutablePureComponent {
     // React-router does this for us, but too late, feeling laggy.
     document.querySelector(currentLinkSelector).classList.remove('active');
     document.querySelector(nextLinkSelector).classList.add('active');
+
+    if (!this.state.shouldAnimate && typeof this.pendingIndex === 'number') {
+      this.context.router.history.push(getLink(this.pendingIndex));
+      this.pendingIndex = null;
+    }
   }
 
   handleAnimationEnd = () => {
@@ -162,7 +167,6 @@ export default class ColumnsArea extends ImmutablePureComponent {
     const { shouldAnimate } = this.state;
 
     const columnIndex = getIndex(this.context.router.history.location.pathname);
-    this.pendingIndex = null;
 
     if (singleColumn) {
       const floatingActionButton = shouldHideFAB(this.context.router.history.location.pathname) ? null : <Link key='floating-action-button' to='/statuses/new' className='floating-action-button' aria-label={intl.formatMessage(messages.publish)}><i className='fa fa-pencil' /></Link>;
diff --git a/app/javascript/flavours/glitch/features/ui/index.js b/app/javascript/flavours/glitch/features/ui/index.js
index 787488db4..e072c22ec 100644
--- a/app/javascript/flavours/glitch/features/ui/index.js
+++ b/app/javascript/flavours/glitch/features/ui/index.js
@@ -317,7 +317,7 @@ export default class UI extends React.Component {
   handleHotkeySearch = e => {
     e.preventDefault();
 
-    const element = this.node.querySelector('.drawer--search input');
+    const element = this.node.querySelector('.search__input');
 
     if (element) {
       element.focus();
diff --git a/app/javascript/flavours/glitch/reducers/search.js b/app/javascript/flavours/glitch/reducers/search.js
index 9a525bf47..1c32a5b9f 100644
--- a/app/javascript/flavours/glitch/reducers/search.js
+++ b/app/javascript/flavours/glitch/reducers/search.js
@@ -16,6 +16,7 @@ const initialState = ImmutableMap({
   submitted: false,
   hidden: false,
   results: ImmutableMap(),
+  searchTerm: '',
 });
 
 export default function search(state = initialState, action) {
@@ -40,7 +41,7 @@ export default function search(state = initialState, action) {
       accounts: ImmutableList(action.results.accounts.map(item => item.id)),
       statuses: ImmutableList(action.results.statuses.map(item => item.id)),
       hashtags: fromJS(action.results.hashtags),
-    })).set('submitted', true);
+    })).set('submitted', true).set('searchTerm', action.searchTerm);
   default:
     return state;
   }
diff --git a/app/javascript/flavours/glitch/styles/components/drawer.scss b/app/javascript/flavours/glitch/styles/components/drawer.scss
index 0e50482f6..0994a9b43 100644
--- a/app/javascript/flavours/glitch/styles/components/drawer.scss
+++ b/app/javascript/flavours/glitch/styles/components/drawer.scss
@@ -73,90 +73,16 @@
   }
 }
 
-.drawer--search {
+.search {
   position: relative;
   margin-bottom: 10px;
   flex: none;
 
   @include limited-single-column('screen and (max-width: 360px)') { margin-bottom: 0 }
   @include single-column('screen and (max-width: 630px)') { font-size: 16px }
-
-  input {
-    display: block;
-    box-sizing: border-box;
-    margin: 0;
-    border: none;
-    padding: 15px 30px 15px 15px;
-    width: 100%;
-    outline: 0;
-    color: $darker-text-color;
-    background: $ui-base-color;
-    font-size: 14px;
-    font-family: inherit;
-    line-height: 16px;
-
-    &:focus {
-      outline: 0;
-      background: lighten($ui-base-color, 4%);
-    }
-  }
-
-  & > .icon {
-    display: block;
-    position: absolute;
-    top: 10px;
-    right: 10px;
-    width: 18px;
-    height: 18px;
-    color: $secondary-text-color;
-    font-size: 18px;
-    line-height: 18px;
-    z-index: 2;
-
-    .fa {
-      display: inline-block;
-      position: absolute;
-      top: 0;
-      bottom: 0;
-      left: 0;
-      right: 0;
-      opacity: 0;
-      cursor: default;
-      pointer-events: none;
-      transition: all 100ms linear;
-      transition-property: color, transform, opacity;
-    }
-
-    .fa-search {
-      opacity: 0.3;
-      transform: rotate(0deg);
-    }
-
-    .fa-times-circle {
-      transform: rotate(-90deg);
-      cursor: pointer;
-
-      &:hover { color: $primary-text-color }
-    }
-  }
-
-  &.active {
-    & > .icon {
-      .fa-search {
-        opacity: 0;
-        transform: rotate(90deg);
-      }
-
-      .fa-times-circle {
-        opacity: 0.3;
-        pointer-events: auto;
-        transform: rotate(0deg);
-      }
-    }
-  }
 }
 
-.drawer--search--popout {
+.search-popout {
   @include search-popout();
 }
 
diff --git a/app/javascript/flavours/glitch/styles/components/search.scss b/app/javascript/flavours/glitch/styles/components/search.scss
index 3ef141133..117da362f 100644
--- a/app/javascript/flavours/glitch/styles/components/search.scss
+++ b/app/javascript/flavours/glitch/styles/components/search.scss
@@ -3,13 +3,25 @@
 }
 
 .search__input {
+  @include search-input();
+
   display: block;
-  padding: 10px;
+  padding: 15px;
   padding-right: 30px;
-  @include search-input();
+  line-height: 18px;
+  font-size: 16px;
 }
 
 .search__icon {
+  &::-moz-focus-inner {
+    border: 0;
+  }
+
+  &::-moz-focus-inner,
+  &:focus {
+    outline: 0 !important;
+  }
+
   .fa {
     position: absolute;
     top: 16px;
@@ -18,7 +30,7 @@
     display: inline-block;
     opacity: 0;
     transition: all 100ms linear;
-    transition-property: transform, opacity;
+    transition-property: color, transform, opacity;
     font-size: 18px;
     width: 18px;
     height: 18px;
@@ -33,17 +45,18 @@
   }
 
   .fa-search {
-    transform: rotate(90deg);
+    transform: rotate(0deg);
 
     &.active {
-      pointer-events: none;
-      transform: rotate(0deg);
+      pointer-events: auto;
+      opacity: 0.3;
     }
   }
 
   .fa-times-circle {
     top: 17px;
     transform: rotate(0deg);
+    color: $action-button-color;
     cursor: pointer;
 
     &.active {
@@ -51,7 +64,7 @@
     }
 
     &:hover {
-      color: $primary-text-color;
+      color: lighten($action-button-color, 7%);
     }
   }
 }
@@ -65,6 +78,11 @@
   font-weight: 500;
 }
 
+.search-results__info {
+  padding: 10px;
+  color: $secondary-text-color;
+}
+
 .trends {
   &__header {
     color: $dark-text-color;
diff --git a/app/javascript/flavours/glitch/styles/components/single_column.scss b/app/javascript/flavours/glitch/styles/components/single_column.scss
index ca962abd2..e0f3d62a7 100644
--- a/app/javascript/flavours/glitch/styles/components/single_column.scss
+++ b/app/javascript/flavours/glitch/styles/components/single_column.scss
@@ -6,7 +6,7 @@
   height: calc(100% - 10px);
   overflow-y: hidden;
 
-  .drawer--search input {
+  .search__input {
     line-height: 18px;
     font-size: 16px;
     padding: 15px;
diff --git a/app/javascript/flavours/glitch/util/emoji/index.js b/app/javascript/flavours/glitch/util/emoji/index.js
index 82a1ef89c..e6fcaf0dc 100644
--- a/app/javascript/flavours/glitch/util/emoji/index.js
+++ b/app/javascript/flavours/glitch/util/emoji/index.js
@@ -93,8 +93,11 @@ export const buildCustomEmojis = (customEmojis) => {
       keywords: [name],
       imageUrl: url,
       custom: true,
+      customCategory: emoji.get('category'),
     });
   });
 
   return emojis;
 };
+
+export const categoriesFromEmojis = customEmojis => customEmojis.reduce((set, emoji) => set.add(emoji.get('category') ? `custom-${emoji.get('category')}` : 'custom'), new Set());
diff --git a/app/javascript/mastodon/actions/search.js b/app/javascript/mastodon/actions/search.js
index 7c06670eb..0974fdd15 100644
--- a/app/javascript/mastodon/actions/search.js
+++ b/app/javascript/mastodon/actions/search.js
@@ -48,7 +48,7 @@ export function submitSearch() {
         dispatch(importFetchedStatuses(response.data.statuses));
       }
 
-      dispatch(fetchSearchSuccess(response.data));
+      dispatch(fetchSearchSuccess(response.data, value));
       dispatch(fetchRelationships(response.data.accounts.map(item => item.id)));
     }).catch(error => {
       dispatch(fetchSearchFail(error));
@@ -62,10 +62,11 @@ export function fetchSearchRequest() {
   };
 };
 
-export function fetchSearchSuccess(results) {
+export function fetchSearchSuccess(results, searchTerm) {
   return {
     type: SEARCH_FETCH_SUCCESS,
     results,
+    searchTerm,
   };
 };
 
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 c1429c756..e57c3c20c 100644
--- a/app/javascript/mastodon/features/compose/components/emoji_picker_dropdown.js
+++ b/app/javascript/mastodon/features/compose/components/emoji_picker_dropdown.js
@@ -6,7 +6,7 @@ import Overlay from 'react-overlays/lib/Overlay';
 import classNames from 'classnames';
 import ImmutablePropTypes from 'react-immutable-proptypes';
 import detectPassiveEvents from 'detect-passive-events';
-import { buildCustomEmojis } from '../../emoji/emoji';
+import { buildCustomEmojis, categoriesFromEmojis } from '../../emoji/emoji';
 
 const messages = defineMessages({
   emoji: { id: 'emoji_button.label', defaultMessage: 'Insert emoji' },
@@ -31,19 +31,6 @@ let EmojiPicker, Emoji; // load asynchronously
 const backgroundImageFn = () => `${assetHost}/emoji/sheet_10.png`;
 const listenerOptions = detectPassiveEvents.hasSupport ? { passive: true } : false;
 
-const categoriesSort = [
-  'recent',
-  'custom',
-  'people',
-  'nature',
-  'foods',
-  'activity',
-  'places',
-  'objects',
-  'symbols',
-  'flags',
-];
-
 class ModifierPickerMenu extends React.PureComponent {
 
   static propTypes = {
@@ -241,8 +228,23 @@ class EmojiPickerMenu extends React.PureComponent {
     }
 
     const title = intl.formatMessage(messages.emoji);
+
     const { modifierOpen } = this.state;
 
+    const categoriesSort = [
+      'recent',
+      'people',
+      'nature',
+      'foods',
+      'activity',
+      'places',
+      'objects',
+      'symbols',
+      'flags',
+    ];
+
+    categoriesSort.splice(1, 0, ...Array.from(categoriesFromEmojis(custom_emojis)).sort());
+
     return (
       <div className={classNames('emoji-picker-dropdown__menu', { selecting: modifierOpen })} style={style} ref={this.setRef}>
         <EmojiPicker
diff --git a/app/javascript/mastodon/features/compose/components/search_results.js b/app/javascript/mastodon/features/compose/components/search_results.js
index e0966f52c..2f338dd24 100644
--- a/app/javascript/mastodon/features/compose/components/search_results.js
+++ b/app/javascript/mastodon/features/compose/components/search_results.js
@@ -7,6 +7,7 @@ import StatusContainer from '../../../containers/status_container';
 import ImmutablePureComponent from 'react-immutable-pure-component';
 import Hashtag from '../../../components/hashtag';
 import Icon from 'mastodon/components/icon';
+import { searchEnabled } from '../../../initial_state';
 
 const messages = defineMessages({
   dismissSuggestion: { id: 'suggestions.dismiss', defaultMessage: 'Dismiss suggestion' },
@@ -20,6 +21,7 @@ class SearchResults extends ImmutablePureComponent {
     suggestions: ImmutablePropTypes.list.isRequired,
     fetchSuggestions: PropTypes.func.isRequired,
     dismissSuggestion: PropTypes.func.isRequired,
+    searchTerm: PropTypes.string,
     intl: PropTypes.object.isRequired,
   };
 
@@ -28,7 +30,7 @@ class SearchResults extends ImmutablePureComponent {
   }
 
   render () {
-    const { intl, results, suggestions, dismissSuggestion } = this.props;
+    const { intl, results, suggestions, dismissSuggestion, searchTerm } = this.props;
 
     if (results.isEmpty() && !suggestions.isEmpty()) {
       return (
@@ -76,6 +78,16 @@ class SearchResults extends ImmutablePureComponent {
           {results.get('statuses').map(statusId => <StatusContainer key={statusId} id={statusId} />)}
         </div>
       );
+    } else if(results.get('statuses') && results.get('statuses').size === 0 && !searchEnabled && !(searchTerm.startsWith('@') || searchTerm.startsWith('#') || searchTerm.includes(' '))) {
+      statuses = (
+        <div className='search-results__section'>
+          <h5><Icon id='quote-right' fixedWidth /><FormattedMessage id='search_results.statuses' defaultMessage='Toots' /></h5>
+
+          <div className='search-results__info'>
+            <FormattedMessage id='search_results.statuses_fts_disabled' defaultMessage='Searching toots by their content is not enabled on this Mastodon server.' />
+          </div>
+        </div>
+      );
     }
 
     if (results.get('hashtags') && results.get('hashtags').size > 0) {
diff --git a/app/javascript/mastodon/features/compose/containers/search_results_container.js b/app/javascript/mastodon/features/compose/containers/search_results_container.js
index f9637861a..e4d5f3420 100644
--- a/app/javascript/mastodon/features/compose/containers/search_results_container.js
+++ b/app/javascript/mastodon/features/compose/containers/search_results_container.js
@@ -5,6 +5,7 @@ import { fetchSuggestions, dismissSuggestion } from '../../../actions/suggestion
 const mapStateToProps = state => ({
   results: state.getIn(['search', 'results']),
   suggestions: state.getIn(['suggestions', 'items']),
+  searchTerm: state.getIn(['search', 'searchTerm']),
 });
 
 const mapDispatchToProps = dispatch => ({
diff --git a/app/javascript/mastodon/features/emoji/emoji.js b/app/javascript/mastodon/features/emoji/emoji.js
index 988cea253..01b5a6664 100644
--- a/app/javascript/mastodon/features/emoji/emoji.js
+++ b/app/javascript/mastodon/features/emoji/emoji.js
@@ -92,8 +92,11 @@ export const buildCustomEmojis = (customEmojis) => {
       keywords: [name],
       imageUrl: url,
       custom: true,
+      customCategory: emoji.get('category'),
     });
   });
 
   return emojis;
 };
+
+export const categoriesFromEmojis = customEmojis => customEmojis.reduce((set, emoji) => set.add(emoji.get('category') ? `custom-${emoji.get('category')}` : 'custom'), new Set());
diff --git a/app/javascript/mastodon/features/ui/components/columns_area.js b/app/javascript/mastodon/features/ui/components/columns_area.js
index 756db3c61..042e44e43 100644
--- a/app/javascript/mastodon/features/ui/components/columns_area.js
+++ b/app/javascript/mastodon/features/ui/components/columns_area.js
@@ -110,6 +110,11 @@ class ColumnsArea extends ImmutablePureComponent {
     // React-router does this for us, but too late, feeling laggy.
     document.querySelector(currentLinkSelector).classList.remove('active');
     document.querySelector(nextLinkSelector).classList.add('active');
+
+    if (!this.state.shouldAnimate && typeof this.pendingIndex === 'number') {
+      this.context.router.history.push(getLink(this.pendingIndex));
+      this.pendingIndex = null;
+    }
   }
 
   handleAnimationEnd = () => {
@@ -160,7 +165,6 @@ class ColumnsArea extends ImmutablePureComponent {
     const { shouldAnimate } = this.state;
 
     const columnIndex = getIndex(this.context.router.history.location.pathname);
-    this.pendingIndex = null;
 
     if (singleColumn) {
       const floatingActionButton = shouldHideFAB(this.context.router.history.location.pathname) ? null : <Link key='floating-action-button' to='/statuses/new' className='floating-action-button' aria-label={intl.formatMessage(messages.publish)}><Icon id='pencil' /></Link>;
diff --git a/app/javascript/mastodon/reducers/search.js b/app/javascript/mastodon/reducers/search.js
index 4758defb1..77b7f588c 100644
--- a/app/javascript/mastodon/reducers/search.js
+++ b/app/javascript/mastodon/reducers/search.js
@@ -16,6 +16,7 @@ const initialState = ImmutableMap({
   submitted: false,
   hidden: false,
   results: ImmutableMap(),
+  searchTerm: '',
 });
 
 export default function search(state = initialState, action) {
@@ -40,7 +41,7 @@ export default function search(state = initialState, action) {
       accounts: ImmutableList(action.results.accounts.map(item => item.id)),
       statuses: ImmutableList(action.results.statuses.map(item => item.id)),
       hashtags: fromJS(action.results.hashtags),
-    })).set('submitted', true);
+    })).set('submitted', true).set('searchTerm', action.searchTerm);
   default:
     return state;
   }
diff --git a/app/javascript/styles/mastodon/components.scss b/app/javascript/styles/mastodon/components.scss
index dc750dbfe..1063d1836 100644
--- a/app/javascript/styles/mastodon/components.scss
+++ b/app/javascript/styles/mastodon/components.scss
@@ -3996,6 +3996,11 @@ a.status-card.compact:hover {
   }
 }
 
+.search-results__info {
+  padding: 10px;
+  color: $secondary-text-color;
+}
+
 .modal-root {
   position: relative;
   transition: opacity 0.3s linear;
diff --git a/app/models/custom_emoji.rb b/app/models/custom_emoji.rb
index e73cd9bd2..514cf4845 100644
--- a/app/models/custom_emoji.rb
+++ b/app/models/custom_emoji.rb
@@ -16,6 +16,7 @@
 #  uri                :string
 #  image_remote_url   :string
 #  visible_in_picker  :boolean          default(TRUE), not null
+#  category_id        :bigint(8)
 #
 
 class CustomEmoji < ApplicationRecord
@@ -27,6 +28,7 @@ class CustomEmoji < ApplicationRecord
     :(#{SHORTCODE_RE_FRAGMENT}):
     (?=[^[:alnum:]:]|$)/x
 
+  belongs_to :category, class_name: 'CustomEmojiCategory', optional: true
   has_one :local_counterpart, -> { where(domain: nil) }, class_name: 'CustomEmoji', primary_key: :shortcode, foreign_key: :shortcode
 
   has_attached_file :image, styles: { static: { format: 'png', convert_options: '-coalesce -strip' } }
diff --git a/app/models/custom_emoji_category.rb b/app/models/custom_emoji_category.rb
new file mode 100644
index 000000000..7d8c0ee2d
--- /dev/null
+++ b/app/models/custom_emoji_category.rb
@@ -0,0 +1,15 @@
+# frozen_string_literal: true
+
+# == Schema Information
+#
+# Table name: custom_emoji_categories
+#
+#  id         :bigint(8)        not null, primary key
+#  name       :string
+#  created_at :datetime         not null
+#  updated_at :datetime         not null
+#
+
+class CustomEmojiCategory < ApplicationRecord
+  has_many :emojis, class_name: 'CustomEmoji', foreign_key: 'category_id', inverse_of: :category
+end
diff --git a/app/serializers/activitypub/update_poll_serializer.rb b/app/serializers/activitypub/update_poll_serializer.rb
index b894f309f..1d47b9764 100644
--- a/app/serializers/activitypub/update_poll_serializer.rb
+++ b/app/serializers/activitypub/update_poll_serializer.rb
@@ -14,7 +14,7 @@ class ActivityPub::UpdatePollSerializer < ActivityPub::Serializer
   end
 
   def actor
-    ActivityPub::TagManager.instance.uri_for(object)
+    ActivityPub::TagManager.instance.uri_for(object.account)
   end
 
   def to
diff --git a/app/serializers/rest/custom_emoji_serializer.rb b/app/serializers/rest/custom_emoji_serializer.rb
index 65686a866..aff58e4d9 100644
--- a/app/serializers/rest/custom_emoji_serializer.rb
+++ b/app/serializers/rest/custom_emoji_serializer.rb
@@ -5,6 +5,8 @@ class REST::CustomEmojiSerializer < ActiveModel::Serializer
 
   attributes :shortcode, :url, :static_url, :visible_in_picker
 
+  attribute :category, if: :category_loaded?
+
   def url
     full_asset_url(object.image.url)
   end
@@ -12,4 +14,12 @@ class REST::CustomEmojiSerializer < ActiveModel::Serializer
   def static_url
     full_asset_url(object.image.url(:static))
   end
+
+  def category
+    object.category.name
+  end
+
+  def category_loaded?
+    object.association(:category).loaded? && object.category.present?
+  end
 end
diff --git a/db/migrate/20190627222225_create_custom_emoji_categories.rb b/db/migrate/20190627222225_create_custom_emoji_categories.rb
new file mode 100644
index 000000000..4713793e6
--- /dev/null
+++ b/db/migrate/20190627222225_create_custom_emoji_categories.rb
@@ -0,0 +1,9 @@
+class CreateCustomEmojiCategories < ActiveRecord::Migration[5.2]
+  def change
+    create_table :custom_emoji_categories do |t|
+      t.string :name, index: { unique: true }
+
+      t.timestamps
+    end
+  end
+end
diff --git a/db/migrate/20190627222826_add_category_id_to_custom_emojis.rb b/db/migrate/20190627222826_add_category_id_to_custom_emojis.rb
new file mode 100644
index 000000000..873b4d05f
--- /dev/null
+++ b/db/migrate/20190627222826_add_category_id_to_custom_emojis.rb
@@ -0,0 +1,5 @@
+class AddCategoryIdToCustomEmojis < ActiveRecord::Migration[5.2]
+  def change
+    add_column :custom_emojis, :category_id, :bigint
+  end
+end
diff --git a/db/schema.rb b/db/schema.rb
index aa02be55f..2e7af9b78 100644
--- a/db/schema.rb
+++ b/db/schema.rb
@@ -10,7 +10,7 @@
 #
 # It's strongly recommended that you check this file into your version control system.
 
-ActiveRecord::Schema.define(version: 2019_05_29_143559) do
+ActiveRecord::Schema.define(version: 2019_06_27_222826) do
 
   # These are extensions that must be enabled in order to support this database
   enable_extension "plpgsql"
@@ -218,6 +218,13 @@ ActiveRecord::Schema.define(version: 2019_05_29_143559) do
     t.index ["uri"], name: "index_conversations_on_uri", unique: true
   end
 
+  create_table "custom_emoji_categories", force: :cascade do |t|
+    t.string "name"
+    t.datetime "created_at", null: false
+    t.datetime "updated_at", null: false
+    t.index ["name"], name: "index_custom_emoji_categories_on_name", unique: true
+  end
+
   create_table "custom_emojis", force: :cascade do |t|
     t.string "shortcode", default: "", null: false
     t.string "domain"
@@ -231,6 +238,7 @@ ActiveRecord::Schema.define(version: 2019_05_29_143559) do
     t.string "uri"
     t.string "image_remote_url"
     t.boolean "visible_in_picker", default: true, null: false
+    t.bigint "category_id"
     t.index ["shortcode", "domain"], name: "index_custom_emojis_on_shortcode_and_domain", unique: true
   end
 
diff --git a/lib/mastodon/emoji_cli.rb b/lib/mastodon/emoji_cli.rb
index 97a822e45..beac1b1fd 100644
--- a/lib/mastodon/emoji_cli.rb
+++ b/lib/mastodon/emoji_cli.rb
@@ -15,6 +15,7 @@ module Mastodon
     option :suffix
     option :overwrite, type: :boolean
     option :unlisted, type: :boolean
+    option :category
     desc 'import PATH', 'Import emoji from a TAR GZIP archive at PATH'
     long_desc <<-LONG_DESC
       Imports custom emoji from a TAR GZIP archive specified by PATH.
@@ -22,6 +23,9 @@ module Mastodon
       Existing emoji will be skipped unless the --overwrite option
       is provided, in which case they will be overwritten.
 
+      You can specifiy a --category under which the emojis will be
+      grouped together.
+
       With the --prefix option, a prefix can be added to all
       generated shortcodes. Likewise, the --suffix option controls
       the suffix of all shortcodes.
@@ -33,6 +37,7 @@ module Mastodon
       imported = 0
       skipped  = 0
       failed   = 0
+      category = options[:category] ? CustomEmojiCategory.find_or_create_by(name: options[:category]) : nil
 
       Gem::Package::TarReader.new(Zlib::GzipReader.open(path)) do |tar|
         tar.each do |entry|
@@ -50,6 +55,7 @@ module Mastodon
           custom_emoji.image = StringIO.new(entry.read)
           custom_emoji.image_file_name = File.basename(entry.full_name)
           custom_emoji.visible_in_picker = !options[:unlisted]
+          custom_emoji.category = category
 
           if custom_emoji.save
             imported += 1
diff --git a/spec/fabricators/custom_emoji_category_fabricator.rb b/spec/fabricators/custom_emoji_category_fabricator.rb
new file mode 100644
index 000000000..f593b95ed
--- /dev/null
+++ b/spec/fabricators/custom_emoji_category_fabricator.rb
@@ -0,0 +1,3 @@
+Fabricator(:custom_emoji_category) do
+  name "MyString"
+end
diff --git a/spec/models/custom_emoji_category_spec.rb b/spec/models/custom_emoji_category_spec.rb
new file mode 100644
index 000000000..160033f4d
--- /dev/null
+++ b/spec/models/custom_emoji_category_spec.rb
@@ -0,0 +1,5 @@
+require 'rails_helper'
+
+RSpec.describe CustomEmojiCategory, type: :model do
+  pending "add some examples to (or delete) #{__FILE__}"
+end
diff --git a/spec/serializers/activitypub/update_poll_spec.rb b/spec/serializers/activitypub/update_poll_spec.rb
new file mode 100644
index 000000000..f9e035eab
--- /dev/null
+++ b/spec/serializers/activitypub/update_poll_spec.rb
@@ -0,0 +1,27 @@
+# frozen_string_literal: true
+
+require 'rails_helper'
+
+describe ActivityPub::UpdatePollSerializer do
+  let(:account) { Fabricate(:account) }
+  let(:poll)    { Fabricate(:poll, account: account) }
+  let!(:status) { Fabricate(:status, account: account, poll: poll) }
+
+  before(:each) do
+    @serialization = ActiveModelSerializers::SerializableResource.new(status, serializer: ActivityPub::UpdatePollSerializer, adapter: ActivityPub::Adapter)
+  end
+
+  subject { JSON.parse(@serialization.to_json) }
+
+  it 'has a Update type' do
+    expect(subject['type']).to eql('Update')
+  end
+
+  it 'has an object with Question type' do
+    expect(subject['object']['type']).to eql('Question')
+  end
+
+  it 'has the correct actor URI set' do
+    expect(subject['actor']).to eql(ActivityPub::TagManager.instance.uri_for(account))
+  end
+end
diff --git a/spec/workers/activitypub/distribute_poll_update_worker_spec.rb b/spec/workers/activitypub/distribute_poll_update_worker_spec.rb
new file mode 100644
index 000000000..7eb6119fd
--- /dev/null
+++ b/spec/workers/activitypub/distribute_poll_update_worker_spec.rb
@@ -0,0 +1,22 @@
+require 'rails_helper'
+
+describe ActivityPub::DistributePollUpdateWorker do
+  subject { described_class.new }
+
+  let(:account)  { Fabricate(:account) }
+  let(:follower) { Fabricate(:account, protocol: :activitypub, inbox_url: 'http://example.com') }
+  let(:poll)     { Fabricate(:poll, account: account) }
+  let!(:status)  { Fabricate(:status, account: account, poll: poll) }
+
+  describe '#perform' do
+    before do
+      allow(ActivityPub::DeliveryWorker).to receive(:push_bulk)
+      follower.follow!(account)
+    end
+
+    it 'delivers to followers' do
+      subject.perform(status.id)
+      expect(ActivityPub::DeliveryWorker).to have_received(:push_bulk).with(['http://example.com'])
+    end
+  end
+end
diff --git a/yarn.lock b/yarn.lock
index 776473f77..a7d98fe3a 100644
--- a/yarn.lock
+++ b/yarn.lock
@@ -3388,8 +3388,8 @@ elliptic@^6.0.0:
     minimalistic-crypto-utils "^1.0.0"
 
 emoji-mart@Gargron/emoji-mart#build:
-  version "2.6.2"
-  resolved "https://codeload.github.com/Gargron/emoji-mart/tar.gz/ff00dc470b5b2d9f145a6d6e977a54de5df2b4c9"
+  version "2.6.3"
+  resolved "https://codeload.github.com/Gargron/emoji-mart/tar.gz/934f314fd8322276765066e8a2a6be5bac61b1cf"
 
 emoji-regex@^7.0.1, emoji-regex@^7.0.2:
   version "7.0.3"