diff --git a/app/controllers/api/v1/statuses_controller.rb b/app/controllers/api/v1/statuses_controller.rb
index bba3c0651beedb365d8554bd3bb0057d801e9db4..fc94c13c58b4e86f71dc40bfcc3f06e7e8650e7d 100644
--- a/app/controllers/api/v1/statuses_controller.rb
+++ b/app/controllers/api/v1/statuses_controller.rb
@@ -44,7 +44,8 @@ class Api::V1::StatusesController < Api::BaseController
                                          scheduled_at: status_params[:scheduled_at],
                                          application: doorkeeper_token.application,
                                          poll: status_params[:poll],
-                                         idempotency: request.headers['Idempotency-Key'])
+                                         idempotency: request.headers['Idempotency-Key'],
+                                         quote_id: status_params[:quote_id].blank? ? nil : status_params[:quote_id])
 
     render json: @status, serializer: @status.is_a?(ScheduledStatus) ? REST::ScheduledStatusSerializer : REST::StatusSerializer
   end
@@ -76,6 +77,7 @@ class Api::V1::StatusesController < Api::BaseController
       :spoiler_text,
       :visibility,
       :scheduled_at,
+      :quote_id,
       media_ids: [],
       poll: [
         :multiple,
diff --git a/app/javascript/mastodon/actions/compose.js b/app/javascript/mastodon/actions/compose.js
index c3c6ff1a1de704f37cefc0576f6d2cda22e37988..e721d8a428b924f515d92543e66983c10c2c1b7f 100644
--- a/app/javascript/mastodon/actions/compose.js
+++ b/app/javascript/mastodon/actions/compose.js
@@ -20,6 +20,8 @@ export const COMPOSE_SUBMIT_FAIL     = 'COMPOSE_SUBMIT_FAIL';
 export const COMPOSE_REPLY           = 'COMPOSE_REPLY';
 export const COMPOSE_REPLY_CANCEL    = 'COMPOSE_REPLY_CANCEL';
 export const COMPOSE_DIRECT          = 'COMPOSE_DIRECT';
+export const COMPOSE_QUOTE           = 'COMPOSE_QUOTE';
+export const COMPOSE_QUOTE_CANCEL    = 'COMPOSE_QUOTE_CANCEL';
 export const COMPOSE_MENTION         = 'COMPOSE_MENTION';
 export const COMPOSE_RESET           = 'COMPOSE_RESET';
 export const COMPOSE_UPLOAD_REQUEST  = 'COMPOSE_UPLOAD_REQUEST';
@@ -95,6 +97,23 @@ export function cancelReplyCompose() {
   };
 };
 
+export function quoteCompose(status, routerHistory) {
+  return (dispatch, getState) => {
+    dispatch({
+      type: COMPOSE_QUOTE,
+      status: status,
+    });
+
+    ensureComposeIsVisible(getState, routerHistory);
+  };
+};
+
+export function cancelQuoteCompose() {
+  return {
+    type: COMPOSE_QUOTE_CANCEL,
+  };
+};
+
 export function resetCompose() {
   return {
     type: COMPOSE_RESET,
@@ -142,6 +161,7 @@ export function submitCompose(routerHistory) {
       spoiler_text: getState().getIn(['compose', 'spoiler']) ? getState().getIn(['compose', 'spoiler_text'], '') : '',
       visibility: getState().getIn(['compose', 'privacy']),
       poll: getState().getIn(['compose', 'poll'], null),
+      quote_id: getState().getIn(['compose', 'quote_from'], null),
     }, {
       headers: {
         'Idempotency-Key': getState().getIn(['compose', 'idempotencyKey']),
diff --git a/app/javascript/mastodon/actions/importer/normalizer.js b/app/javascript/mastodon/actions/importer/normalizer.js
index 78f321da4d12568c55a6a0f72aace7877d9012c5..1d31ad07e37dc9d70b107a5026433eaf5e705de4 100644
--- a/app/javascript/mastodon/actions/importer/normalizer.js
+++ b/app/javascript/mastodon/actions/importer/normalizer.js
@@ -60,6 +60,8 @@ export function normalizeStatus(status, normalOldStatus) {
     normalStatus.contentHtml = normalOldStatus.get('contentHtml');
     normalStatus.spoilerHtml = normalOldStatus.get('spoilerHtml');
     normalStatus.hidden = normalOldStatus.get('hidden');
+    normalStatus.quote = normalOldStatus.get('quote');
+    normalStatus.quote_hidden = normalOldStatus.get('quote_hidden');
   } else {
     const spoilerText   = normalStatus.spoiler_text || '';
     const searchContent = ([spoilerText, status.content].concat((status.poll && status.poll.options) ? status.poll.options.map(option => option.title) : [])).join('\n\n').replace(/<br\s*\/?>/g, '\n').replace(/<\/p><p>/g, '\n\n');
@@ -69,6 +71,30 @@ export function normalizeStatus(status, normalOldStatus) {
     normalStatus.contentHtml  = emojify(normalStatus.content, emojiMap);
     normalStatus.spoilerHtml  = emojify(escapeTextContentForBrowser(spoilerText), emojiMap);
     normalStatus.hidden       = expandSpoilers ? false : spoilerText.length > 0 || normalStatus.sensitive;
+
+    if (status.quote && status.quote.id) {
+      const quote_spoilerText = status.quote.spoiler_text || '';
+      const quote_searchContent = [quote_spoilerText, status.quote.content].join('\n\n').replace(/<br\s*\/?>/g, '\n').replace(/<\/p><p>/g, '\n\n');
+
+      const quote_emojiMap = makeEmojiMap(normalStatus.quote);
+
+      const quote_account_emojiMap = makeEmojiMap(status.quote.account);
+      const displayName = normalStatus.quote.account.display_name.length === 0 ? normalStatus.quote.account.username : normalStatus.quote.account.display_name;
+      normalStatus.quote.account.display_name_html = emojify(escapeTextContentForBrowser(displayName), quote_account_emojiMap);
+      normalStatus.quote.search_index = domParser.parseFromString(quote_searchContent, 'text/html').documentElement.textContent;
+      let docElem = domParser.parseFromString(normalStatus.quote.content, 'text/html').documentElement;
+      Array.from(docElem.querySelectorAll('span.invisible'), span => span.remove());
+      Array.from(docElem.querySelectorAll('p,br'), line => {
+        let parentNode = line.parentNode;
+        if (line.nextSibling) {
+          parentNode.insertBefore(document.createTextNode(' '), line.nextSibling);
+        }
+      });
+      let _contentHtml = docElem.textContent;
+      normalStatus.quote.contentHtml  = '<p>'+emojify(_contentHtml.substr(0, 150), quote_emojiMap) + (_contentHtml.substr(150) ? '...' : '')+'</p>';
+      normalStatus.quote.spoilerHtml  = emojify(escapeTextContentForBrowser(quote_spoilerText), quote_emojiMap);
+      normalStatus.quote_hidden       = expandSpoilers ? false : quote_spoilerText.length > 0 || normalStatus.quote.sensitive;
+    }
   }
 
   return normalStatus;
diff --git a/app/javascript/mastodon/actions/statuses.js b/app/javascript/mastodon/actions/statuses.js
index 5640201c621f0f494afb96d731ace606a899c69b..1eba35bd904b6ea55511ab1d90a2757a92e414b9 100644
--- a/app/javascript/mastodon/actions/statuses.js
+++ b/app/javascript/mastodon/actions/statuses.js
@@ -32,6 +32,9 @@ export const STATUS_COLLAPSE = 'STATUS_COLLAPSE';
 
 export const REDRAFT = 'REDRAFT';
 
+export const QUOTE_REVEAL = 'QUOTE_REVEAL';
+export const QUOTE_HIDE   = 'QUOTE_HIDE';
+
 export function fetchStatusRequest(id, skipLoading) {
   return {
     type: STATUS_FETCH_REQUEST,
@@ -329,3 +332,25 @@ export function toggleStatusCollapse(id, isCollapsed) {
     isCollapsed,
   };
 }
+
+export function hideQuote(ids) {
+  if (!Array.isArray(ids)) {
+    ids = [ids];
+  }
+
+  return {
+    type: QUOTE_HIDE,
+    ids,
+  };
+};
+
+export function revealQuote(ids) {
+  if (!Array.isArray(ids)) {
+    ids = [ids];
+  }
+
+  return {
+    type: QUOTE_REVEAL,
+    ids,
+  };
+};
diff --git a/app/javascript/mastodon/components/media_gallery.js b/app/javascript/mastodon/components/media_gallery.js
index cfe164a507d8220ea6dda2f234e4c893f0277c8c..9c7cbb1300429be68dab3e702ffa475bf79b3aba 100644
--- a/app/javascript/mastodon/components/media_gallery.js
+++ b/app/javascript/mastodon/components/media_gallery.js
@@ -255,10 +255,12 @@ class MediaGallery extends React.PureComponent {
     visible: PropTypes.bool,
     autoplay: PropTypes.bool,
     onToggleVisibility: PropTypes.func,
+    quote: PropTypes.bool,
   };
 
   static defaultProps = {
     standalone: false,
+    quote: false,
   };
 
   state = {
@@ -303,7 +305,7 @@ class MediaGallery extends React.PureComponent {
   }
 
   render () {
-    const { media, intl, sensitive, height, defaultWidth, standalone, autoplay } = this.props;
+    const { media, intl, sensitive, height, defaultWidth, standalone, quote, autoplay } = this.props;
     const { visible } = this.state;
 
     const width = this.state.width || defaultWidth;
@@ -325,6 +327,10 @@ class MediaGallery extends React.PureComponent {
     const size     = media.take(4).size;
     const uncached = media.every(attachment => attachment.get('type') === 'unknown');
 
+    if (quote) {
+      style.height /= 2;
+    }
+
     if (standalone && this.isFullSizeEligible()) {
       children = <Item standalone autoplay={autoplay} onClick={this.handleClick} attachment={media.get(0)} displayWidth={width} visible={visible} />;
     } else {
diff --git a/app/javascript/mastodon/components/status.js b/app/javascript/mastodon/components/status.js
index 0dc00cb98d59b5f1c93e934a58856f4074078cf8..2177f0cfabe0b5c189d7eee3f24e66f059c0cc5e 100644
--- a/app/javascript/mastodon/components/status.js
+++ b/app/javascript/mastodon/components/status.js
@@ -77,6 +77,7 @@ class Status extends ImmutablePureComponent {
     onHeightChange: PropTypes.func,
     onToggleHidden: PropTypes.func,
     onToggleCollapsed: PropTypes.func,
+    onQuoteToggleHidden: PropTypes.func,
     muted: PropTypes.bool,
     hidden: PropTypes.bool,
     unread: PropTypes.bool,
@@ -87,6 +88,7 @@ class Status extends ImmutablePureComponent {
     updateScrollBottom: PropTypes.func,
     cacheMediaWidth: PropTypes.func,
     cachedMediaWidth: PropTypes.number,
+    contextType: PropTypes.string,
   };
 
   // Avoid checking props that are functions (and whose equality will always
@@ -148,6 +150,15 @@ class Status extends ImmutablePureComponent {
     }
   }
 
+  handleQuoteClick = () => {
+    if (!this.context.router) {
+      return;
+    }
+
+    const { status } = this.props;
+    this.context.router.history.push(`/statuses/${status.getIn(['reblog', 'quote', 'id'], status.getIn(['quote', 'id']))}`);
+  }
+
   handleAccountClick = (e) => {
     if (this.context.router && e.button === 0 && !(e.ctrlKey || e.metaKey)) {
       const id = e.currentTarget.getAttribute('data-id');
@@ -164,6 +175,10 @@ class Status extends ImmutablePureComponent {
     this.props.onToggleCollapsed(this._properStatus(), isCollapsed);
   }
 
+  handleExpandedQuoteToggle = () => {
+    this.props.onQuoteToggleHidden(this._properStatus());
+  };
+
   renderLoadingMediaGallery () {
     return <div className='media-gallery' style={{ height: '110px' }} />;
   }
@@ -253,11 +268,17 @@ class Status extends ImmutablePureComponent {
     this.node = c;
   }
 
+  _properQuoteStatus () {
+    const { status } = this.props;
+
+    return status.get('quote');
+  }
+
   render () {
     let media = null;
-    let statusAvatar, prepend, rebloggedByText;
+    let statusAvatar, prepend, rebloggedByText, unlistedQuoteText;
 
-    const { intl, hidden, featured, otherAccounts, unread, showThread } = this.props;
+    const { intl, hidden, featured, otherAccounts, unread, showThread, contextType } = this.props;
 
     let { status, account, ...other } = this.props;
 
@@ -413,6 +434,53 @@ class Status extends ImmutablePureComponent {
       statusAvatar = <AvatarOverlay account={status.get('account')} friend={account} />;
     }
 
+    let quote = null;
+    if (status.get('quote', null) !== null) {
+      let quote_status = status.get('quote');
+
+      let quote_media = null;
+      if (quote_status.get('media_attachments').size > 0) {
+        if (this.props.muted || quote_status.get('media_attachments').some(item => item.get('type') === 'unknown')) {
+          quote_media = (
+            <AttachmentList
+              compact
+              media={quote_status.get('media_attachments')}
+            />
+          );
+        } else {
+          quote_media = (
+            <Bundle fetchComponent={MediaGallery} loading={this.renderLoadingMediaGallery} >
+              {Component => <Component media={quote_status.get('media_attachments')} sensitive={quote_status.get('sensitive')} height={110} onOpenMedia={this.props.onOpenMedia} quote />}
+            </Bundle>
+          );
+        }
+      }
+
+      if (quote_status.get('visibility') === 'unlisted' && contextType !== 'home') {
+        unlistedQuoteText = intl.formatMessage({ id: 'status.unlisted_quote', defaultMessage: 'Unlisted quote' });
+        quote = (
+          <div className={classNames('quote-status', `status-${quote_status.get('visibility')}`, { muted: this.props.muted })} data-id={quote_status.get('id')}>
+            <div className={classNames('status__content unlisted-quote', { 'status__content--with-action': this.context.router })}>
+              <strong onClick={this.handleQuoteClick}>{unlistedQuoteText}</strong>
+            </div>
+          </div>
+        );
+      } else {
+        quote = (
+          <div className={classNames('quote-status', `status-${quote_status.get('visibility')}`, { muted: this.props.muted })} data-id={quote_status.get('id')}>
+            <div className='status__info'>
+              <a onClick={this.handleAccountClick} target='_blank' data-id={quote_status.getIn(['account', 'id'])} href={quote_status.getIn(['account', 'url'])} title={quote_status.getIn(['account', 'acct'])} className='status__display-name'>
+                <div className='status__avatar'><Avatar account={quote_status.get('account')} size={18} /></div>
+                <DisplayName account={quote_status.get('account')} />
+              </a>
+            </div>
+            <StatusContent status={quote_status} onClick={this.handleQuoteClick} expanded={!status.get('quote_hidden')} onExpandedToggle={this.handleExpandedQuoteToggle} />
+            {quote_media}
+          </div>
+        );
+      }
+    }
+
     return (
       <HotKeys handlers={handlers}>
         <div className={classNames('status__wrapper', `status__wrapper-${status.get('visibility')}`, { 'status__wrapper-reply': !!status.get('in_reply_to_id'), read: unread === false, focusable: !this.props.muted })} tabIndex={this.props.muted ? null : 0} data-featured={featured ? 'true' : null} aria-label={textForScreenReader(intl, status, rebloggedByText)} ref={this.handleRef}>
@@ -435,6 +503,7 @@ class Status extends ImmutablePureComponent {
             <StatusContent status={status} onClick={this.handleClick} expanded={!status.get('hidden')} onExpandedToggle={this.handleExpandedToggle} collapsable onCollapsedToggle={this.handleCollapsedToggle} />
 
             {media}
+            {quote}
 
             {showThread && status.get('in_reply_to_id') && status.get('in_reply_to_account_id') === status.getIn(['account', 'id']) && (
               <button className='status__content__read-more-button' onClick={this.handleClick}>
diff --git a/app/javascript/mastodon/components/status_action_bar.js b/app/javascript/mastodon/components/status_action_bar.js
index 4b3c79d0daec82bf46bec05dd278a34a62455bdf..9acb8f9403009a1815d01a09031f10eaa9333f28 100644
--- a/app/javascript/mastodon/components/status_action_bar.js
+++ b/app/javascript/mastodon/components/status_action_bar.js
@@ -23,6 +23,7 @@ const messages = defineMessages({
   reblog_private: { id: 'status.reblog_private', defaultMessage: 'Boost to original audience' },
   cancel_reblog_private: { id: 'status.cancel_reblog_private', defaultMessage: 'Unboost' },
   cannot_reblog: { id: 'status.cannot_reblog', defaultMessage: 'This post cannot be boosted' },
+  quote: { id: 'status.quote', defaultMessage: 'Quote' },
   favourite: { id: 'status.favourite', defaultMessage: 'Favourite' },
   bookmark: { id: 'status.bookmark', defaultMessage: 'Bookmark' },
   removeBookmark: { id: 'status.remove_bookmark', defaultMessage: 'Remove bookmark' },
@@ -70,6 +71,7 @@ class StatusActionBar extends ImmutablePureComponent {
     onReply: PropTypes.func,
     onFavourite: PropTypes.func,
     onReblog: PropTypes.func,
+    onQuote: PropTypes.func,
     onDelete: PropTypes.func,
     onDirect: PropTypes.func,
     onMention: PropTypes.func,
@@ -137,6 +139,10 @@ class StatusActionBar extends ImmutablePureComponent {
     this.props.onBookmark(this.props.status);
   }
 
+  handleQuoteClick = () => {
+    this.props.onQuote(this.props.status, this.context.router.history);
+  }
+
   handleDeleteClick = () => {
     this.props.onDelete(this.props.status, this.context.router.history);
   }
@@ -328,6 +334,7 @@ class StatusActionBar extends ImmutablePureComponent {
         <div className='status__action-bar__counter'><IconButton className='status__action-bar-button' title={replyTitle} icon={status.get('in_reply_to_account_id') === status.getIn(['account', 'id']) ? 'reply' : replyIcon} onClick={this.handleReplyClick} /><span className='status__action-bar__counter__label' >{obfuscatedCount(status.get('replies_count'))}</span></div>
         <IconButton className='status__action-bar-button' disabled={!publicStatus} active={status.get('reblogged')} pressed={status.get('reblogged')} title={!publicStatus ? intl.formatMessage(messages.cannot_reblog) : intl.formatMessage(messages.reblog)} icon={reblogIcon} onClick={this.handleReblogClick} />
         <IconButton className='status__action-bar-button star-icon' animate active={status.get('favourited')} pressed={status.get('favourited')} title={intl.formatMessage(messages.favourite)} icon='star' onClick={this.handleFavouriteClick} />
+        <IconButton className='status__action-bar-button' disabled={anonymousAccess || !publicStatus} title={!publicStatus ? intl.formatMessage(messages.cannot_reblog) : intl.formatMessage(messages.quote)} icon='quote-right' onClick={this.handleQuoteClick} />
         {shareButton}
 
         <div className='status__action-bar-dropdown'>
diff --git a/app/javascript/mastodon/containers/status_container.js b/app/javascript/mastodon/containers/status_container.js
index 2ba3a3123da224eb826f7935f136b26087db48d5..adb7a4e8b4b81b4cecb529515d2259bea04f228b 100644
--- a/app/javascript/mastodon/containers/status_container.js
+++ b/app/javascript/mastodon/containers/status_container.js
@@ -4,6 +4,7 @@ import Status from '../components/status';
 import { makeGetStatus } from '../selectors';
 import {
   replyCompose,
+  quoteCompose,
   mentionCompose,
   directCompose,
 } from '../actions/compose';
@@ -24,6 +25,8 @@ import {
   hideStatus,
   revealStatus,
   toggleStatusCollapse,
+  hideQuote,
+  revealQuote,
 } from '../actions/statuses';
 import {
   unmuteAccount,
@@ -95,6 +98,10 @@ const mapDispatchToProps = (dispatch, { intl }) => ({
     }
   },
 
+  onQuote (status, router) {
+    dispatch(quoteCompose(status, router));
+  },
+
   onFavourite (status) {
     if (status.get('favourited')) {
       dispatch(unfavourite(status));
@@ -206,6 +213,14 @@ const mapDispatchToProps = (dispatch, { intl }) => ({
   onUnblockDomain (domain) {
     dispatch(unblockDomain(domain));
   },
+  
+  onQuoteToggleHidden (status) {
+    if (status.get('quote_hidden')) {
+      dispatch(revealQuote(status.get('id')));
+    } else {
+      dispatch(hideQuote(status.get('id')));
+    }
+  },
 
 });
 
diff --git a/app/javascript/mastodon/features/compose/components/compose_form.js b/app/javascript/mastodon/features/compose/components/compose_form.js
index 47e189251c9f0d1ab8babe42b69576002db3bfc1..843ae31a666fe4d3834a394c621bb9e6060a7955 100644
--- a/app/javascript/mastodon/features/compose/components/compose_form.js
+++ b/app/javascript/mastodon/features/compose/components/compose_form.js
@@ -4,6 +4,7 @@ import Button from '../../../components/button';
 import ImmutablePropTypes from 'react-immutable-proptypes';
 import PropTypes from 'prop-types';
 import ReplyIndicatorContainer from '../containers/reply_indicator_container';
+import QuoteIndicatorContainer from '../containers/quote_indicator_container';
 import AutosuggestTextarea from '../../../components/autosuggest_textarea';
 import AutosuggestInput from '../../../components/autosuggest_input';
 import PollButtonContainer from '../containers/poll_button_container';
@@ -195,6 +196,7 @@ class ComposeForm extends ImmutablePureComponent {
         <WarningContainer />
 
         <ReplyIndicatorContainer />
+        <QuoteIndicatorContainer />
 
         <div className={`spoiler-input ${this.props.spoiler ? 'spoiler-input--visible' : ''}`} ref={this.setRef}>
           <AutosuggestInput
diff --git a/app/javascript/mastodon/features/compose/components/quote_indicator.js b/app/javascript/mastodon/features/compose/components/quote_indicator.js
new file mode 100644
index 0000000000000000000000000000000000000000..4a2f92ff1380448e8b4711d478c448e5cead5292
--- /dev/null
+++ b/app/javascript/mastodon/features/compose/components/quote_indicator.js
@@ -0,0 +1,75 @@
+import React from 'react';
+import ImmutablePropTypes from 'react-immutable-proptypes';
+import PropTypes from 'prop-types';
+import Avatar from '../../../components/avatar';
+import IconButton from '../../../components/icon_button';
+import DisplayName from '../../../components/display_name';
+import { defineMessages, injectIntl } from 'react-intl';
+import ImmutablePureComponent from 'react-immutable-pure-component';
+import { isRtl } from '../../../rtl';
+import AttachmentList from 'mastodon/components/attachment_list';
+
+const messages = defineMessages({
+  cancel: { id: 'quote_indicator.cancel', defaultMessage: 'Cancel' },
+});
+
+@injectIntl
+export default class QuoteIndicator extends ImmutablePureComponent {
+
+  static contextTypes = {
+    router: PropTypes.object,
+  };
+
+  static propTypes = {
+    status: ImmutablePropTypes.map,
+    onCancel: PropTypes.func.isRequired,
+    intl: PropTypes.object.isRequired,
+  };
+
+  handleClick = () => {
+    this.props.onCancel();
+  }
+
+  handleAccountClick = (e) => {
+    if (e.button === 0 && !(e.ctrlKey || e.metaKey)) {
+      e.preventDefault();
+      this.context.router.history.push(`/accounts/${this.props.status.getIn(['account', 'id'])}`);
+    }
+  }
+
+  render () {
+    const { status, intl } = this.props;
+
+    if (!status) {
+      return null;
+    }
+
+    const content = { __html: status.get('contentHtml') };
+    const style   = {
+      direction: isRtl(status.get('search_index')) ? 'rtl' : 'ltr',
+    };
+
+    return (
+      <div className='quote-indicator'>
+        <div className='quote-indicator__header'>
+          <div className='quote-indicator__cancel'><IconButton title={intl.formatMessage(messages.cancel)} icon='times' onClick={this.handleClick} /></div>
+
+          <a href={status.getIn(['account', 'url'])} onClick={this.handleAccountClick} className='quote-indicator__display-name'>
+            <div className='quote-indicator__display-avatar'><Avatar account={status.get('account')} size={24} /></div>
+            <DisplayName account={status.get('account')} />
+          </a>
+        </div>
+
+        <div className='quote-indicator__content' style={style} dangerouslySetInnerHTML={content} />
+
+        {status.get('media_attachments').size > 0 && (
+          <AttachmentList
+            compact
+            media={status.get('media_attachments')}
+          />
+        )}
+      </div>
+    );
+  }
+
+}
diff --git a/app/javascript/mastodon/features/compose/containers/quote_indicator_container.js b/app/javascript/mastodon/features/compose/containers/quote_indicator_container.js
new file mode 100644
index 0000000000000000000000000000000000000000..8a3ad4959a244a6d2525f26eba5e4558c35643d5
--- /dev/null
+++ b/app/javascript/mastodon/features/compose/containers/quote_indicator_container.js
@@ -0,0 +1,24 @@
+import { connect } from 'react-redux';
+import { cancelQuoteCompose } from '../../../actions/compose';
+import { makeGetStatus } from '../../../selectors';
+import QuoteIndicator from '../components/quote_indicator';
+
+const makeMapStateToProps = () => {
+  const getStatus = makeGetStatus();
+
+  const mapStateToProps = state => ({
+    status: getStatus(state, { id: state.getIn(['compose', 'quote_from']) }),
+  });
+
+  return mapStateToProps;
+};
+
+const mapDispatchToProps = dispatch => ({
+
+  onCancel () {
+    dispatch(cancelQuoteCompose());
+  },
+
+});
+
+export default connect(makeMapStateToProps, mapDispatchToProps)(QuoteIndicator);
diff --git a/app/javascript/mastodon/features/status/components/action_bar.js b/app/javascript/mastodon/features/status/components/action_bar.js
index bf6469f2fc31045dcebb933f8674aed41ccd01e3..b841ad3f9f9eb05b59085555aec555aeef0cadfd 100644
--- a/app/javascript/mastodon/features/status/components/action_bar.js
+++ b/app/javascript/mastodon/features/status/components/action_bar.js
@@ -17,6 +17,7 @@ const messages = defineMessages({
   reblog_private: { id: 'status.reblog_private', defaultMessage: 'Boost to original audience' },
   cancel_reblog_private: { id: 'status.cancel_reblog_private', defaultMessage: 'Unboost' },
   cannot_reblog: { id: 'status.cannot_reblog', defaultMessage: 'This post cannot be boosted' },
+  quote: { id: 'status.quote', defaultMessage: 'Quote' },
   favourite: { id: 'status.favourite', defaultMessage: 'Favourite' },
   bookmark: { id: 'status.bookmark', defaultMessage: 'Bookmark' },
   mute: { id: 'status.mute', defaultMessage: 'Mute @{name}' },
@@ -54,6 +55,7 @@ class ActionBar extends React.PureComponent {
     relationship: ImmutablePropTypes.map,
     onReply: PropTypes.func.isRequired,
     onReblog: PropTypes.func.isRequired,
+    onQuote: PropTypes.func.isRequired,
     onFavourite: PropTypes.func.isRequired,
     onBookmark: PropTypes.func.isRequired,
     onDelete: PropTypes.func.isRequired,
@@ -80,6 +82,10 @@ class ActionBar extends React.PureComponent {
     this.props.onReblog(this.props.status, e);
   }
 
+  handleQuoteClick = () => {
+    this.props.onQuote(this.props.status, this.context.router.history);
+  }
+
   handleFavouriteClick = () => {
     this.props.onFavourite(this.props.status);
   }
@@ -271,6 +277,7 @@ class ActionBar extends React.PureComponent {
         <div className='detailed-status__button'><IconButton title={intl.formatMessage(messages.reply)} icon={status.get('in_reply_to_account_id') === status.getIn(['account', 'id']) ? 'reply' : replyIcon} onClick={this.handleReplyClick} /></div>
         <div className='detailed-status__button'><IconButton disabled={reblog_disabled} active={status.get('reblogged')} title={reblog_disabled ? intl.formatMessage(messages.cannot_reblog) : intl.formatMessage(messages.reblog)} icon={reblogIcon} onClick={this.handleReblogClick} /></div>
         <div className='detailed-status__button'><IconButton className='star-icon' animate active={status.get('favourited')} title={intl.formatMessage(messages.favourite)} icon='star' onClick={this.handleFavouriteClick} /></div>
+        <div className='detailed-status__button'><IconButton disabled={reblog_disabled} title={reblog_disabled ? intl.formatMessage(messages.cannot_reblog) : intl.formatMessage(messages.quote)} icon='quote-right' onClick={this.handleQuoteClick} /></div>
         {shareButton}
         <div className='detailed-status__button'><IconButton className='bookmark-icon' active={status.get('bookmarked')} title={intl.formatMessage(messages.bookmark)} icon='bookmark' onClick={this.handleBookmarkClick} /></div>
 
diff --git a/app/javascript/mastodon/features/status/components/card.js b/app/javascript/mastodon/features/status/components/card.js
index 2993fe29aaa54a589d687ac5527b7d9673b82577..6dcb875a719f861703e1772c2b11cb91915a6985 100644
--- a/app/javascript/mastodon/features/status/components/card.js
+++ b/app/javascript/mastodon/features/status/components/card.js
@@ -56,6 +56,10 @@ const addAutoPlay = html => {
 
 export default class Card extends React.PureComponent {
 
+  static contextTypes = {
+    router: PropTypes.object,
+  };
+
   static propTypes = {
     card: ImmutablePropTypes.map,
     maxDescription: PropTypes.number,
diff --git a/app/javascript/mastodon/features/status/components/detailed_status.js b/app/javascript/mastodon/features/status/components/detailed_status.js
index d5bc82735985bfd595aa599699c81de89a9f10bd..307d9dbc45ac16b4fb76af8bd0ec2fa09cf72e66 100644
--- a/app/javascript/mastodon/features/status/components/detailed_status.js
+++ b/app/javascript/mastodon/features/status/components/detailed_status.js
@@ -32,6 +32,7 @@ export default class DetailedStatus extends ImmutablePureComponent {
     compact: PropTypes.bool,
     showMedia: PropTypes.bool,
     onToggleMediaVisibility: PropTypes.func,
+    onQuoteToggleHidden: PropTypes.func.isRequired,
   };
 
   state = {
@@ -88,6 +89,19 @@ export default class DetailedStatus extends ImmutablePureComponent {
     window.open(href, 'mastodon-intent', 'width=445,height=600,resizable=no,menubar=no,status=no,scrollbars=yes');
   }
 
+  handleExpandedQuoteToggle = () => {
+    this.props.onQuoteToggleHidden(this.props.status);
+  }
+
+  handleQuoteClick = () => {
+    if (!this.context.router) {
+      return;
+    }
+
+    const { status } = this.props;
+    this.context.router.history.push(`/statuses/${status.getIn(['quote', 'id'])}`);
+  }
+
   render () {
     const status = (this.props.status && this.props.status.get('reblog')) ? this.props.status.get('reblog') : this.props.status;
     const outerStyle = { boxSizing: 'border-box' };
@@ -107,6 +121,36 @@ export default class DetailedStatus extends ImmutablePureComponent {
       outerStyle.height = `${this.state.height}px`;
     }
 
+    let quote = null;
+    if (status.get('quote', null) !== null) {
+      let quote_status = status.get('quote');
+
+      let quote_media = null;
+      if (quote_status.get('media_attachments').size > 0) {
+        quote_media = (
+          <MediaGallery
+            standalone
+            sensitive={quote_status.get('sensitive')}
+            media={quote_status.get('media_attachments')}
+            height={300}
+            onOpenMedia={this.props.onOpenMedia}
+            quote
+          />
+        );
+      }
+
+      quote = (
+        <div className='quote-status'>
+          <div className='status__info'>
+            <div className='status__avatar'><Avatar account={quote_status.get('account')} size={18} /></div>
+            <DisplayName account={quote_status.get('account')} />
+          </div>
+          <StatusContent status={quote_status} onClick={this.handleQuoteClick} expanded={!status.get('quote_hidden')} onExpandedToggle={this.handleExpandedQuoteToggle} />
+          {quote_media}
+        </div>
+      );
+    }
+
     if (status.get('media_attachments').size > 0) {
       if (status.getIn(['media_attachments', 0, 'type']) === 'audio') {
         const attachment = status.getIn(['media_attachments', 0]);
@@ -218,6 +262,7 @@ export default class DetailedStatus extends ImmutablePureComponent {
           <StatusContent status={status} expanded={!status.get('hidden')} onExpandedToggle={this.handleExpandedToggle} />
 
           {media}
+          {quote}
 
           <div className='detailed-status__meta'>
             <a className='detailed-status__datetime' href={status.get('url')} target='_blank' rel='noopener noreferrer'>
diff --git a/app/javascript/mastodon/features/status/index.js b/app/javascript/mastodon/features/status/index.js
index c058120d6b07d6f8e250e8d0f2c8193e5ce20a35..0ff7350b0f7116d7ff7af25b0840cadb4b309971 100644
--- a/app/javascript/mastodon/features/status/index.js
+++ b/app/javascript/mastodon/features/status/index.js
@@ -22,6 +22,7 @@ import {
 } from '../../actions/interactions';
 import {
   replyCompose,
+  quoteCompose,
   mentionCompose,
   directCompose,
 } from '../../actions/compose';
@@ -31,6 +32,8 @@ import {
   deleteStatus,
   hideStatus,
   revealStatus,
+  hideQuote,
+  revealQuote,
 } from '../../actions/statuses';
 import {
   unblockAccount,
@@ -251,6 +254,10 @@ class Status extends ImmutablePureComponent {
     }
   }
 
+  handleQuoteClick = (status) => {
+    this.props.dispatch(quoteCompose(status, this.context.router.history));
+  }
+
   handleDeleteClick = (status, history, withRedraft = false) => {
     const { dispatch, intl } = this.props;
 
@@ -317,6 +324,14 @@ class Status extends ImmutablePureComponent {
     }
   }
 
+  handleQuoteToggleHidden = (status) => {
+    if (status.get('quote_hidden')) {
+      this.props.dispatch(revealQuote(status.get('id')));
+    } else {
+      this.props.dispatch(hideQuote(status.get('id')));
+    }
+  }
+
   handleToggleAll = () => {
     const { status, ancestorsIds, descendantsIds } = this.props;
     const statusIds = [status.get('id')].concat(ancestorsIds.toJS(), descendantsIds.toJS());
@@ -550,6 +565,7 @@ class Status extends ImmutablePureComponent {
                   domain={domain}
                   showMedia={this.state.showMedia}
                   onToggleMediaVisibility={this.handleToggleMediaVisibility}
+                  onQuoteToggleHidden={this.handleQuoteToggleHidden}
                 />
 
                 <ActionBar
@@ -559,6 +575,7 @@ class Status extends ImmutablePureComponent {
                   onFavourite={this.handleFavouriteClick}
                   onReblog={this.handleReblogClick}
                   onBookmark={this.handleBookmarkClick}
+                  onQuote={this.handleQuoteClick}
                   onDelete={this.handleDeleteClick}
                   onDirect={this.handleDirectClick}
                   onMention={this.handleMentionClick}
diff --git a/app/javascript/mastodon/locales/defaultMessages.json b/app/javascript/mastodon/locales/defaultMessages.json
index 0cb67c08c0c8e1643174ffdb183c2729602ebdcc..b00cbdbc1d30caf34ff02df82fa825fe255fb525 100644
--- a/app/javascript/mastodon/locales/defaultMessages.json
+++ b/app/javascript/mastodon/locales/defaultMessages.json
@@ -394,6 +394,14 @@
         "defaultMessage": "This post cannot be boosted",
         "id": "status.cannot_reblog"
       },
+      {
+        "defaultMessage": "Quote",
+        "id": "status.quote"
+      },
+      {
+        "defaultMessage": "Unlisted quote",
+        "id": "status.unlisted_quote"
+      },
       {
         "defaultMessage": "Favourite",
         "id": "status.favourite"
@@ -1103,6 +1111,15 @@
     ],
     "path": "app/javascript/mastodon/features/compose/components/privacy_dropdown.json"
   },
+  {
+    "descriptors": [
+      {
+        "defaultMessage": "Cancel",
+        "id": "quote_indicator.cancel"
+      }
+    ],
+    "path": "app/javascript/mastodon/features/compose/components/quote_indicator.json"
+  },
   {
     "descriptors": [
       {
diff --git a/app/javascript/mastodon/locales/en.json b/app/javascript/mastodon/locales/en.json
index 0494a9cf5593323ba2db3de7251758c8f4441f3e..9be7ade34b0d00e08033adfa0cdb6cefc73d6a73 100644
--- a/app/javascript/mastodon/locales/en.json
+++ b/app/javascript/mastodon/locales/en.json
@@ -324,6 +324,7 @@
   "privacy.public.short": "Public",
   "privacy.unlisted.long": "Do not post to public timelines",
   "privacy.unlisted.short": "Unlisted",
+  "quote_indicator.cancel": "Cancel",
   "refresh": "Refresh",
   "regeneration_indicator.label": "Loading…",
   "regeneration_indicator.sublabel": "Your home feed is being prepared!",
@@ -374,6 +375,7 @@
   "status.pin": "Pin on profile",
   "status.pinned": "Pinned toot",
   "status.read_more": "Read more",
+  "status.quote": "Quote",
   "status.reblog": "Boost",
   "status.reblog_private": "Boost to original audience",
   "status.reblogged_by": "{name} boosted",
diff --git a/app/javascript/mastodon/locales/ja.json b/app/javascript/mastodon/locales/ja.json
index 4cb405a8956c3547eaad9c453849be614af16e7d..b93af6c9564e79c92e72ccf68c21e61bf6ea2f7b 100644
--- a/app/javascript/mastodon/locales/ja.json
+++ b/app/javascript/mastodon/locales/ja.json
@@ -324,6 +324,7 @@
   "privacy.public.short": "公開",
   "privacy.unlisted.long": "公開TLで表示しない",
   "privacy.unlisted.short": "未収載",
+  "quote_indicator.cancel": "キャンセル",
   "refresh": "æ›´æ–°",
   "regeneration_indicator.label": "読み込み中…",
   "regeneration_indicator.sublabel": "ホームタイムラインは準備中です!",
@@ -374,6 +375,8 @@
   "status.pin": "プロフィールに固定表示",
   "status.pinned": "固定されたトゥート",
   "status.read_more": "もっと見る",
+  "status.quote": "引用",
+  "status.unlisted_quote": "未収載の引用",
   "status.reblog": "ブースト",
   "status.reblog_private": "ブースト",
   "status.reblogged_by": "{name}さんがブースト",
diff --git a/app/javascript/mastodon/reducers/compose.js b/app/javascript/mastodon/reducers/compose.js
index c6653fe4cdf9128c3c0b0ca59249090c09aebc2b..b84c095ac4287a892cf9206c25d0d2bd9a1e5e40 100644
--- a/app/javascript/mastodon/reducers/compose.js
+++ b/app/javascript/mastodon/reducers/compose.js
@@ -5,6 +5,8 @@ import {
   COMPOSE_REPLY,
   COMPOSE_REPLY_CANCEL,
   COMPOSE_DIRECT,
+  COMPOSE_QUOTE,
+  COMPOSE_QUOTE_CANCEL,
   COMPOSE_MENTION,
   COMPOSE_SUBMIT_REQUEST,
   COMPOSE_SUBMIT_SUCCESS,
@@ -55,6 +57,8 @@ const initialState = ImmutableMap({
   caretPosition: null,
   preselectDate: null,
   in_reply_to: null,
+  quote_from: null,
+  quote_from_url: null,
   is_composing: false,
   is_submitting: false,
   is_changing_upload: false,
@@ -96,6 +100,7 @@ function clearAll(state) {
     map.set('is_submitting', false);
     map.set('is_changing_upload', false);
     map.set('in_reply_to', null);
+    map.set('quote_from', null);
     map.set('privacy', state.get('default_privacy'));
     map.set('sensitive', false);
     map.update('media_attachments', list => list.clear());
@@ -241,6 +246,17 @@ const updateSuggestionTags = (state, token) => {
   });
 };
 
+const rejectQuoteAltText = html => {
+  const fragment = domParser.parseFromString(html, 'text/html').documentElement;
+
+  const quote_inline = fragment.querySelector('span.quote-inline');
+  if (quote_inline) {
+    quote_inline.remove();
+  }
+
+  return fragment.innerHTML;
+};
+
 export default function compose(state = initialState, action) {
   switch(action.type) {
   case STORE_HYDRATE:
@@ -287,6 +303,8 @@ export default function compose(state = initialState, action) {
   case COMPOSE_REPLY:
     return state.withMutations(map => {
       map.set('in_reply_to', action.status.get('id'));
+      map.set('quote_from', null);
+      map.set('quote_from_url', null);
       map.set('text', statusToTextMentions(state, action.status));
       map.set('privacy', privacyPreference(action.status.get('visibility'), state.get('default_privacy')));
       map.set('focusDate', new Date());
@@ -294,6 +312,25 @@ export default function compose(state = initialState, action) {
       map.set('preselectDate', new Date());
       map.set('idempotencyKey', uuid());
 
+      if (action.status.get('spoiler_text').length > 0) {
+        map.set('spoiler', true);
+        map.set('spoiler_text', action.status.get('spoiler_text'));
+      } else {
+        map.set('spoiler', false);
+        map.set('spoiler_text', '');
+      }
+    });
+  case COMPOSE_QUOTE:
+    return state.withMutations(map => {
+      map.set('in_reply_to', null);
+      map.set('quote_from', action.status.get('id'));
+      map.set('quote_from_url', action.status.get('url'));
+      map.set('text', '');
+      map.set('privacy', privacyPreference(action.status.get('visibility'), state.get('default_privacy')));
+      map.set('focusDate', new Date());
+      map.set('preselectDate', new Date());
+      map.set('idempotencyKey', uuid());
+
       if (action.status.get('spoiler_text').length > 0) {
         map.set('spoiler', true);
         map.set('spoiler_text', action.status.get('spoiler_text'));
@@ -303,9 +340,12 @@ export default function compose(state = initialState, action) {
       }
     });
   case COMPOSE_REPLY_CANCEL:
+  case COMPOSE_QUOTE_CANCEL:
   case COMPOSE_RESET:
     return state.withMutations(map => {
       map.set('in_reply_to', null);
+      map.set('quote_from', null);
+      map.set('quote_from_url', null);
       map.set('text', '');
       map.set('spoiler', false);
       map.set('spoiler_text', '');
@@ -378,8 +418,10 @@ export default function compose(state = initialState, action) {
       }));
   case REDRAFT:
     return state.withMutations(map => {
-      map.set('text', action.raw_text || unescapeHTML(expandMentions(action.status)));
+      map.set('text', action.raw_text || unescapeHTML(rejectQuoteAltText(expandMentions(action.status))));
       map.set('in_reply_to', action.status.get('in_reply_to_id'));
+      map.set('quote_from', action.status.getIn(['quote', 'id']));
+      map.set('quote_from_url', action.status.getIn(['quote', 'url']));
       map.set('privacy', action.status.get('visibility'));
       map.set('media_attachments', action.status.get('media_attachments'));
       map.set('focusDate', new Date());
diff --git a/app/javascript/mastodon/reducers/search.js b/app/javascript/mastodon/reducers/search.js
index 875b2d92b39d4ba6cd6ad2d9ca091c55729ac411..f139a124bd1f0cad59f56a1456c9321a4522e716 100644
--- a/app/javascript/mastodon/reducers/search.js
+++ b/app/javascript/mastodon/reducers/search.js
@@ -9,6 +9,7 @@ import {
   COMPOSE_MENTION,
   COMPOSE_REPLY,
   COMPOSE_DIRECT,
+  COMPOSE_QUOTE,
 } from '../actions/compose';
 import { Map as ImmutableMap, List as ImmutableList, fromJS } from 'immutable';
 
@@ -36,6 +37,7 @@ export default function search(state = initialState, action) {
   case COMPOSE_REPLY:
   case COMPOSE_MENTION:
   case COMPOSE_DIRECT:
+  case COMPOSE_QUOTE:
     return state.set('hidden', true);
   case SEARCH_FETCH_SUCCESS:
     return state.set('results', ImmutableMap({
diff --git a/app/javascript/mastodon/reducers/statuses.js b/app/javascript/mastodon/reducers/statuses.js
index 398a48cff8f226b54c4df9aec865214d0c68d642..4d75ca8866747c0c5c53bd8194e438cbb87d0e13 100644
--- a/app/javascript/mastodon/reducers/statuses.js
+++ b/app/javascript/mastodon/reducers/statuses.js
@@ -13,6 +13,8 @@ import {
   STATUS_REVEAL,
   STATUS_HIDE,
   STATUS_COLLAPSE,
+  QUOTE_REVEAL,
+  QUOTE_HIDE,
 } from '../actions/statuses';
 import { TIMELINE_DELETE } from '../actions/timelines';
 import { STATUS_IMPORT, STATUSES_IMPORT } from '../actions/importer';
@@ -76,6 +78,14 @@ export default function statuses(state = initialState, action) {
     });
   case STATUS_COLLAPSE:
     return state.setIn([action.id, 'collapsed'], action.isCollapsed);
+  case QUOTE_REVEAL:
+    return state.withMutations(map => {
+      action.ids.forEach(id => map.setIn([id, 'quote_hidden'], false));
+    });
+  case QUOTE_HIDE:
+    return state.withMutations(map => {
+      action.ids.forEach(id => map.setIn([id, 'quote_hidden'], true));
+    });
   case TIMELINE_DELETE:
     return deleteStatus(state, action.id, action.references);
   default:
diff --git a/app/javascript/packs/public.js b/app/javascript/packs/public.js
index 9bc6214af28b2b08832cc7f85d834f3597624002..bb5048cb8f2d0e141642aaa95079d3785a142a19 100644
--- a/app/javascript/packs/public.js
+++ b/app/javascript/packs/public.js
@@ -249,6 +249,19 @@ function main() {
       target.style.display = 'block';
     }
   });
+  
+  delegate(document, '.quote-status', 'click', ({ target }) => {
+    if (target.closest('.status__content__spoiler-link') ||
+      target.closest('.media-gallery'))
+      return false;
+    const url = target.closest('.status__display-name') ? target.closest('.status__display-name').getAttribute('href') : target.closest('.quote-status').getAttribute('dataurl');
+    if (window.location.hostname === url.split('/')[2].split(':')[0]) {
+      window.location.href = url;
+    } else {
+      window.open(url, 'blank');
+    }
+    return false;
+  });
 }
 
 loadPolyfills()
diff --git a/app/javascript/styles/mastodon/components.scss b/app/javascript/styles/mastodon/components.scss
index 94671c350d32542b1ab9bb75a01ed0563712f82b..ee56d3bae6c3bbc6c972be3d1b93784ca87f5b1b 100644
--- a/app/javascript/styles/mastodon/components.scss
+++ b/app/javascript/styles/mastodon/components.scss
@@ -723,26 +723,37 @@
 }
 
 .reply-indicator {
+  background: $ui-primary-color;
+}
+
+.quote-indicator {
+  background: $success-green;
+}
+
+.reply-indicator,
+.quote-indicator {
   border-radius: 4px;
   margin-bottom: 10px;
-  background: $ui-primary-color;
   padding: 10px;
   min-height: 23px;
   overflow-y: auto;
   flex: 0 2 auto;
 }
 
-.reply-indicator__header {
+.reply-indicator__header,
+.quote-indicator__header {
   margin-bottom: 5px;
   overflow: hidden;
 }
 
-.reply-indicator__cancel {
+.reply-indicator__cancel,
+.quote-indicator__cancel {
   float: right;
   line-height: 24px;
 }
 
-.reply-indicator__display-name {
+.reply-indicator__display-name,
+.quote-indicator__display-name {
   color: $inverted-text-color;
   display: block;
   max-width: 100%;
@@ -752,7 +763,8 @@
   text-decoration: none;
 }
 
-.reply-indicator__display-avatar {
+.reply-indicator__display-avatar,
+.quote-indicator__display-avatar {
   float: left;
   margin-right: 5px;
 }
@@ -762,7 +774,8 @@
 }
 
 .status__content,
-.reply-indicator__content {
+.reply-indicator__content,
+.quote-indicator__content {
   position: relative;
   font-size: 15px;
   line-height: 20px;
@@ -907,6 +920,56 @@
   border-bottom: 1px solid lighten($ui-base-color, 8%);
 }
 
+.quote-inline {
+  display: none;
+}
+
+.quote-status {
+  border: solid 1px $ui-base-lighter-color;
+  border-radius: 4px;
+  padding: 5px;
+  margin-top: 8px;
+  position: relative;
+
+  & > .unlisted-quote {
+    color: $dark-text-color;
+    font-weight: 500;
+  }
+
+  .status__avatar {
+    height: 18px;
+    width: 18px;
+    position: absolute;
+    top: 5px;
+    left: 5px;
+    cursor: pointer;
+
+    & > div {
+      width: 18px;
+      height: 18px;
+    }
+  }
+
+  .display-name__account {
+    color: $ui-base-lighter-color;
+  }
+
+  .display-name {
+    padding-left: 20px;
+  }
+}
+
+@media screen and (min-width: 630px) {
+  .columns-area--mobile .quote-status .status__avatar {
+    top: 5px;
+    left: 5px;
+  }
+}
+
+.muted .quote-status .display-name {
+  color: $ui-base-lighter-color;
+}
+
 .status__prepend-icon-wrapper {
   left: -26px;
   position: absolute;
@@ -1197,7 +1260,8 @@
   margin-left: 6px;
 }
 
-.reply-indicator__content {
+.reply-indicator__content,
+.quote-indicator__content {
   color: $inverted-text-color;
   font-size: 14px;
 
diff --git a/app/javascript/styles/mastodon/statuses.scss b/app/javascript/styles/mastodon/statuses.scss
index 19ce0ab8f06924409caa904f2df8b42151d6e2a1..2558c99b300bb2a9f7fdef01c6a0049f9e555b41 100644
--- a/app/javascript/styles/mastodon/statuses.scss
+++ b/app/javascript/styles/mastodon/statuses.scss
@@ -63,6 +63,28 @@
       }
     }
 
+    .status.quote-status {
+      border: solid 1px $ui-base-lighter-color;
+      border-radius: 4px;
+      padding: 5px;
+      margin-top: 15px;
+      cursor: pointer;
+      width: 100%;
+
+      .status__avatar {
+        height: 18px;
+        width: 18px;
+        position: absolute;
+        top: 5px;
+        left: 5px;
+
+        & > div {
+          width: 18px;
+          height: 18px;
+        }
+      }
+    }
+
     @media screen and (max-width: 740px) {
       .detailed-status,
       .status,
diff --git a/app/lib/activitypub/activity/create.rb b/app/lib/activitypub/activity/create.rb
index c55cfe08eae8909584c389c63064d20c4972c11d..ed6039ffcd130428a7dc126866dea801b8f9d92a 100644
--- a/app/lib/activitypub/activity/create.rb
+++ b/app/lib/activitypub/activity/create.rb
@@ -78,6 +78,7 @@ class ActivityPub::Activity::Create < ActivityPub::Activity
         conversation: conversation_from_uri(@object['conversation']),
         media_attachment_ids: process_attachments.take(4).map(&:id),
         poll: process_poll,
+        quote: quote_from_url(@object['quoteUrl']),
       }
     end
   end
@@ -457,4 +458,10 @@ class ActivityPub::Activity::Create < ActivityPub::Activity
   def poll_lock_options
     { redis: Redis.current, key: "vote:#{replied_to_status.poll_id}:#{@account.id}" }
   end
+
+  def quote_from_url(url)
+    return nil if url.nil?
+    quote = ResolveURLService.new.call(url)
+    status_from_uri(quote.uri) if quote
+  end
 end
diff --git a/app/lib/activitypub/adapter.rb b/app/lib/activitypub/adapter.rb
index 78138fb73f8c3753ea7516195fb9c3098594aab8..b40eb072d61adaf0aaabb92feaf19763b2c2b5a5 100644
--- a/app/lib/activitypub/adapter.rb
+++ b/app/lib/activitypub/adapter.rb
@@ -22,6 +22,7 @@ class ActivityPub::Adapter < ActiveModelSerializers::Adapter::Base
     blurhash: { 'toot' => 'http://joinmastodon.org/ns#', 'blurhash' => 'toot:blurhash' },
     discoverable: { 'toot' => 'http://joinmastodon.org/ns#', 'discoverable' => 'toot:discoverable' },
     voters_count: { 'toot' => 'http://joinmastodon.org/ns#', 'votersCount' => 'toot:votersCount' },
+    quoteUrl: { 'quoteUrl' => 'as:quoteUrl' },
   }.freeze
 
   def self.default_key_transform
diff --git a/app/lib/formatter.rb b/app/lib/formatter.rb
index c771dcaaa0d4c178b075b0a708fae84a527ad501..c5fa072d2b68c4e0aa38f78ff11ebbe5fec55ff2 100644
--- a/app/lib/formatter.rb
+++ b/app/lib/formatter.rb
@@ -39,11 +39,24 @@ class Formatter
     html = encode_and_link_urls(html, linkable_accounts)
     html = encode_custom_emojis(html, status.emojis, options[:autoplay]) if options[:custom_emojify]
     html = simple_format(html, {}, sanitize: false)
+    html = quotify(html, status, options) if status.quote? && !options[:escape_quotify]
     html = html.delete("\n")
 
     html.html_safe # rubocop:disable Rails/OutputSafety
   end
 
+  def format_in_quote(status, **options)
+    html = format(status)
+    return '' if html.empty?
+    doc = Nokogiri::HTML.parse(html, nil, 'utf-8')
+    doc.search('span.invisible').remove
+    html = doc.css('body')[0].inner_html
+    html.sub!(/^<p>(.+)<\/p>$/, '\1')
+    html = Sanitize.clean(html).delete("\n").truncate(150)
+    html = encode_custom_emojis(html, status.emojis) if options[:custom_emojify]
+    html.html_safe
+  end
+
   def reformat(html)
     sanitize(html, Sanitize::Config::MASTODON_STRICT)
   end
@@ -188,6 +201,15 @@ class Formatter
     html
   end
 
+  def quotify(html, status, options)
+    #options[:escape_quotify] = true
+    #quote_content = format_in_quote(status.quote, options)
+    url = ActivityPub::TagManager.instance.url_for(status.quote)
+    link = encode_and_link_urls(url)
+    #html.sub(/(<[^>]+>)\z/, "<span class=\"quote-inline\"><br/>QT: #{quote_content} [#{link}]</span>\\1")
+    html.sub(/(<[^>]+>)\z/, "<span class=\"quote-inline\"><br/>QT: [#{link}]</span>\\1")
+  end
+
   def rewrite(text, entities)
     text = text.to_s
 
diff --git a/app/lib/sanitize_config.rb b/app/lib/sanitize_config.rb
index e2480376e4ac50127dfccf4db14378e0f7c65771..2c96454dff8e1bb0af0c5ae2b552d81913e2db9e 100644
--- a/app/lib/sanitize_config.rb
+++ b/app/lib/sanitize_config.rb
@@ -14,6 +14,7 @@ class Sanitize
         next true if e =~ /^(h|p|u|dt|e)-/ # microformats classes
         next true if e =~ /^(mention|hashtag)$/ # semantic classes
         next true if e =~ /^(ellipsis|invisible)$/ # link formatting classes
+        next true if e =~ /^quote-inline$/ # quote inline classes
       end
 
       node['class'] = class_list.join(' ')
diff --git a/app/models/status.rb b/app/models/status.rb
index 1e630196bde5b9069f38edfec16bf6728682cc12..bf2294a5b1480e9939890ac868ac6800d1c56fac 100644
--- a/app/models/status.rb
+++ b/app/models/status.rb
@@ -23,6 +23,7 @@
 #  in_reply_to_account_id :bigint(8)
 #  poll_id                :bigint(8)
 #  deleted_at             :datetime
+#  quote_id               :bigint(8)
 #
 
 class Status < ApplicationRecord
@@ -52,6 +53,7 @@ class Status < ApplicationRecord
 
   belongs_to :thread, foreign_key: 'in_reply_to_id', class_name: 'Status', inverse_of: :replies, optional: true
   belongs_to :reblog, foreign_key: 'reblog_of_id', class_name: 'Status', inverse_of: :reblogs, optional: true
+  belongs_to :quote, foreign_key: 'quote_id', class_name: 'Status', inverse_of: :quoted, optional: true
 
   has_many :favourites, inverse_of: :status, dependent: :destroy
   has_many :bookmarks, inverse_of: :status, dependent: :destroy
@@ -60,6 +62,7 @@ class Status < ApplicationRecord
   has_many :mentions, dependent: :destroy, inverse_of: :status
   has_many :active_mentions, -> { active }, class_name: 'Mention', inverse_of: :status
   has_many :media_attachments, dependent: :nullify
+  has_many :quoted, foreign_key: 'quote_id', class_name: 'Status', inverse_of: :quote
 
   has_and_belongs_to_many :tags
   has_and_belongs_to_many :preview_cards
@@ -160,6 +163,10 @@ class Status < ApplicationRecord
     !reblog_of_id.nil?
   end
 
+  def quote?
+    !quote_id.nil? && quote
+  end
+
   def within_realtime_window?
     created_at >= REAL_TIME_WINDOW.ago
   end
@@ -232,7 +239,7 @@ class Status < ApplicationRecord
     fields  = [spoiler_text, text]
     fields += preloadable_poll.options unless preloadable_poll.nil?
 
-    @emojis = CustomEmoji.from_text(fields.join(' '), account.domain)
+    @emojis = CustomEmoji.from_text(fields.join(' '), account.domain) + (quote? ? CustomEmoji.from_text([quote.spoiler_text, quote.text].join(' '), quote.account.domain) : [])
   end
 
   def mark_for_mass_destruction!
diff --git a/app/serializers/activitypub/note_serializer.rb b/app/serializers/activitypub/note_serializer.rb
index 110621a28bf88ec6498b776368bac696bfa0fdc5..6ee13d09e1691a189ea964a3eac9fafabc22b695 100644
--- a/app/serializers/activitypub/note_serializer.rb
+++ b/app/serializers/activitypub/note_serializer.rb
@@ -7,7 +7,7 @@ class ActivityPub::NoteSerializer < ActivityPub::Serializer
              :in_reply_to, :published, :url,
              :attributed_to, :to, :cc, :sensitive,
              :atom_uri, :in_reply_to_atom_uri,
-             :conversation
+             :conversation, :quote_url
 
   attribute :content
   attribute :content_map, if: :language?
@@ -121,6 +121,10 @@ class ActivityPub::NoteSerializer < ActivityPub::Serializer
     end
   end
 
+  def quote_url
+    object.quote? ? ActivityPub::TagManager.instance.uri_for(object.quote) : nil
+  end
+
   def local?
     object.account.local?
   end
diff --git a/app/serializers/rest/status_serializer.rb b/app/serializers/rest/status_serializer.rb
index 1ed8948da1f2ee9d290c7df36a50e4ad3af4deae..0be7babe3bcff0f4eb3e799702140990a809689d 100644
--- a/app/serializers/rest/status_serializer.rb
+++ b/app/serializers/rest/status_serializer.rb
@@ -18,6 +18,7 @@ class REST::StatusSerializer < ActiveModel::Serializer
   belongs_to :reblog, serializer: REST::StatusSerializer
   belongs_to :application, if: :show_application?
   belongs_to :account, serializer: REST::AccountSerializer
+  belongs_to :quote, serializer: REST::StatusSerializer
 
   has_many :media_attachments, serializer: REST::MediaAttachmentSerializer
   has_many :ordered_mentions, key: :mentions
diff --git a/app/services/fetch_link_card_service.rb b/app/services/fetch_link_card_service.rb
index 91141c1f545f398c0f09e4cf83ea9692e9cc63f2..59ccead789c6e8c347a957a66336689f85b23fbb 100644
--- a/app/services/fetch_link_card_service.rb
+++ b/app/services/fetch_link_card_service.rb
@@ -66,7 +66,7 @@ class FetchLinkCardService < BaseService
       urls = @status.text.scan(URL_PATTERN).map { |array| Addressable::URI.parse(array[0]).normalize }
     else
       html  = Nokogiri::HTML(@status.text)
-      links = html.css('a')
+      links = html.css(':not(.quote-inline) > a')
       urls  = links.map { |a| Addressable::URI.parse(a['href']) unless skip_link?(a) }.compact.map(&:normalize).compact
     end
 
diff --git a/app/services/post_status_service.rb b/app/services/post_status_service.rb
index a0a650d62411735c9da77f9ffc118e31065d61bd..6e78c91f821ac9bd291c0799d43b1a1f9395d066 100644
--- a/app/services/post_status_service.rb
+++ b/app/services/post_status_service.rb
@@ -160,6 +160,7 @@ class PostStatusService < BaseService
       visibility: @visibility,
       language: language_from_option(@options[:language]) || @account.user&.setting_default_language&.presence || LanguageDetector.instance.detect(@text, @account),
       application: @options[:application],
+      quote_id: @options[:quote_id],
     }.compact
   end
 
diff --git a/app/views/statuses/_detailed_status.html.haml b/app/views/statuses/_detailed_status.html.haml
index 021390e474ffda3c9ce6ce6c996109d0b5bb9dd8..8252232f44191b24466ed6b2eeed62e6e45ca175 100644
--- a/app/views/statuses/_detailed_status.html.haml
+++ b/app/views/statuses/_detailed_status.html.haml
@@ -26,6 +26,9 @@
         = react_component :poll, disabled: true, poll: ActiveModelSerializers::SerializableResource.new(status.preloadable_poll, serializer: REST::PollSerializer, scope: current_user, scope_name: :current_user).as_json do
           = render partial: 'statuses/poll', locals: { status: status, poll: status.preloadable_poll, autoplay: autoplay }
 
+  - if status.quote?
+    = render partial: "statuses/quote_status", locals: {status: status.quote}
+
   - if !status.media_attachments.empty?
     - if status.media_attachments.first.video?
       - video = status.media_attachments.first
diff --git a/app/views/statuses/_quote_status.html.haml b/app/views/statuses/_quote_status.html.haml
new file mode 100644
index 0000000000000000000000000000000000000000..7c9cb3c925a9158e74d579f2b017078d52ff36d2
--- /dev/null
+++ b/app/views/statuses/_quote_status.html.haml
@@ -0,0 +1,18 @@
+.status.quote-status{ dataurl:  ActivityPub::TagManager.instance.url_for(status) }
+  = link_to ActivityPub::TagManager.instance.url_for(status.account), class: 'status__display-name p-author h-card', target: stream_link_target, rel: 'noopener' do
+    .status__avatar
+      %div
+        = image_tag status.account.avatar(:original), width: 18, height: 18, alt: '', class: 'account__avatar u-photo'
+    %span.display-name
+      %strong.p-name.emojify= display_name(status.account, custom_emojify: true)
+      %span= acct(status.account)
+
+  .status__content.p-name.emojify<
+    - if status.spoiler_text?
+      %p{ style: 'margin-bottom: 0' }<
+        %span.p-summary> #{Formatter.instance.format_spoiler(status)}&nbsp;
+        %a.status__content__spoiler-link{ href: '#' }= t('statuses.show_more')
+    .e-content{ lang: status.language, style: "display: #{status.spoiler_text? ? 'none' : 'block'}; direction: #{rtl_status?(status) ? 'rtl' : 'ltr'}" }>
+      %p>= Formatter.instance.format_in_quote(status, custom_emojify: true)
+    - unless status.media_attachments.empty?
+      %div{ data: { component: 'MediaGallery', props: Oj.dump(height: 343, sensitive: status.sensitive? && !current_account&.user&.setting_display_sensitive_media, 'autoPlayGif': current_account&.user&.setting_auto_play_gif, media: status.media_attachments.map { |a| ActiveModelSerializers::SerializableResource.new(a, serializer: REST::MediaAttachmentSerializer).as_json }, quote: true) }}
diff --git a/app/views/statuses/_simple_status.html.haml b/app/views/statuses/_simple_status.html.haml
index 66c9412afcc6d77f0c2c8133ba8731ccb296c148..33b5140e39eece65417c58f43716162cad94dba7 100644
--- a/app/views/statuses/_simple_status.html.haml
+++ b/app/views/statuses/_simple_status.html.haml
@@ -30,6 +30,9 @@
         = react_component :poll, disabled: true, poll: ActiveModelSerializers::SerializableResource.new(status.preloadable_poll, serializer: REST::PollSerializer, scope: current_user, scope_name: :current_user).as_json do
           = render partial: 'statuses/poll', locals: { status: status, poll: status.preloadable_poll, autoplay: autoplay }
 
+  - if status.quote?
+    = render partial: "statuses/quote_status", locals: {status: status.quote}
+
   - if !status.media_attachments.empty?
     - if status.media_attachments.first.video?
       - video = status.media_attachments.first
diff --git a/config/brakeman.ignore b/config/brakeman.ignore
index 7e3828f7e017d2e6fba0b18fb1399f0a819a9433..c20766c3a456694a101cc56a2425dafbd20184de 100644
--- a/config/brakeman.ignore
+++ b/config/brakeman.ignore
@@ -125,14 +125,14 @@
       "fingerprint": "67afc0d5f7775fa5bd91d1912e1b5505aeedef61876347546fa20f92fd6915e6",
       "check_name": "Render",
       "message": "Render path contains parameter value",
-      "file": "app/views/stream_entries/embed.html.haml",
+      "file": "app/views/statuses/embed.html.haml",
       "line": 3,
       "link": "https://brakemanscanner.org/docs/warning_types/dynamic_render_path/",
-      "code": "render(action => \"stream_entries/#{Account.find_local!(params[:account_username]).statuses.find(params[:id]).stream_entry.activity_type.downcase}\", { Account.find_local!(params[:account_username]).statuses.find(params[:id]).stream_entry.activity_type.downcase.to_sym => Account.find_local!(params[:account_username]).statuses.find(params[:id]).stream_entry.activity, :centered => true, :autoplay => ActiveModel::Type::Boolean.new.cast(params[:autoplay]) })",
-      "render_path": [{"type":"controller","class":"StatusesController","method":"embed","line":63,"file":"app/controllers/statuses_controller.rb","rendered":{"name":"stream_entries/embed","file":"/home/eugr/Projects/mastodon/app/views/stream_entries/embed.html.haml"}}],
+      "code": "render(action => \"statuses/#{Account.find_local!(params[:account_username]).statuses.find(params[:id]).stream_entry.activity_type.downcase}\", { Account.find_local!(params[:account_username]).statuses.find(params[:id]).stream_entry.activity_type.downcase.to_sym => Account.find_local!(params[:account_username]).statuses.find(params[:id]).stream_entry.activity, :centered => true, :autoplay => ActiveModel::Type::Boolean.new.cast(params[:autoplay]) })",
+      "render_path": [{"type":"controller","class":"StatusesController","method":"embed","line":63,"file":"app/controllers/statuses_controller.rb","rendered":{"name":"statuses/embed","file":"/home/eugr/Projects/mastodon/app/views/statuses/embed.html.haml"}}],
       "location": {
         "type": "template",
-        "template": "stream_entries/embed"
+        "template": "statuses/embed"
       },
       "user_input": "params[:id]",
       "confidence": "Weak",
@@ -282,14 +282,14 @@
       "fingerprint": "fbd0fc59adb5c6d44b60e02debb31d3af11719f534c9881e21435bbff87404d6",
       "check_name": "Render",
       "message": "Render path contains parameter value",
-      "file": "app/views/stream_entries/show.html.haml",
+      "file": "app/views/statuses/show.html.haml",
       "line": 23,
       "link": "https://brakemanscanner.org/docs/warning_types/dynamic_render_path/",
-      "code": "render(partial => \"stream_entries/#{Account.find_local!(params[:account_username]).statuses.find(params[:id]).stream_entry.activity_type.downcase}\", { :locals => ({ Account.find_local!(params[:account_username]).statuses.find(params[:id]).stream_entry.activity_type.downcase.to_sym => Account.find_local!(params[:account_username]).statuses.find(params[:id]).stream_entry.activity, :include_threads => true }) })",
-      "render_path": [{"type":"controller","class":"StatusesController","method":"show","line":34,"file":"app/controllers/statuses_controller.rb","rendered":{"name":"stream_entries/show","file":"/home/eugr/Projects/mastodon/app/views/stream_entries/show.html.haml"}}],
+      "code": "render(partial => \"stat/#{Account.find_local!(params[:account_username]).statuses.find(params[:id]).stream_entry.activity_type.downcase}\", { :locals => ({ Account.find_local!(params[:account_username]).statuses.find(params[:id]).stream_entry.activity_type.downcase.to_sym => Account.find_local!(params[:account_username]).statuses.find(params[:id]).stream_entry.activity, :include_threads => true }) })",
+      "render_path": [{"type":"controller","class":"StatusesController","method":"show","line":34,"file":"app/controllers/statuses_controller.rb","rendered":{"name":"statuses/show","file":"/home/eugr/Projects/mastodon/app/views/statuses/show.html.haml"}}],
       "location": {
         "type": "template",
-        "template": "stream_entries/show"
+        "template": "statuses/show"
       },
       "user_input": "params[:id]",
       "confidence": "Weak",
diff --git a/db/migrate/20180419235016_add_quote_id_to_statuses.rb b/db/migrate/20180419235016_add_quote_id_to_statuses.rb
new file mode 100644
index 0000000000000000000000000000000000000000..d7550b24887964435fdbd97d35582c7bf1e4432a
--- /dev/null
+++ b/db/migrate/20180419235016_add_quote_id_to_statuses.rb
@@ -0,0 +1,5 @@
+class AddQuoteIdToStatuses < ActiveRecord::Migration[5.1]
+  def change
+    add_column :statuses, :quote_id, :bigint, null: true, default: nil
+  end
+end
diff --git a/db/schema.rb b/db/schema.rb
index fc2d3a51188cbeb185e5c7a86d7faabbdb6e374a..0595f4661843fa6394a50f9651b2f359343a6d4b 100644
--- a/db/schema.rb
+++ b/db/schema.rb
@@ -687,6 +687,7 @@ ActiveRecord::Schema.define(version: 2020_01_19_112504) do
     t.bigint "application_id"
     t.bigint "in_reply_to_account_id"
     t.bigint "poll_id"
+    t.bigint "quote_id"
     t.datetime "deleted_at"
     t.index ["account_id", "id", "visibility", "updated_at"], name: "index_statuses_20190820", order: { id: :desc }, where: "(deleted_at IS NULL)"
     t.index ["id", "account_id"], name: "index_statuses_local_20190824", order: { id: :desc }, where: "((local OR (uri IS NULL)) AND (deleted_at IS NULL) AND (visibility = 0) AND (reblog_of_id IS NULL) AND ((NOT reply) OR (in_reply_to_account_id = account_id)))"