about summary refs log tree commit diff
path: root/app
diff options
context:
space:
mode:
authorReverite <github@reverite.sh>2019-05-11 18:06:12 -0700
committerReverite <github@reverite.sh>2019-05-11 18:06:12 -0700
commitcdb159f27e2aeb2cf3bb184ac20b7056b013f714 (patch)
tree01a185cccbaedb031eaf592706349907dc6decd4 /app
parentb403052b6759c8a87fc8e20304c0c31c05412313 (diff)
parentf6838a28f64a2e1a955d7062d8b4270312df802f (diff)
Merge branch 'glitch' into production
Diffstat (limited to 'app')
-rw-r--r--app/controllers/api/v1/statuses_controller.rb2
-rw-r--r--app/controllers/statuses_controller.rb6
-rw-r--r--app/controllers/stream_entries_controller.rb6
-rw-r--r--app/javascript/flavours/glitch/actions/statuses.js7
-rw-r--r--app/javascript/flavours/glitch/containers/status_container.js16
-rw-r--r--app/javascript/flavours/glitch/features/compose/containers/sensitive_button_container.js17
-rw-r--r--app/javascript/flavours/glitch/features/ui/components/boost_modal.js4
-rw-r--r--app/javascript/flavours/glitch/reducers/compose.js2
-rw-r--r--app/javascript/flavours/glitch/styles/components/composer.scss32
-rw-r--r--app/javascript/flavours/glitch/styles/contrast/diff.scss8
-rw-r--r--app/javascript/flavours/glitch/styles/polls.scss9
-rw-r--r--app/javascript/mastodon/actions/compose.js3
-rw-r--r--app/javascript/mastodon/actions/statuses.js7
-rw-r--r--app/javascript/mastodon/components/autosuggest_input.js229
-rw-r--r--app/javascript/mastodon/components/autosuggest_textarea.js12
-rw-r--r--app/javascript/mastodon/containers/status_container.js16
-rw-r--r--app/javascript/mastodon/features/account_gallery/components/media_item.js14
-rw-r--r--app/javascript/mastodon/features/compose/components/compose_form.js28
-rw-r--r--app/javascript/mastodon/features/compose/components/poll_form.js34
-rw-r--r--app/javascript/mastodon/features/compose/containers/compose_form_container.js4
-rw-r--r--app/javascript/mastodon/features/compose/containers/poll_form_container.js19
-rw-r--r--app/javascript/mastodon/features/compose/containers/sensitive_button_container.js17
-rw-r--r--app/javascript/mastodon/features/ui/components/boost_modal.js4
-rw-r--r--app/javascript/mastodon/locales/ca.json9
-rw-r--r--app/javascript/mastodon/locales/co.json3
-rw-r--r--app/javascript/mastodon/locales/defaultMessages.json25
-rw-r--r--app/javascript/mastodon/locales/en.json3
-rw-r--r--app/javascript/mastodon/locales/fr.json3
-rw-r--r--app/javascript/mastodon/locales/ja.json3
-rw-r--r--app/javascript/mastodon/locales/nl.json1
-rw-r--r--app/javascript/mastodon/reducers/compose.js16
-rw-r--r--app/javascript/styles/contrast/diff.scss8
-rw-r--r--app/javascript/styles/mastodon/components.scss40
-rw-r--r--app/javascript/styles/mastodon/polls.scss17
-rw-r--r--app/lib/activitypub/tag_manager.rb20
-rw-r--r--app/models/form/status_batch.rb1
-rw-r--r--app/models/tombstone.rb11
-rw-r--r--app/serializers/rest/status_serializer.rb9
-rw-r--r--app/views/admin/action_logs/_action_log.html.haml2
39 files changed, 569 insertions, 98 deletions
diff --git a/app/controllers/api/v1/statuses_controller.rb b/app/controllers/api/v1/statuses_controller.rb
index f9506971a..b0e134554 100644
--- a/app/controllers/api/v1/statuses_controller.rb
+++ b/app/controllers/api/v1/statuses_controller.rb
@@ -65,7 +65,7 @@ class Api::V1::StatusesController < Api::BaseController
 
     RemovalWorker.perform_async(@status.id)
 
-    render_empty
+    render json: @status, serializer: REST::StatusSerializer, source_requested: true
   end
 
   private
diff --git a/app/controllers/statuses_controller.rb b/app/controllers/statuses_controller.rb
index 53cf1c4ca..28eebda28 100644
--- a/app/controllers/statuses_controller.rb
+++ b/app/controllers/statuses_controller.rb
@@ -28,7 +28,11 @@ class StatusesController < ApplicationController
     respond_to do |format|
       format.html do
         use_pack 'public'
-        mark_cacheable! unless user_signed_in?
+
+        unless user_signed_in?
+          skip_session!
+          expires_in 10.seconds, public: true
+        end
 
         @body_classes = 'with-modals'
 
diff --git a/app/controllers/stream_entries_controller.rb b/app/controllers/stream_entries_controller.rb
index 8cb54a148..1e16c5157 100644
--- a/app/controllers/stream_entries_controller.rb
+++ b/app/controllers/stream_entries_controller.rb
@@ -16,6 +16,12 @@ class StreamEntriesController < ApplicationController
     respond_to do |format|
       format.html do
         use_pack 'public'
+
+        unless user_signed_in?
+          skip_session!
+          expires_in 5.minutes, public: true
+        end
+
         redirect_to short_account_status_url(params[:account_username], @stream_entry.activity) if @type == 'status'
       end
 
diff --git a/app/javascript/flavours/glitch/actions/statuses.js b/app/javascript/flavours/glitch/actions/statuses.js
index 4eabc4be0..550fe510f 100644
--- a/app/javascript/flavours/glitch/actions/statuses.js
+++ b/app/javascript/flavours/glitch/actions/statuses.js
@@ -71,10 +71,11 @@ export function fetchStatusFail(id, error, skipLoading) {
   };
 };
 
-export function redraft(status) {
+export function redraft(status, raw_text) {
   return {
     type: REDRAFT,
     status,
+    raw_text,
   };
 };
 
@@ -88,12 +89,12 @@ export function deleteStatus(id, router, withRedraft = false) {
 
     dispatch(deleteStatusRequest(id));
 
-    api(getState).delete(`/api/v1/statuses/${id}`).then(() => {
+    api(getState).delete(`/api/v1/statuses/${id}`).then(response => {
       dispatch(deleteStatusSuccess(id));
       dispatch(deleteFromTimelines(id));
 
       if (withRedraft) {
-        dispatch(redraft(status));
+        dispatch(redraft(status, response.data.text));
 
         if (!getState().getIn(['compose', 'mounted'])) {
           router.push('/statuses/new');
diff --git a/app/javascript/flavours/glitch/containers/status_container.js b/app/javascript/flavours/glitch/containers/status_container.js
index 60636feb4..98dc5bb87 100644
--- a/app/javascript/flavours/glitch/containers/status_container.js
+++ b/app/javascript/flavours/glitch/containers/status_container.js
@@ -88,18 +88,18 @@ const mapDispatchToProps = (dispatch, { intl }) => ({
   },
 
   onModalReblog (status) {
-    dispatch(reblog(status));
+    if (status.get('reblogged')) {
+      dispatch(unreblog(status));
+    } else {
+      dispatch(reblog(status));
+    }
   },
 
   onReblog (status, e) {
-    if (status.get('reblogged')) {
-      dispatch(unreblog(status));
+    if (e.shiftKey || !boostModal) {
+      this.onModalReblog(status);
     } else {
-      if (e.shiftKey || !boostModal) {
-        this.onModalReblog(status);
-      } else {
-        dispatch(openModal('BOOST', { status, onReblog: this.onModalReblog }));
-      }
+      dispatch(openModal('BOOST', { status, onReblog: this.onModalReblog }));
     }
   },
 
diff --git a/app/javascript/flavours/glitch/features/compose/containers/sensitive_button_container.js b/app/javascript/flavours/glitch/features/compose/containers/sensitive_button_container.js
index 8f163979f..fa1ae8821 100644
--- a/app/javascript/flavours/glitch/features/compose/containers/sensitive_button_container.js
+++ b/app/javascript/flavours/glitch/features/compose/containers/sensitive_button_container.js
@@ -4,7 +4,6 @@ import PropTypes from 'prop-types';
 import classNames from 'classnames';
 import { changeComposeSensitivity } from 'flavours/glitch/actions/compose';
 import { injectIntl, defineMessages, FormattedMessage } from 'react-intl';
-import Icon from 'flavours/glitch/components/icon';
 
 const messages = defineMessages({
   marked: { id: 'compose_form.sensitive.marked', defaultMessage: 'Media is marked as sensitive' },
@@ -42,9 +41,19 @@ class SensitiveButton extends React.PureComponent {
 
     return (
       <div className='compose-form__sensitive-button'>
-        <button className={classNames('icon-button', { active })} onClick={onClick} disabled={disabled} title={intl.formatMessage(active ? messages.marked : messages.unmarked)}>
-          <Icon icon='eye-slash' /> <FormattedMessage id='compose_form.sensitive.hide' defaultMessage='Mark media as sensitive' />
-        </button>
+        <label className={classNames('icon-button', { active })} title={intl.formatMessage(active ? messages.marked : messages.unmarked)}>
+          <input
+            name='mark-sensitive'
+            type='checkbox'
+            checked={active}
+            onChange={onClick}
+            disabled={disabled}
+          />
+
+          <span className={classNames('checkbox', { active })} />
+
+          <FormattedMessage id='compose_form.sensitive.hide' defaultMessage='Mark media as sensitive' />
+        </label>
       </div>
     );
   }
diff --git a/app/javascript/flavours/glitch/features/ui/components/boost_modal.js b/app/javascript/flavours/glitch/features/ui/components/boost_modal.js
index 0a914dce2..ce7ec2479 100644
--- a/app/javascript/flavours/glitch/features/ui/components/boost_modal.js
+++ b/app/javascript/flavours/glitch/features/ui/components/boost_modal.js
@@ -10,6 +10,7 @@ import DisplayName from 'flavours/glitch/components/display_name';
 import ImmutablePureComponent from 'react-immutable-pure-component';
 
 const messages = defineMessages({
+  cancel_reblog: { id: 'status.cancel_reblog_private', defaultMessage: 'Unboost' },
   reblog: { id: 'status.reblog', defaultMessage: 'Boost' },
 });
 
@@ -52,6 +53,7 @@ export default class BoostModal extends ImmutablePureComponent {
 
   render () {
     const { status, intl } = this.props;
+    const buttonText = status.get('reblogged') ? messages.cancel_reblog : messages.reblog;
 
     return (
       <div className='modal-root__modal boost-modal'>
@@ -77,7 +79,7 @@ export default class BoostModal extends ImmutablePureComponent {
 
         <div className='boost-modal__action-bar'>
           <div><FormattedMessage id='boost_modal.combo' defaultMessage='You can press {combo} to skip this next time' values={{ combo: <span>Shift + <i className='fa fa-retweet' /></span> }} /></div>
-          <Button text={intl.formatMessage(messages.reblog)} onClick={this.handleReblog} ref={this.setRef} />
+          <Button text={intl.formatMessage(buttonText)} onClick={this.handleReblog} ref={this.setRef} />
         </div>
       </div>
     );
diff --git a/app/javascript/flavours/glitch/reducers/compose.js b/app/javascript/flavours/glitch/reducers/compose.js
index 009e1fee7..bc1785a48 100644
--- a/app/javascript/flavours/glitch/reducers/compose.js
+++ b/app/javascript/flavours/glitch/reducers/compose.js
@@ -426,7 +426,7 @@ export default function compose(state = initialState, action) {
     return state.mergeIn(['doodle'], action.options);
   case REDRAFT:
     return state.withMutations(map => {
-      map.set('text', unescapeHTML(expandMentions(action.status)));
+      map.set('text', action.raw_text || unescapeHTML(expandMentions(action.status)));
       map.set('in_reply_to', action.status.get('in_reply_to_id'));
       map.set('privacy', action.status.get('visibility'));
       map.set('media_attachments', action.status.get('media_attachments'));
diff --git a/app/javascript/flavours/glitch/styles/components/composer.scss b/app/javascript/flavours/glitch/styles/components/composer.scss
index 86041da20..bb333d35f 100644
--- a/app/javascript/flavours/glitch/styles/components/composer.scss
+++ b/app/javascript/flavours/glitch/styles/components/composer.scss
@@ -61,9 +61,35 @@
   padding: 10px;
   padding-top: 0;
 
-  .icon-button {
-    font-size: 14px;
-    font-weight: 500;
+  font-size: 14px;
+  font-weight: 500;
+
+  &.active {
+    color: $highlight-text-color;
+  }
+
+  input[type=checkbox] {
+    display: none;
+  }
+
+  .checkbox {
+    display: inline-block;
+    position: relative;
+    border: 1px solid $ui-primary-color;
+    box-sizing: border-box;
+    width: 18px;
+    height: 18px;
+    flex: 0 0 auto;
+    margin-left: 5px;
+    margin-right: 10px;
+    top: -1px;
+    border-radius: 4px;
+    vertical-align: middle;
+
+    &.active {
+      border-color: $highlight-text-color;
+      background: $highlight-text-color;
+    }
   }
 }
 
diff --git a/app/javascript/flavours/glitch/styles/contrast/diff.scss b/app/javascript/flavours/glitch/styles/contrast/diff.scss
index 8429103b8..f78e60597 100644
--- a/app/javascript/flavours/glitch/styles/contrast/diff.scss
+++ b/app/javascript/flavours/glitch/styles/contrast/diff.scss
@@ -67,3 +67,11 @@
     text-decoration: none;
   }
 }
+
+.nothing-here {
+  color: $darker-text-color;
+}
+
+.public-layout .public-account-header__tabs__tabs .counter.active::after {
+  border-bottom: 4px solid $ui-highlight-color;
+}
diff --git a/app/javascript/flavours/glitch/styles/polls.scss b/app/javascript/flavours/glitch/styles/polls.scss
index 315fd5782..50bb45e7c 100644
--- a/app/javascript/flavours/glitch/styles/polls.scss
+++ b/app/javascript/flavours/glitch/styles/polls.scss
@@ -118,11 +118,14 @@
     text-decoration: underline;
     font-size: inherit;
 
-    &:hover,
-    &:focus,
-    &:active {
+    &:hover {
       text-decoration: none;
     }
+
+    &:active,
+    &:focus {
+      background-color: rgba($dark-text-color, .1);
+    }
   }
 
   .button {
diff --git a/app/javascript/mastodon/actions/compose.js b/app/javascript/mastodon/actions/compose.js
index 0ee663766..94062f2be 100644
--- a/app/javascript/mastodon/actions/compose.js
+++ b/app/javascript/mastodon/actions/compose.js
@@ -383,7 +383,7 @@ export function readyComposeSuggestionsAccounts(token, accounts) {
   };
 };
 
-export function selectComposeSuggestion(position, token, suggestion) {
+export function selectComposeSuggestion(position, token, suggestion, path) {
   return (dispatch, getState) => {
     let completion, startPosition;
 
@@ -405,6 +405,7 @@ export function selectComposeSuggestion(position, token, suggestion) {
       position: startPosition,
       token,
       completion,
+      path,
     });
   };
 };
diff --git a/app/javascript/mastodon/actions/statuses.js b/app/javascript/mastodon/actions/statuses.js
index 1794538e2..3916b9ac1 100644
--- a/app/javascript/mastodon/actions/statuses.js
+++ b/app/javascript/mastodon/actions/statuses.js
@@ -131,10 +131,11 @@ export function fetchStatusFail(id, error, skipLoading) {
   };
 };
 
-export function redraft(status) {
+export function redraft(status, raw_text) {
   return {
     type: REDRAFT,
     status,
+    raw_text,
   };
 };
 
@@ -148,13 +149,13 @@ export function deleteStatus(id, router, withRedraft = false) {
 
     dispatch(deleteStatusRequest(id));
 
-    api(getState).delete(`/api/v1/statuses/${id}`).then(() => {
+    api(getState).delete(`/api/v1/statuses/${id}`).then(response => {
       evictStatus(id);
       dispatch(deleteStatusSuccess(id));
       dispatch(deleteFromTimelines(id));
 
       if (withRedraft) {
-        dispatch(redraft(status));
+        dispatch(redraft(status, response.data.text));
 
         if (!getState().getIn(['compose', 'mounted'])) {
           router.push('/statuses/new');
diff --git a/app/javascript/mastodon/components/autosuggest_input.js b/app/javascript/mastodon/components/autosuggest_input.js
new file mode 100644
index 000000000..bb8ab60db
--- /dev/null
+++ b/app/javascript/mastodon/components/autosuggest_input.js
@@ -0,0 +1,229 @@
+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 classNames from 'classnames';
+import { List as ImmutableList } from 'immutable';
+
+const textAtCursorMatchesToken = (str, caretPosition, searchTokens) => {
+  let word;
+
+  let left  = str.slice(0, caretPosition).search(/\S+$/);
+  let right = str.slice(caretPosition).search(/\s/);
+
+  if (right < 0) {
+    word = str.slice(left);
+  } else {
+    word = str.slice(left, right + caretPosition);
+  }
+
+  if (!word || word.trim().length < 3 || searchTokens.indexOf(word[0]) === -1) {
+    return [null, null];
+  }
+
+  word = word.trim().toLowerCase();
+
+  if (word.length > 0) {
+    return [left + 1, word];
+  } else {
+    return [null, null];
+  }
+};
+
+export default class AutosuggestInput extends ImmutablePureComponent {
+
+  static propTypes = {
+    value: PropTypes.string,
+    suggestions: ImmutablePropTypes.list,
+    disabled: PropTypes.bool,
+    placeholder: PropTypes.string,
+    onSuggestionSelected: PropTypes.func.isRequired,
+    onSuggestionsClearRequested: PropTypes.func.isRequired,
+    onSuggestionsFetchRequested: PropTypes.func.isRequired,
+    onChange: PropTypes.func.isRequired,
+    onKeyUp: PropTypes.func,
+    onKeyDown: PropTypes.func,
+    autoFocus: PropTypes.bool,
+    className: PropTypes.string,
+    id: PropTypes.string,
+    searchTokens: PropTypes.list,
+    maxLength: PropTypes.number,
+  };
+
+  static defaultProps = {
+    autoFocus: true,
+    searchTokens: ImmutableList(['@', ':', '#']),
+  };
+
+  state = {
+    suggestionsHidden: true,
+    focused: false,
+    selectedSuggestion: 0,
+    lastToken: null,
+    tokenStart: 0,
+  };
+
+  onChange = (e) => {
+    const [ tokenStart, token ] = textAtCursorMatchesToken(e.target.value, e.target.selectionStart, this.props.searchTokens);
+
+    if (token !== null && this.state.lastToken !== token) {
+      this.setState({ lastToken: token, selectedSuggestion: 0, tokenStart });
+      this.props.onSuggestionsFetchRequested(token);
+    } else if (token === null) {
+      this.setState({ lastToken: null });
+      this.props.onSuggestionsClearRequested();
+    }
+
+    this.props.onChange(e);
+  }
+
+  onKeyDown = (e) => {
+    const { suggestions, disabled } = this.props;
+    const { selectedSuggestion, suggestionsHidden } = this.state;
+
+    if (disabled) {
+      e.preventDefault();
+      return;
+    }
+
+    if (e.which === 229 || e.isComposing) {
+      // Ignore key events during text composition
+      // e.key may be a name of the physical key even in this case (e.x. Safari / Chrome on Mac)
+      return;
+    }
+
+    switch(e.key) {
+    case 'Escape':
+      if (suggestions.size === 0 || suggestionsHidden) {
+        document.querySelector('.ui').parentElement.focus();
+      } else {
+        e.preventDefault();
+        this.setState({ suggestionsHidden: true });
+      }
+
+      break;
+    case 'ArrowDown':
+      if (suggestions.size > 0 && !suggestionsHidden) {
+        e.preventDefault();
+        this.setState({ selectedSuggestion: Math.min(selectedSuggestion + 1, suggestions.size - 1) });
+      }
+
+      break;
+    case 'ArrowUp':
+      if (suggestions.size > 0 && !suggestionsHidden) {
+        e.preventDefault();
+        this.setState({ selectedSuggestion: Math.max(selectedSuggestion - 1, 0) });
+      }
+
+      break;
+    case 'Enter':
+    case 'Tab':
+      // Select suggestion
+      if (this.state.lastToken !== null && suggestions.size > 0 && !suggestionsHidden) {
+        e.preventDefault();
+        e.stopPropagation();
+        this.props.onSuggestionSelected(this.state.tokenStart, this.state.lastToken, suggestions.get(selectedSuggestion));
+      }
+
+      break;
+    }
+
+    if (e.defaultPrevented || !this.props.onKeyDown) {
+      return;
+    }
+
+    this.props.onKeyDown(e);
+  }
+
+  onBlur = () => {
+    this.setState({ suggestionsHidden: true, focused: false });
+  }
+
+  onFocus = () => {
+    this.setState({ focused: true });
+  }
+
+  onSuggestionClick = (e) => {
+    const suggestion = this.props.suggestions.get(e.currentTarget.getAttribute('data-index'));
+    e.preventDefault();
+    this.props.onSuggestionSelected(this.state.tokenStart, this.state.lastToken, suggestion);
+    this.input.focus();
+  }
+
+  componentWillReceiveProps (nextProps) {
+    if (nextProps.suggestions !== this.props.suggestions && nextProps.suggestions.size > 0 && this.state.suggestionsHidden && this.state.focused) {
+      this.setState({ suggestionsHidden: false });
+    }
+  }
+
+  setInput = (c) => {
+    this.input = c;
+  }
+
+  renderSuggestion = (suggestion, i) => {
+    const { selectedSuggestion } = this.state;
+    let inner, key;
+
+    if (typeof suggestion === 'object') {
+      inner = <AutosuggestEmoji emoji={suggestion} />;
+      key   = suggestion.id;
+    } else if (suggestion[0] === '#') {
+      inner = suggestion;
+      key   = suggestion;
+    } 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, className, id, maxLength } = this.props;
+    const { suggestionsHidden } = this.state;
+    const style = { direction: 'ltr' };
+
+    if (isRtl(value)) {
+      style.direction = 'rtl';
+    }
+
+    return (
+      <div className='autosuggest-input'>
+        <label>
+          <span style={{ display: 'none' }}>{placeholder}</span>
+
+          <input
+            type='text'
+            ref={this.setInput}
+            disabled={disabled}
+            placeholder={placeholder}
+            autoFocus={autoFocus}
+            value={value}
+            onChange={this.onChange}
+            onKeyDown={this.onKeyDown}
+            onKeyUp={onKeyUp}
+            onFocus={this.onFocus}
+            onBlur={this.onBlur}
+            style={style}
+            aria-autocomplete='list'
+            id={id}
+            className={className}
+            maxLength={maxLength}
+          />
+        </label>
+
+        <div className={`autosuggest-textarea__suggestions ${suggestionsHidden || suggestions.isEmpty() ? '' : 'autosuggest-textarea__suggestions--visible'}`}>
+          {suggestions.map(this.renderSuggestion)}
+        </div>
+      </div>
+    );
+  }
+
+}
diff --git a/app/javascript/mastodon/components/autosuggest_textarea.js b/app/javascript/mastodon/components/autosuggest_textarea.js
index a4f5cf50c..f3fb7fa8b 100644
--- a/app/javascript/mastodon/components/autosuggest_textarea.js
+++ b/app/javascript/mastodon/components/autosuggest_textarea.js
@@ -55,7 +55,8 @@ export default class AutosuggestTextarea extends ImmutablePureComponent {
   };
 
   state = {
-    suggestionsHidden: false,
+    suggestionsHidden: true,
+    focused: false,
     selectedSuggestion: 0,
     lastToken: null,
     tokenStart: 0,
@@ -134,7 +135,11 @@ export default class AutosuggestTextarea extends ImmutablePureComponent {
   }
 
   onBlur = () => {
-    this.setState({ suggestionsHidden: true });
+    this.setState({ suggestionsHidden: true, focused: false });
+  }
+
+  onFocus = () => {
+    this.setState({ focused: true });
   }
 
   onSuggestionClick = (e) => {
@@ -145,7 +150,7 @@ export default class AutosuggestTextarea extends ImmutablePureComponent {
   }
 
   componentWillReceiveProps (nextProps) {
-    if (nextProps.suggestions !== this.props.suggestions && nextProps.suggestions.size > 0 && this.state.suggestionsHidden) {
+    if (nextProps.suggestions !== this.props.suggestions && nextProps.suggestions.size > 0 && this.state.suggestionsHidden && this.state.focused) {
       this.setState({ suggestionsHidden: false });
     }
   }
@@ -207,6 +212,7 @@ export default class AutosuggestTextarea extends ImmutablePureComponent {
             onChange={this.onChange}
             onKeyDown={this.onKeyDown}
             onKeyUp={onKeyUp}
+            onFocus={this.onFocus}
             onBlur={this.onBlur}
             onPaste={this.onPaste}
             style={style}
diff --git a/app/javascript/mastodon/containers/status_container.js b/app/javascript/mastodon/containers/status_container.js
index 0fce674e2..86324b846 100644
--- a/app/javascript/mastodon/containers/status_container.js
+++ b/app/javascript/mastodon/containers/status_container.js
@@ -69,18 +69,18 @@ const mapDispatchToProps = (dispatch, { intl }) => ({
   },
 
   onModalReblog (status) {
-    dispatch(reblog(status));
+    if (status.get('reblogged')) {
+      dispatch(unreblog(status));
+    } else {
+      dispatch(reblog(status));
+    }
   },
 
   onReblog (status, e) {
-    if (status.get('reblogged')) {
-      dispatch(unreblog(status));
+    if (e.shiftKey || !boostModal) {
+      this.onModalReblog(status);
     } else {
-      if (e.shiftKey || !boostModal) {
-        this.onModalReblog(status);
-      } else {
-        dispatch(openModal('BOOST', { status, onReblog: this.onModalReblog }));
-      }
+      dispatch(openModal('BOOST', { status, onReblog: this.onModalReblog }));
     }
   },
 
diff --git a/app/javascript/mastodon/features/account_gallery/components/media_item.js b/app/javascript/mastodon/features/account_gallery/components/media_item.js
index 5643e6449..2609b96ff 100644
--- a/app/javascript/mastodon/features/account_gallery/components/media_item.js
+++ b/app/javascript/mastodon/features/account_gallery/components/media_item.js
@@ -2,6 +2,7 @@ import React from 'react';
 import PropTypes from 'prop-types';
 import ImmutablePropTypes from 'react-immutable-proptypes';
 import ImmutablePureComponent from 'react-immutable-pure-component';
+import Icon from 'mastodon/components/icon';
 import { autoPlayGif, displayMedia } from 'mastodon/initial_state';
 import classNames from 'classnames';
 import { decode } from 'blurhash';
@@ -88,8 +89,10 @@ export default class MediaItem extends ImmutablePureComponent {
     const width  = `${Math.floor((displayWidth - 4) / 3) - 4}px`;
     const height = width;
     const status = attachment.get('status');
+    const title = status.get('spoiler_text') || attachment.get('description');
 
     let thumbnail = '';
+    let icon;
 
     if (attachment.get('type') === 'unknown') {
       // Skip
@@ -131,11 +134,20 @@ export default class MediaItem extends ImmutablePureComponent {
       );
     }
 
+    if (!visible) {
+      icon = (
+        <span className='account-gallery__item__icons'>
+          <Icon id='eye-slash' />
+        </span>
+      );
+    }
+
     return (
       <div className='account-gallery__item' style={{ width, height }}>
-        <a className='media-gallery__item-thumbnail' href={status.get('url')} target='_blank' onClick={this.handleClick}>
+        <a className='media-gallery__item-thumbnail' href={status.get('url')} target='_blank' onClick={this.handleClick} title={title}>
           <canvas width={32} height={32} ref={this.setCanvasRef} className={classNames('media-gallery__preview', { 'media-gallery__preview--hidden': visible && loaded })} />
           {visible && thumbnail}
+          {!visible && icon}
         </a>
       </div>
     );
diff --git a/app/javascript/mastodon/features/compose/components/compose_form.js b/app/javascript/mastodon/features/compose/components/compose_form.js
index 03738f1de..7e8b38580 100644
--- a/app/javascript/mastodon/features/compose/components/compose_form.js
+++ b/app/javascript/mastodon/features/compose/components/compose_form.js
@@ -5,6 +5,7 @@ import ImmutablePropTypes from 'react-immutable-proptypes';
 import PropTypes from 'prop-types';
 import ReplyIndicatorContainer from '../containers/reply_indicator_container';
 import AutosuggestTextarea from '../../../components/autosuggest_textarea';
+import AutosuggestInput from '../../../components/autosuggest_input';
 import PollButtonContainer from '../containers/poll_button_container';
 import UploadButtonContainer from '../containers/upload_button_container';
 import { defineMessages, injectIntl } from 'react-intl';
@@ -103,7 +104,11 @@ class ComposeForm extends ImmutablePureComponent {
   }
 
   onSuggestionSelected = (tokenStart, token, value) => {
-    this.props.onSuggestionSelected(tokenStart, token, value);
+    this.props.onSuggestionSelected(tokenStart, token, value, ['text']);
+  }
+
+  onSpoilerSuggestionSelected = (tokenStart, token, value) => {
+    this.props.onSuggestionSelected(tokenStart, token, value, ['spoiler_text']);
   }
 
   handleChangeSpoilerText = (e) => {
@@ -136,7 +141,7 @@ class ComposeForm extends ImmutablePureComponent {
       this.autosuggestTextarea.textarea.focus();
     } else if (this.props.spoiler !== prevProps.spoiler) {
       if (this.props.spoiler) {
-        this.spoilerText.focus();
+        this.spoilerText.input.focus();
       } else {
         this.autosuggestTextarea.textarea.focus();
       }
@@ -179,10 +184,21 @@ class ComposeForm extends ImmutablePureComponent {
         <ReplyIndicatorContainer />
 
         <div className={`spoiler-input ${this.props.spoiler ? 'spoiler-input--visible' : ''}`}>
-          <label>
-            <span style={{ display: 'none' }}>{intl.formatMessage(messages.spoiler_placeholder)}</span>
-            <input placeholder={intl.formatMessage(messages.spoiler_placeholder)} value={this.props.spoilerText} onChange={this.handleChangeSpoilerText} onKeyDown={this.handleKeyDown} tabIndex={this.props.spoiler ? 0 : -1} type='text' className='spoiler-input__input'  id='cw-spoiler-input' ref={this.setSpoilerText} />
-          </label>
+          <AutosuggestInput
+            placeholder={intl.formatMessage(messages.spoiler_placeholder)}
+            value={this.props.spoilerText}
+            onChange={this.handleChangeSpoilerText}
+            onKeyDown={this.handleKeyDown}
+            disabled={!this.props.spoiler}
+            ref={this.setSpoilerText}
+            suggestions={this.props.suggestions}
+            onSuggestionsFetchRequested={this.onSuggestionsFetchRequested}
+            onSuggestionsClearRequested={this.onSuggestionsClearRequested}
+            onSuggestionSelected={this.onSpoilerSuggestionSelected}
+            searchTokens={[':']}
+            id='cw-spoiler-input'
+            className='spoiler-input__input'
+          />
         </div>
 
         <div className='compose-form__autosuggest-wrapper'>
diff --git a/app/javascript/mastodon/features/compose/components/poll_form.js b/app/javascript/mastodon/features/compose/components/poll_form.js
index 383e37eb6..211601d52 100644
--- a/app/javascript/mastodon/features/compose/components/poll_form.js
+++ b/app/javascript/mastodon/features/compose/components/poll_form.js
@@ -5,6 +5,7 @@ import ImmutablePureComponent from 'react-immutable-pure-component';
 import { defineMessages, injectIntl, FormattedMessage } from 'react-intl';
 import IconButton from 'mastodon/components/icon_button';
 import Icon from 'mastodon/components/icon';
+import AutosuggestInput from 'mastodon/components/autosuggest_input';
 import classNames from 'classnames';
 
 const messages = defineMessages({
@@ -27,6 +28,10 @@ class Option extends React.PureComponent {
     onChange: PropTypes.func.isRequired,
     onRemove: PropTypes.func.isRequired,
     onToggleMultiple: PropTypes.func.isRequired,
+    suggestions: ImmutablePropTypes.list,
+    onClearSuggestions: PropTypes.func.isRequired,
+    onFetchSuggestions: PropTypes.func.isRequired,
+    onSuggestionSelected: PropTypes.func.isRequired,
     intl: PropTypes.object.isRequired,
   };
 
@@ -38,12 +43,25 @@ class Option extends React.PureComponent {
     this.props.onRemove(this.props.index);
   };
 
+
   handleToggleMultiple = e => {
     this.props.onToggleMultiple();
     e.preventDefault();
     e.stopPropagation();
   };
 
+  onSuggestionsClearRequested = () => {
+    this.props.onClearSuggestions();
+  }
+
+  onSuggestionsFetchRequested = (token) => {
+    this.props.onFetchSuggestions(token);
+  }
+
+  onSuggestionSelected = (tokenStart, token, value) => {
+    this.props.onSuggestionSelected(tokenStart, token, value, ['poll', 'options', this.props.index]);
+  }
+
   render () {
     const { isPollMultiple, title, index, intl } = this.props;
 
@@ -57,12 +75,16 @@ class Option extends React.PureComponent {
             tabIndex='0'
           />
 
-          <input
-            type='text'
+          <AutosuggestInput
             placeholder={intl.formatMessage(messages.option_placeholder, { number: index + 1 })}
             maxLength={25}
             value={title}
             onChange={this.handleOptionTitleChange}
+            suggestions={this.props.suggestions}
+            onSuggestionsFetchRequested={this.onSuggestionsFetchRequested}
+            onSuggestionsClearRequested={this.onSuggestionsClearRequested}
+            onSuggestionSelected={this.onSuggestionSelected}
+            searchTokens={[':']}
           />
         </label>
 
@@ -87,6 +109,10 @@ class PollForm extends ImmutablePureComponent {
     onAddOption: PropTypes.func.isRequired,
     onRemoveOption: PropTypes.func.isRequired,
     onChangeSettings: PropTypes.func.isRequired,
+    suggestions: ImmutablePropTypes.list,
+    onClearSuggestions: PropTypes.func.isRequired,
+    onFetchSuggestions: PropTypes.func.isRequired,
+    onSuggestionSelected: PropTypes.func.isRequired,
     intl: PropTypes.object.isRequired,
   };
 
@@ -103,7 +129,7 @@ class PollForm extends ImmutablePureComponent {
   };
 
   render () {
-    const { options, expiresIn, isMultiple, onChangeOption, onRemoveOption, intl } = this.props;
+    const { options, expiresIn, isMultiple, onChangeOption, onRemoveOption, intl, ...other } = this.props;
 
     if (!options) {
       return null;
@@ -112,7 +138,7 @@ class PollForm extends ImmutablePureComponent {
     return (
       <div className='compose-form__poll-wrapper'>
         <ul>
-          {options.map((title, i) => <Option title={title} key={i} index={i} onChange={onChangeOption} onRemove={onRemoveOption} isPollMultiple={isMultiple} onToggleMultiple={this.handleToggleMultiple} />)}
+          {options.map((title, i) => <Option title={title} key={i} index={i} onChange={onChangeOption} onRemove={onRemoveOption} isPollMultiple={isMultiple} onToggleMultiple={this.handleToggleMultiple} {...other} />)}
         </ul>
 
         <div className='poll__footer'>
diff --git a/app/javascript/mastodon/features/compose/containers/compose_form_container.js b/app/javascript/mastodon/features/compose/containers/compose_form_container.js
index f9f1fba36..93a468388 100644
--- a/app/javascript/mastodon/features/compose/containers/compose_form_container.js
+++ b/app/javascript/mastodon/features/compose/containers/compose_form_container.js
@@ -45,8 +45,8 @@ const mapDispatchToProps = (dispatch) => ({
     dispatch(fetchComposeSuggestions(token));
   },
 
-  onSuggestionSelected (position, token, suggestion) {
-    dispatch(selectComposeSuggestion(position, token, suggestion));
+  onSuggestionSelected (position, token, suggestion, path) {
+    dispatch(selectComposeSuggestion(position, token, suggestion, path));
   },
 
   onChangeSpoilerText (checked) {
diff --git a/app/javascript/mastodon/features/compose/containers/poll_form_container.js b/app/javascript/mastodon/features/compose/containers/poll_form_container.js
index da795a291..1401371d0 100644
--- a/app/javascript/mastodon/features/compose/containers/poll_form_container.js
+++ b/app/javascript/mastodon/features/compose/containers/poll_form_container.js
@@ -1,8 +1,14 @@
 import { connect } from 'react-redux';
 import PollForm from '../components/poll_form';
 import { addPollOption, removePollOption, changePollOption, changePollSettings } from '../../../actions/compose';
+import {
+  clearComposeSuggestions,
+  fetchComposeSuggestions,
+  selectComposeSuggestion,
+} from '../../../actions/compose';
 
 const mapStateToProps = state => ({
+  suggestions: state.getIn(['compose', 'suggestions']),
   options: state.getIn(['compose', 'poll', 'options']),
   expiresIn: state.getIn(['compose', 'poll', 'expires_in']),
   isMultiple: state.getIn(['compose', 'poll', 'multiple']),
@@ -24,6 +30,19 @@ const mapDispatchToProps = dispatch => ({
   onChangeSettings(expiresIn, isMultiple) {
     dispatch(changePollSettings(expiresIn, isMultiple));
   },
+
+  onClearSuggestions () {
+    dispatch(clearComposeSuggestions());
+  },
+
+  onFetchSuggestions (token) {
+    dispatch(fetchComposeSuggestions(token));
+  },
+
+  onSuggestionSelected (position, token, accountId, path) {
+    dispatch(selectComposeSuggestion(position, token, accountId, path));
+  },
+
 });
 
 export default connect(mapStateToProps, mapDispatchToProps)(PollForm);
diff --git a/app/javascript/mastodon/features/compose/containers/sensitive_button_container.js b/app/javascript/mastodon/features/compose/containers/sensitive_button_container.js
index 50612b086..7073f76c2 100644
--- a/app/javascript/mastodon/features/compose/containers/sensitive_button_container.js
+++ b/app/javascript/mastodon/features/compose/containers/sensitive_button_container.js
@@ -4,7 +4,6 @@ import PropTypes from 'prop-types';
 import classNames from 'classnames';
 import { changeComposeSensitivity } from 'mastodon/actions/compose';
 import { injectIntl, defineMessages, FormattedMessage } from 'react-intl';
-import Icon from 'mastodon/components/icon';
 
 const messages = defineMessages({
   marked: { id: 'compose_form.sensitive.marked', defaultMessage: 'Media is marked as sensitive' },
@@ -38,9 +37,19 @@ class SensitiveButton extends React.PureComponent {
 
     return (
       <div className='compose-form__sensitive-button'>
-        <button className={classNames('icon-button', { active })} onClick={onClick} disabled={disabled} title={intl.formatMessage(active ? messages.marked : messages.unmarked)}>
-          <Icon id='eye-slash' /> <FormattedMessage id='compose_form.sensitive.hide' defaultMessage='Mark media as sensitive' />
-        </button>
+        <label className={classNames('icon-button', { active })} title={intl.formatMessage(active ? messages.marked : messages.unmarked)}>
+          <input
+            name='mark-sensitive'
+            type='checkbox'
+            checked={active}
+            onChange={onClick}
+            disabled={disabled}
+          />
+
+          <span className={classNames('checkbox', { active })} />
+
+          <FormattedMessage id='compose_form.sensitive.hide' defaultMessage='Mark media as sensitive' />
+        </label>
       </div>
     );
   }
diff --git a/app/javascript/mastodon/features/ui/components/boost_modal.js b/app/javascript/mastodon/features/ui/components/boost_modal.js
index 920e93d40..4c39a60e7 100644
--- a/app/javascript/mastodon/features/ui/components/boost_modal.js
+++ b/app/javascript/mastodon/features/ui/components/boost_modal.js
@@ -11,6 +11,7 @@ import ImmutablePureComponent from 'react-immutable-pure-component';
 import Icon from 'mastodon/components/icon';
 
 const messages = defineMessages({
+  cancel_reblog: { id: 'status.cancel_reblog_private', defaultMessage: 'Unboost' },
   reblog: { id: 'status.reblog', defaultMessage: 'Boost' },
 });
 
@@ -51,6 +52,7 @@ class BoostModal extends ImmutablePureComponent {
 
   render () {
     const { status, intl } = this.props;
+    const buttonText = status.get('reblogged') ? messages.cancel_reblog : messages.reblog;
 
     return (
       <div className='modal-root__modal boost-modal'>
@@ -76,7 +78,7 @@ class BoostModal extends ImmutablePureComponent {
 
         <div className='boost-modal__action-bar'>
           <div><FormattedMessage id='boost_modal.combo' defaultMessage='You can press {combo} to skip this next time' values={{ combo: <span>Shift + <Icon id='retweet' /></span> }} /></div>
-          <Button text={intl.formatMessage(messages.reblog)} onClick={this.handleReblog} ref={this.setRef} />
+          <Button text={intl.formatMessage(buttonText)} onClick={this.handleReblog} ref={this.setRef} />
         </div>
       </div>
     );
diff --git a/app/javascript/mastodon/locales/ca.json b/app/javascript/mastodon/locales/ca.json
index 18dd56d0d..0cafb1120 100644
--- a/app/javascript/mastodon/locales/ca.json
+++ b/app/javascript/mastodon/locales/ca.json
@@ -17,7 +17,7 @@
   "account.hide_reblogs": "Amaga els impulsos de @{name}",
   "account.link_verified_on": "La propietat d'aquest enllaç es va verificar el dia {date}",
   "account.locked_info": "Aquest estat de privadesa del compte està definit com a bloquejat. El propietari revisa manualment qui pot seguir-lo.",
-  "account.media": "Media",
+  "account.media": "Mèdia",
   "account.mention": "Esmentar @{name}",
   "account.moved_to": "{name} s'ha mogut a:",
   "account.mute": "Silencia @{name}",
@@ -77,6 +77,7 @@
   "compose_form.poll.remove_option": "Elimina aquesta opció",
   "compose_form.publish": "Toot",
   "compose_form.publish_loud": "{publish}!",
+  "compose_form.sensitive.hide": "Marcar mèdia com a sensible",
   "compose_form.sensitive.marked": "Mèdia marcat com a sensible",
   "compose_form.sensitive.unmarked": "Mèdia no està marcat com a sensible",
   "compose_form.spoiler.marked": "Text es ocult sota l'avís",
@@ -85,7 +86,7 @@
   "confirmation_modal.cancel": "Cancel·la",
   "confirmations.block.block_and_report": "Block & Report",
   "confirmations.block.confirm": "Bloca",
-  "confirmations.block.message": "Estàs segur que vols blocar {name}?",
+  "confirmations.block.message": "Estàs segur que vols bloquejar a {name}?",
   "confirmations.delete.confirm": "Suprimeix",
   "confirmations.delete.message": "Estàs segur que vols suprimir aquest estat?",
   "confirmations.delete_list.confirm": "Suprimeix",
@@ -125,7 +126,7 @@
   "empty_column.favourited_statuses": "Encara no tens cap toot favorit. Quan en tinguis, apareixerà aquí.",
   "empty_column.favourites": "Encara ningú ha marcat aquest toot com a favorit. Quan algú ho faci, apareixera aquí.",
   "empty_column.follow_requests": "Encara no teniu cap petició de seguiment. Quan rebeu una, apareixerà aquí.",
-  "empty_column.hashtag": "Encara no hi ha res amb aquesta etiqueta.",
+  "empty_column.hashtag": "Encara no hi ha res en aquesta etiqueta.",
   "empty_column.home": "Encara no segueixes ningú. Visita {public} o fes cerca per començar i conèixer altres usuaris.",
   "empty_column.home.public_timeline": "la línia de temps pública",
   "empty_column.list": "Encara no hi ha res en aquesta llista. Quan els membres d'aquesta llista publiquin nous estats, apareixeran aquí.",
@@ -209,6 +210,7 @@
   "lightbox.close": "Tancar",
   "lightbox.next": "Següent",
   "lightbox.previous": "Anterior",
+  "lightbox.view_context": "Veure el context",
   "lists.account.add": "Afegir a la llista",
   "lists.account.remove": "Treure de la llista",
   "lists.delete": "Delete list",
@@ -340,7 +342,6 @@
   "status.reply": "Respondre",
   "status.replyAll": "Respondre al tema",
   "status.report": "Informar sobre @{name}",
-  "status.sensitive_toggle": "Clic per veure",
   "status.sensitive_warning": "Contingut sensible",
   "status.share": "Compartir",
   "status.show_less": "Mostra menys",
diff --git a/app/javascript/mastodon/locales/co.json b/app/javascript/mastodon/locales/co.json
index 016be39b3..335706af7 100644
--- a/app/javascript/mastodon/locales/co.json
+++ b/app/javascript/mastodon/locales/co.json
@@ -77,6 +77,7 @@
   "compose_form.poll.remove_option": "Toglie sta scelta",
   "compose_form.publish": "Toot",
   "compose_form.publish_loud": "{publish}!",
+  "compose_form.sensitive.hide": "Indicà u media cum'è sensibile",
   "compose_form.sensitive.marked": "Media indicatu cum'è sensibile",
   "compose_form.sensitive.unmarked": "Media micca indicatu cum'è sensibile",
   "compose_form.spoiler.marked": "Testu piattatu daret'à un'avertimentu",
@@ -209,6 +210,7 @@
   "lightbox.close": "Chjudà",
   "lightbox.next": "Siguente",
   "lightbox.previous": "Pricidente",
+  "lightbox.view_context": "Vede u cuntestu",
   "lists.account.add": "Aghjunghje à a lista",
   "lists.account.remove": "Toglie di a lista",
   "lists.delete": "Supprime a lista",
@@ -340,7 +342,6 @@
   "status.reply": "Risponde",
   "status.replyAll": "Risponde à tutti",
   "status.report": "Palisà @{name}",
-  "status.sensitive_toggle": "Cliccate per vede",
   "status.sensitive_warning": "Cuntinutu sensibile",
   "status.share": "Sparte",
   "status.show_less": "Ripiegà",
diff --git a/app/javascript/mastodon/locales/defaultMessages.json b/app/javascript/mastodon/locales/defaultMessages.json
index 76d4351d0..8e7ed4210 100644
--- a/app/javascript/mastodon/locales/defaultMessages.json
+++ b/app/javascript/mastodon/locales/defaultMessages.json
@@ -180,10 +180,6 @@
       {
         "defaultMessage": "Media hidden",
         "id": "status.media_hidden"
-      },
-      {
-        "defaultMessage": "Click to view",
-        "id": "status.sensitive_toggle"
       }
     ],
     "path": "app/javascript/mastodon/components/media_gallery.json"
@@ -1096,6 +1092,10 @@
       {
         "defaultMessage": "Media is not marked as sensitive",
         "id": "compose_form.sensitive.unmarked"
+      },
+      {
+        "defaultMessage": "Mark media as sensitive",
+        "id": "compose_form.sensitive.hide"
       }
     ],
     "path": "app/javascript/mastodon/features/compose/containers/sensitive_button_container.json"
@@ -2262,6 +2262,10 @@
       {
         "defaultMessage": "Next",
         "id": "lightbox.next"
+      },
+      {
+        "defaultMessage": "View context",
+        "id": "lightbox.view_context"
       }
     ],
     "path": "app/javascript/mastodon/features/ui/components/media_modal.json"
@@ -2357,6 +2361,15 @@
   {
     "descriptors": [
       {
+        "defaultMessage": "View context",
+        "id": "lightbox.view_context"
+      }
+    ],
+    "path": "app/javascript/mastodon/features/ui/components/video_modal.json"
+  },
+  {
+    "descriptors": [
+      {
         "defaultMessage": "Your draft will be lost if you leave Mastodon.",
         "id": "ui.beforeunload"
       }
@@ -2408,10 +2421,6 @@
       {
         "defaultMessage": "Media hidden",
         "id": "status.media_hidden"
-      },
-      {
-        "defaultMessage": "Click to view",
-        "id": "status.sensitive_toggle"
       }
     ],
     "path": "app/javascript/mastodon/features/video/index.json"
diff --git a/app/javascript/mastodon/locales/en.json b/app/javascript/mastodon/locales/en.json
index 79e0c504b..b1eb814fc 100644
--- a/app/javascript/mastodon/locales/en.json
+++ b/app/javascript/mastodon/locales/en.json
@@ -81,6 +81,7 @@
   "compose_form.poll.remove_option": "Remove this choice",
   "compose_form.publish": "Toot",
   "compose_form.publish_loud": "{publish}!",
+  "compose_form.sensitive.hide": "Mark media as sensitive",
   "compose_form.sensitive.marked": "Media is marked as sensitive",
   "compose_form.sensitive.unmarked": "Media is not marked as sensitive",
   "compose_form.spoiler.marked": "Text is hidden behind warning",
@@ -213,6 +214,7 @@
   "lightbox.close": "Close",
   "lightbox.next": "Next",
   "lightbox.previous": "Previous",
+  "lightbox.view_context": "View context",
   "lists.account.add": "Add to list",
   "lists.account.remove": "Remove from list",
   "lists.delete": "Delete list",
@@ -345,7 +347,6 @@
   "status.reply": "Reply",
   "status.replyAll": "Reply to thread",
   "status.report": "Report @{name}",
-  "status.sensitive_toggle": "Click to view",
   "status.sensitive_warning": "Sensitive content",
   "status.share": "Share",
   "status.show_less": "Show less",
diff --git a/app/javascript/mastodon/locales/fr.json b/app/javascript/mastodon/locales/fr.json
index 58f3ce147..090f15bea 100644
--- a/app/javascript/mastodon/locales/fr.json
+++ b/app/javascript/mastodon/locales/fr.json
@@ -77,6 +77,7 @@
   "compose_form.poll.remove_option": "Supprimer ce choix",
   "compose_form.publish": "Pouet",
   "compose_form.publish_loud": "{publish} !",
+  "compose_form.sensitive.hide": "Marquer le média comme sensible",
   "compose_form.sensitive.marked": "Média marqué comme sensible",
   "compose_form.sensitive.unmarked": "Média non marqué comme sensible",
   "compose_form.spoiler.marked": "Le texte est caché derrière un avertissement",
@@ -209,6 +210,7 @@
   "lightbox.close": "Fermer",
   "lightbox.next": "Suivant",
   "lightbox.previous": "Précédent",
+  "lightbox.view_context": "Voir le contexte",
   "lists.account.add": "Ajouter à la liste",
   "lists.account.remove": "Supprimer de la liste",
   "lists.delete": "Effacer la liste",
@@ -340,7 +342,6 @@
   "status.reply": "Répondre",
   "status.replyAll": "Répondre au fil",
   "status.report": "Signaler @{name}",
-  "status.sensitive_toggle": "Cliquer pour afficher",
   "status.sensitive_warning": "Contenu sensible",
   "status.share": "Partager",
   "status.show_less": "Replier",
diff --git a/app/javascript/mastodon/locales/ja.json b/app/javascript/mastodon/locales/ja.json
index 0bf9a22c4..509d0c2e8 100644
--- a/app/javascript/mastodon/locales/ja.json
+++ b/app/javascript/mastodon/locales/ja.json
@@ -81,6 +81,7 @@
   "compose_form.poll.remove_option": "この項目を削除",
   "compose_form.publish": "トゥート",
   "compose_form.publish_loud": "{publish}!",
+  "compose_form.sensitive.hide": "メディアを閲覧注意にする",
   "compose_form.sensitive.marked": "メディアに閲覧注意が設定されています",
   "compose_form.sensitive.unmarked": "メディアに閲覧注意が設定されていません",
   "compose_form.spoiler.marked": "閲覧注意が設定されています",
@@ -213,6 +214,7 @@
   "lightbox.close": "閉じる",
   "lightbox.next": "次",
   "lightbox.previous": "前",
+  "lightbox.view_context": "トゥートを表示",
   "lists.account.add": "リストに追加",
   "lists.account.remove": "リストから外す",
   "lists.delete": "リストを削除",
@@ -345,7 +347,6 @@
   "status.reply": "返信",
   "status.replyAll": "全員に返信",
   "status.report": "@{name}さんを通報",
-  "status.sensitive_toggle": "クリックして表示",
   "status.sensitive_warning": "閲覧注意",
   "status.share": "共有",
   "status.show_less": "隠す",
diff --git a/app/javascript/mastodon/locales/nl.json b/app/javascript/mastodon/locales/nl.json
index 96e39356b..497a11d5c 100644
--- a/app/javascript/mastodon/locales/nl.json
+++ b/app/javascript/mastodon/locales/nl.json
@@ -77,6 +77,7 @@
   "compose_form.poll.remove_option": "Deze keuze verwijderen",
   "compose_form.publish": "Toot",
   "compose_form.publish_loud": "{publish}!",
+  "compose_form.sensitive.hide": "Media als gevoelig markeren",
   "compose_form.sensitive.marked": "Media is als gevoelig gemarkeerd",
   "compose_form.sensitive.unmarked": "Media is niet als gevoelig gemarkeerd",
   "compose_form.spoiler.marked": "Tekst is achter een waarschuwing verborgen",
diff --git a/app/javascript/mastodon/reducers/compose.js b/app/javascript/mastodon/reducers/compose.js
index b45def281..85cbdfb17 100644
--- a/app/javascript/mastodon/reducers/compose.js
+++ b/app/javascript/mastodon/reducers/compose.js
@@ -131,13 +131,15 @@ function removeMedia(state, mediaId) {
   });
 };
 
-const insertSuggestion = (state, position, token, completion) => {
+const insertSuggestion = (state, position, token, completion, path) => {
   return state.withMutations(map => {
-    map.update('text', oldText => `${oldText.slice(0, position)}${completion} ${oldText.slice(position + token.length)}`);
+    map.updateIn(path, oldText => `${oldText.slice(0, position)}${completion} ${oldText.slice(position + token.length)}`);
     map.set('suggestion_token', null);
-    map.update('suggestions', ImmutableList(), list => list.clear());
-    map.set('focusDate', new Date());
-    map.set('caretPosition', position + completion.length + 1);
+    map.set('suggestions', ImmutableList());
+    if (path.length === 1 && path[0] === 'text') {
+      map.set('focusDate', new Date());
+      map.set('caretPosition', position + completion.length + 1);
+    }
     map.set('idempotencyKey', uuid());
   });
 };
@@ -304,7 +306,7 @@ export default function compose(state = initialState, action) {
   case COMPOSE_SUGGESTIONS_READY:
     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);
+    return insertSuggestion(state, action.position, action.token, action.completion, action.path);
   case COMPOSE_SUGGESTION_TAGS_UPDATE:
     return updateSuggestionTags(state, action.token);
   case COMPOSE_TAG_HISTORY_UPDATE:
@@ -329,7 +331,7 @@ export default function compose(state = initialState, action) {
       }));
   case REDRAFT:
     return state.withMutations(map => {
-      map.set('text', unescapeHTML(expandMentions(action.status)));
+      map.set('text', action.raw_content || unescapeHTML(expandMentions(action.status)));
       map.set('in_reply_to', action.status.get('in_reply_to_id'));
       map.set('privacy', action.status.get('visibility'));
       map.set('media_attachments', action.status.get('media_attachments'));
diff --git a/app/javascript/styles/contrast/diff.scss b/app/javascript/styles/contrast/diff.scss
index 8429103b8..f78e60597 100644
--- a/app/javascript/styles/contrast/diff.scss
+++ b/app/javascript/styles/contrast/diff.scss
@@ -67,3 +67,11 @@
     text-decoration: none;
   }
 }
+
+.nothing-here {
+  color: $darker-text-color;
+}
+
+.public-layout .public-account-header__tabs__tabs .counter.active::after {
+  border-bottom: 4px solid $ui-highlight-color;
+}
diff --git a/app/javascript/styles/mastodon/components.scss b/app/javascript/styles/mastodon/components.scss
index 1ba6fa5b1..f8f64bdd6 100644
--- a/app/javascript/styles/mastodon/components.scss
+++ b/app/javascript/styles/mastodon/components.scss
@@ -268,9 +268,34 @@
     padding: 10px;
     padding-top: 0;
 
-    .icon-button {
-      font-size: 14px;
-      font-weight: 500;
+    font-size: 14px;
+    font-weight: 500;
+
+    &.active {
+      color: $highlight-text-color;
+    }
+
+    input[type=checkbox] {
+      display: none;
+    }
+
+    .checkbox {
+      display: inline-block;
+      position: relative;
+      border: 1px solid $ui-primary-color;
+      box-sizing: border-box;
+      width: 18px;
+      height: 18px;
+      flex: 0 0 auto;
+      margin-right: 10px;
+      top: -1px;
+      border-radius: 4px;
+      vertical-align: middle;
+
+      &.active {
+        border-color: $highlight-text-color;
+        background: $highlight-text-color;
+      }
     }
   }
 
@@ -319,6 +344,7 @@
   }
 
   .autosuggest-textarea,
+  .autosuggest-input,
   .spoiler-input {
     position: relative;
   }
@@ -4829,6 +4855,14 @@ a.status-card.compact:hover {
   border-radius: 4px;
   overflow: hidden;
   margin: 2px;
+
+  &__icons {
+    position: absolute;
+    top: 50%;
+    left: 50%;
+    transform: translate(-50%, -50%);
+    font-size: 24px;
+  }
 }
 
 .notification__filter-bar,
diff --git a/app/javascript/styles/mastodon/polls.scss b/app/javascript/styles/mastodon/polls.scss
index d8bc5473a..0d55afda4 100644
--- a/app/javascript/styles/mastodon/polls.scss
+++ b/app/javascript/styles/mastodon/polls.scss
@@ -37,11 +37,14 @@
       display: none;
     }
 
+    .autossugest-input {
+      flex: 1 1 auto;
+    }
+
     input[type=text] {
       display: block;
       box-sizing: border-box;
-      flex: 1 1 auto;
-      width: 20px;
+      width: 100%;
       font-size: 14px;
       color: $inverted-text-color;
       display: block;
@@ -64,6 +67,7 @@
     &.editable {
       display: flex;
       align-items: center;
+      overflow: visible;
     }
   }
 
@@ -114,11 +118,14 @@
     text-decoration: underline;
     font-size: inherit;
 
-    &:hover,
-    &:focus,
-    &:active {
+    &:hover {
       text-decoration: none;
     }
+
+    &:active,
+    &:focus {
+      background-color: rgba($dark-text-color, .1);
+    }
   }
 
   .button {
diff --git a/app/lib/activitypub/tag_manager.rb b/app/lib/activitypub/tag_manager.rb
index 892bb9974..595291342 100644
--- a/app/lib/activitypub/tag_manager.rb
+++ b/app/lib/activitypub/tag_manager.rb
@@ -65,7 +65,14 @@ class ActivityPub::TagManager
     when 'unlisted', 'private'
       [account_followers_url(status.account)]
     when 'direct', 'limited'
-      status.active_mentions.map { |mention| uri_for(mention.account) }
+      if status.account.silenced?
+        # Only notify followers if the account is locally silenced
+        account_ids = status.active_mentions.pluck(:account_id)
+        to = status.account.followers.where(id: account_ids).map { |account| uri_for(account) }
+        to.concat(FollowRequest.where(target_account_id: status.account_id, account_id: account_ids).map { |request| uri_for(request.account) })
+      else
+        status.active_mentions.map { |mention| uri_for(mention.account) }
+      end
     end
   end
 
@@ -86,7 +93,16 @@ class ActivityPub::TagManager
       cc << COLLECTIONS[:public]
     end
 
-    cc.concat(status.active_mentions.map { |mention| uri_for(mention.account) }) unless status.direct_visibility? || status.limited_visibility?
+    unless status.direct_visibility? || status.limited_visibility?
+      if status.account.silenced?
+        # Only notify followers if the account is locally silenced
+        account_ids = status.active_mentions.pluck(:account_id)
+        cc.concat(status.account.followers.where(id: account_ids).map { |account| uri_for(account) })
+        cc.concat(FollowRequest.where(target_account_id: status.account_id, account_id: account_ids).map { |request| uri_for(request.account) })
+      else
+        cc.concat(status.active_mentions.map { |mention| uri_for(mention.account) })
+      end
+    end
 
     cc
   end
diff --git a/app/models/form/status_batch.rb b/app/models/form/status_batch.rb
index 898728067..933dfdaca 100644
--- a/app/models/form/status_batch.rb
+++ b/app/models/form/status_batch.rb
@@ -35,6 +35,7 @@ class Form::StatusBatch
   def delete_statuses
     Status.where(id: status_ids).reorder(nil).find_each do |status|
       RemovalWorker.perform_async(status.id)
+      Tombstone.find_or_create_by(uri: status.uri, account: status.account, by_moderator: true)
       log_action :destroy, status
     end
 
diff --git a/app/models/tombstone.rb b/app/models/tombstone.rb
index 997bb65fd..bf666c43a 100644
--- a/app/models/tombstone.rb
+++ b/app/models/tombstone.rb
@@ -4,11 +4,12 @@
 #
 # Table name: tombstones
 #
-#  id         :bigint(8)        not null, primary key
-#  account_id :bigint(8)
-#  uri        :string           not null
-#  created_at :datetime         not null
-#  updated_at :datetime         not null
+#  id           :bigint(8)        not null, primary key
+#  account_id   :bigint(8)
+#  uri          :string           not null
+#  created_at   :datetime         not null
+#  updated_at   :datetime         not null
+#  by_moderator :boolean
 #
 
 class Tombstone < ApplicationRecord
diff --git a/app/serializers/rest/status_serializer.rb b/app/serializers/rest/status_serializer.rb
index a7b797368..906f489db 100644
--- a/app/serializers/rest/status_serializer.rb
+++ b/app/serializers/rest/status_serializer.rb
@@ -3,7 +3,7 @@
 class REST::StatusSerializer < ActiveModel::Serializer
   attributes :id, :created_at, :in_reply_to_id, :in_reply_to_account_id,
              :sensitive, :spoiler_text, :visibility, :language,
-             :uri, :content, :url, :replies_count, :reblogs_count,
+             :uri, :url, :replies_count, :reblogs_count,
              :favourites_count
 
   attribute :favourited, if: :current_user?
@@ -13,6 +13,9 @@ class REST::StatusSerializer < ActiveModel::Serializer
   attribute :pinned, if: :pinnable?
   attribute :local_only if :local?
 
+  attribute :content, unless: :source_requested?
+  attribute :text, if: :source_requested?
+
   belongs_to :reblog, serializer: REST::StatusSerializer
   belongs_to :application, if: :show_application?
   belongs_to :account, serializer: REST::AccountSerializer
@@ -115,6 +118,10 @@ class REST::StatusSerializer < ActiveModel::Serializer
       %w(public unlisted).include?(object.visibility)
   end
 
+  def source_requested?
+    instance_options[:source_requested]
+  end
+
   def ordered_mentions
     object.active_mentions.to_a.sort_by(&:id)
   end
diff --git a/app/views/admin/action_logs/_action_log.html.haml b/app/views/admin/action_logs/_action_log.html.haml
index f059814bd..a545e189e 100644
--- a/app/views/admin/action_logs/_action_log.html.haml
+++ b/app/views/admin/action_logs/_action_log.html.haml
@@ -6,7 +6,7 @@
       .log-entry__title
         = t("admin.action_logs.actions.#{action_log.action}_#{action_log.target_type.underscore}", name: content_tag(:span, action_log.account.username, class: 'username'), target: content_tag(:span, log_target(action_log), class: 'target')).html_safe
       .log-entry__timestamp
-        %time= l action_log.created_at
+        %time.formatted{ datetime: action_log.created_at.iso8601 }
     .spacer
     .log-entry__icon
       = fa_icon icon_for_log(action_log)