diff --git a/changelog.d/quote.add b/changelog.d/quote.add
new file mode 100644
index 0000000000000000000000000000000000000000..b43b6abad343b4729e37db694cc46c4ff81f4c4e
--- /dev/null
+++ b/changelog.d/quote.add
@@ -0,0 +1 @@
+Implement quoting
diff --git a/src/boot/after_store.js b/src/boot/after_store.js
index 9c1f007bd56a85fee466bc30c6187ecb9ec3b8d2..395d483449048940bfd73b96319122c8a3ce3782 100644
--- a/src/boot/after_store.js
+++ b/src/boot/after_store.js
@@ -259,6 +259,7 @@ const getNodeInfo = async ({ store }) => {
       store.dispatch('setInstanceOption', { name: 'editingAvailable', value: features.includes('editing') })
       store.dispatch('setInstanceOption', { name: 'pollLimits', value: metadata.pollLimits })
       store.dispatch('setInstanceOption', { name: 'mailerEnabled', value: metadata.mailerEnabled })
+      store.dispatch('setInstanceOption', { name: 'quotingAvailable', value: features.includes('quote_posting') })
 
       const uploadLimits = metadata.uploadLimits
       store.dispatch('setInstanceOption', { name: 'uploadlimit', value: parseInt(uploadLimits.general) })
diff --git a/src/components/post_status_form/post_status_form.js b/src/components/post_status_form/post_status_form.js
index b75fee6911d6199d14d8d65207e1ec9ec52bb71f..ba49961d78e450b83a23047f7bb4399815952ab2 100644
--- a/src/components/post_status_form/post_status_form.js
+++ b/src/components/post_status_form/post_status_form.js
@@ -156,11 +156,13 @@ const PostStatusForm = {
         poll: this.statusPoll || {},
         mediaDescriptions: this.statusMediaDescriptions || {},
         visibility: this.statusScope || scope,
-        contentType: statusContentType
+        contentType: statusContentType,
+        quoting: false
       }
     }
 
     return {
+      randomSeed: `${Math.random()}`.replace('.', '-'),
       dropFiles: [],
       uploadingFiles: false,
       error: null,
@@ -265,6 +267,30 @@ const PostStatusForm = {
     isEdit () {
       return typeof this.statusId !== 'undefined' && this.statusId.trim() !== ''
     },
+    quotable () {
+      if (!this.$store.state.instance.quotingAvailable) {
+        return false
+      }
+
+      if (!this.replyTo) {
+        return false
+      }
+
+      const repliedStatus = this.$store.state.statuses.allStatusesObject[this.replyTo]
+      if (!repliedStatus) {
+        return false
+      }
+
+      if (repliedStatus.visibility === 'public' ||
+          repliedStatus.visibility === 'unlisted' ||
+          repliedStatus.visibility === 'local') {
+        return true
+      } else if (repliedStatus.visibility === 'private') {
+        return repliedStatus.user.id === this.$store.state.users.currentUser.id
+      }
+
+      return false
+    },
     ...mapGetters(['mergedConfig']),
     ...mapState({
       mobileLayout: state => state.interface.mobileLayout
@@ -292,7 +318,8 @@ const PostStatusForm = {
         visibility: newStatus.visibility,
         contentType: newStatus.contentType,
         poll: {},
-        mediaDescriptions: {}
+        mediaDescriptions: {},
+        quoting: false
       }
       this.pollFormVisible = false
       this.$refs.mediaUpload && this.$refs.mediaUpload.clearFile()
@@ -340,6 +367,8 @@ const PostStatusForm = {
         return
       }
 
+      const replyOrQuoteAttr = newStatus.quoting ? 'quoteId' : 'inReplyToStatusId'
+
       const postingOptions = {
         status: newStatus.status,
         spoilerText: newStatus.spoilerText || null,
@@ -347,7 +376,7 @@ const PostStatusForm = {
         sensitive: newStatus.nsfw,
         media: newStatus.files,
         store: this.$store,
-        inReplyToStatusId: this.replyTo,
+        [replyOrQuoteAttr]: this.replyTo,
         contentType: newStatus.contentType,
         poll,
         idempotencyKey: this.idempotencyKey
@@ -373,6 +402,7 @@ const PostStatusForm = {
       }
       const newStatus = this.newStatus
       this.previewLoading = true
+      const replyOrQuoteAttr = newStatus.quoting ? 'quoteId' : 'inReplyToStatusId'
       statusPoster.postStatus({
         status: newStatus.status,
         spoilerText: newStatus.spoilerText || null,
@@ -380,7 +410,7 @@ const PostStatusForm = {
         sensitive: newStatus.nsfw,
         media: [],
         store: this.$store,
-        inReplyToStatusId: this.replyTo,
+        [replyOrQuoteAttr]: this.replyTo,
         contentType: newStatus.contentType,
         poll: {},
         preview: true
diff --git a/src/components/post_status_form/post_status_form.vue b/src/components/post_status_form/post_status_form.vue
index 86c1f9073f68eeb0cd17763953eedfb4f9faa2dd..9b108a5aa10071c176d038d4af919162ff13c0cd 100644
--- a/src/components/post_status_form/post_status_form.vue
+++ b/src/components/post_status_form/post_status_form.vue
@@ -126,6 +126,36 @@
             class="preview-status"
           />
         </div>
+        <div
+          v-if="quotable"
+          role="radiogroup"
+          class="btn-group reply-or-quote-selector"
+        >
+          <button
+            :id="`reply-or-quote-option-${randomSeed}-reply`"
+            class="btn button-default reply-or-quote-option"
+            :class="{ toggled: !newStatus.quoting }"
+            tabindex="0"
+            role="radio"
+            :aria-labelledby="`reply-or-quote-option-${randomSeed}-reply`"
+            :aria-checked="!newStatus.quoting"
+            @click="newStatus.quoting = false"
+          >
+            {{ $t('post_status.reply_option') }}
+          </button>
+          <button
+            :id="`reply-or-quote-option-${randomSeed}-quote`"
+            class="btn button-default reply-or-quote-option"
+            :class="{ toggled: newStatus.quoting }"
+            tabindex="0"
+            role="radio"
+            :aria-labelledby="`reply-or-quote-option-${randomSeed}-quote`"
+            :aria-checked="newStatus.quoting"
+            @click="newStatus.quoting = true"
+          >
+            {{ $t('post_status.quote_option') }}
+          </button>
+        </div>
         <EmojiInput
           v-if="!disableSubject && (newStatus.spoilerText || alwaysShowSubject)"
           v-model="newStatus.spoilerText"
@@ -420,6 +450,10 @@
     margin: 0;
   }
 
+  .reply-or-quote-selector {
+    margin-bottom: 0.5em;
+  }
+
   .text-format {
     .only-format {
       color: $fallback--faint;
diff --git a/src/components/status/status.js b/src/components/status/status.js
index 9a9bca7aa044034982670887b97a1fee10a5353c..e722a635e82f72a43785770cf92ef51dc7555789 100644
--- a/src/components/status/status.js
+++ b/src/components/status/status.js
@@ -133,6 +133,7 @@ const Status = {
     'showPinned',
     'inProfile',
     'profileUserId',
+    'inQuote',
 
     'simpleTree',
     'controlledThreadDisplayStatus',
@@ -159,7 +160,8 @@ const Status = {
       uncontrolledMediaPlaying: [],
       suspendable: true,
       error: null,
-      headTailLinks: null
+      headTailLinks: null,
+      displayQuote: !this.inQuote
     }
   },
   computed: {
@@ -401,6 +403,18 @@ const Status = {
     },
     editingAvailable () {
       return this.$store.state.instance.editingAvailable
+    },
+    hasVisibleQuote () {
+      return this.status.quote_url && this.status.quote_visible
+    },
+    hasInvisibleQuote () {
+      return this.status.quote_url && !this.status.quote_visible
+    },
+    quotedStatus () {
+      return this.status.quote_id ? this.$store.state.statuses.allStatusesObject[this.status.quote_id] : undefined
+    },
+    shouldDisplayQuote () {
+      return this.quotedStatus && this.displayQuote
     }
   },
   methods: {
@@ -469,6 +483,18 @@ const Status = {
           window.scrollBy(0, rect.bottom - window.innerHeight + 50)
         }
       }
+    },
+    toggleDisplayQuote () {
+      if (this.shouldDisplayQuote) {
+        this.displayQuote = false
+      } else if (!this.quotedStatus) {
+        this.$store.dispatch('fetchStatus', this.status.quote_id)
+          .then(() => {
+            this.displayQuote = true
+          })
+      } else {
+        this.displayQuote = true
+      }
     }
   },
   watch: {
diff --git a/src/components/status/status.scss b/src/components/status/status.scss
index 448128675b35415a7caf6bf7eb70bcaa1b340dc7..760c6ac1af167b90ae70aee3cca8963e8883217e 100644
--- a/src/components/status/status.scss
+++ b/src/components/status/status.scss
@@ -422,4 +422,22 @@
       }
     }
   }
+
+  .quoted-status {
+    margin-top: 0.5em;
+    border: 1px solid var(--border, $fallback--border);
+    border-radius: var(--attachmentRadius, $fallback--attachmentRadius);
+
+    &.-unavailable-prompt {
+      padding: 0.5em;
+    }
+  }
+
+  .display-quoted-status-button {
+    margin: 0.5em;
+
+    &-icon {
+      color: inherit;
+    }
+  }
 }
diff --git a/src/components/status/status.vue b/src/components/status/status.vue
index 35b153627a0a17bd675d71fffdd4f3f2c468a4da..c49a9e7b4f51a51b1f9e783fa7dc9a451a976d06 100644
--- a/src/components/status/status.vue
+++ b/src/components/status/status.vue
@@ -364,6 +364,45 @@
             @parseReady="setHeadTailLinks"
           />
 
+          <article
+            v-if="hasVisibleQuote"
+            class="quoted-status"
+          >
+            <button
+              class="button-unstyled -link display-quoted-status-button"
+              :aria-expanded="shouldDisplayQuote"
+              @click="toggleDisplayQuote"
+            >
+              {{ shouldDisplayQuote ? $t('status.hide_quote') : $t('status.display_quote') }}
+              <FAIcon
+                class="display-quoted-status-button-icon"
+                :icon="shouldDisplayQuote ? 'chevron-up' : 'chevron-down'"
+              />
+            </button>
+            <Status
+              v-if="shouldDisplayQuote"
+              :statusoid="quotedStatus"
+              :in-quote="true"
+            />
+          </article>
+          <p
+            v-else-if="hasInvisibleQuote"
+            class="quoted-status -unavailable-prompt"
+          >
+            <i18n-t keypath="status.invisible_quote">
+              <template #link>
+                <bdi>
+                  <a
+                    :href="status.quote_url"
+                    target="_blank"
+                  >
+                    {{ status.quote_url }}
+                  </a>
+                </bdi>
+              </template>
+            </i18n-t>
+          </p>
+
           <div
             v-if="inConversation && !isPreview && replies && replies.length"
             class="replies"
diff --git a/src/i18n/en.json b/src/i18n/en.json
index a7ab451f4c6c9d0a249ac7f7f8b4c3481fffb2f6..2358a4cef204485f374c0960c50300bfcb85e5aa 100644
--- a/src/i18n/en.json
+++ b/src/i18n/en.json
@@ -261,6 +261,8 @@
   "post_status": {
     "edit_status": "Edit status",
     "new_status": "Post new status",
+    "reply_option": "Reply to this status",
+    "quote_option": "Quote this status",
     "account_not_locked_warning": "Your account is not {0}. Anyone can follow you to view your follower-only posts.",
     "account_not_locked_warning_link": "locked",
     "attachments_sensitive": "Mark attachments as sensitive",
@@ -1028,7 +1030,10 @@
     "show_all_conversation": "Show full conversation ({numStatus} other status) | Show full conversation ({numStatus} other statuses)",
     "show_only_conversation_under_this": "Only show replies to this status",
     "status_history": "Status history",
-    "reaction_count_label": "{num} person reacted | {num} people reacted"
+    "reaction_count_label": "{num} person reacted | {num} people reacted",
+    "hide_quote": "Hide the quoted status",
+    "display_quote": "Display the quoted status",
+    "invisible_quote": "Quoted status unavailable: {link}"
   },
   "user_card": {
     "approve": "Approve",
diff --git a/src/modules/instance.js b/src/modules/instance.js
index bb0292da0bdef1396abf2f24392f65ad4bc50ace..1ee64552e69faa8ad6cc2c3f567763ac186a0981 100644
--- a/src/modules/instance.js
+++ b/src/modules/instance.js
@@ -128,6 +128,7 @@ const defaultState = {
   mediaProxyAvailable: false,
   suggestionsEnabled: false,
   suggestionsWeb: '',
+  quotingAvailable: false,
 
   // Html stuff
   instanceSpecificPanelContent: '',
diff --git a/src/modules/statuses.js b/src/modules/statuses.js
index ed21a7309f68a224e85e7510845ded42f9b01008..186bba3c96d35e4c2ac528e5bc8f4ba188b9ddda 100644
--- a/src/modules/statuses.js
+++ b/src/modules/statuses.js
@@ -229,6 +229,10 @@ const addNewStatuses = (state, { statuses, showImmediately = false, timeline, us
       timelineObject.newStatusCount += 1
     }
 
+    if (status.quote) {
+      addStatus(status.quote, /* showImmediately = */ false, /* addToTimeline = */ false)
+    }
+
     return status
   }
 
diff --git a/src/services/api/api.service.js b/src/services/api/api.service.js
index ac715678b849c1b00c5162acf4c9675a29198355..c6bca10b48722ed8f991be3b7c5af52995eb0e11 100644
--- a/src/services/api/api.service.js
+++ b/src/services/api/api.service.js
@@ -827,6 +827,7 @@ const postStatus = ({
   poll,
   mediaIds = [],
   inReplyToStatusId,
+  quoteId,
   contentType,
   preview,
   idempotencyKey
@@ -859,6 +860,9 @@ const postStatus = ({
   if (inReplyToStatusId) {
     form.append('in_reply_to_id', inReplyToStatusId)
   }
+  if (quoteId) {
+    form.append('quote_id', quoteId)
+  }
   if (preview) {
     form.append('preview', 'true')
   }
diff --git a/src/services/entity_normalizer/entity_normalizer.service.js b/src/services/entity_normalizer/entity_normalizer.service.js
index adefc5a555f174f791243d035d778b0fa2142c40..610ba1ab94b21afdcaeddc158343e423e15eb2b8 100644
--- a/src/services/entity_normalizer/entity_normalizer.service.js
+++ b/src/services/entity_normalizer/entity_normalizer.service.js
@@ -325,6 +325,10 @@ export const parseStatus = (data) => {
       output.thread_muted = pleroma.thread_muted
       output.emoji_reactions = pleroma.emoji_reactions
       output.parent_visible = pleroma.parent_visible === undefined ? true : pleroma.parent_visible
+      output.quote = pleroma.quote ? parseStatus(pleroma.quote) : undefined
+      output.quote_id = pleroma.quote_id ? pleroma.quote_id : (output.quote ? output.quote.id : undefined)
+      output.quote_url = pleroma.quote_url
+      output.quote_visible = pleroma.quote_visible
     } else {
       output.text = data.content
       output.summary = data.spoiler_text
diff --git a/src/services/status_poster/status_poster.service.js b/src/services/status_poster/status_poster.service.js
index 1eb10bb61dc476b57570c46224d3cfc30d9e337c..aaef5a7a837d9b4f613bdbd97ad43c95deef4642 100644
--- a/src/services/status_poster/status_poster.service.js
+++ b/src/services/status_poster/status_poster.service.js
@@ -10,6 +10,7 @@ const postStatus = ({
   poll,
   media = [],
   inReplyToStatusId = undefined,
+  quoteId = undefined,
   contentType = 'text/plain',
   preview = false,
   idempotencyKey = ''
@@ -24,6 +25,7 @@ const postStatus = ({
     sensitive,
     mediaIds,
     inReplyToStatusId,
+    quoteId,
     contentType,
     poll,
     preview,