diff --git a/CHANGELOG.md b/CHANGELOG.md
index ae54025a39b9cb96e3596b718fb51bcce58c5ff5..d7cc6994cd06329564f21230c99f9838fe88d7c5 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -36,19 +36,21 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/).
 - Attachments are truncated just like post contents
 - Media modal now also displays description and counter position in gallery (i.e. 1/5)
 - Ability to rearrange order of attachments when uploading
+- Enabled users to zoom and pan images in media viewer with mouse and touch
+
 
 ## [2.4.2] - 2022-01-09
-### Added 
+### Added
 - Added Apply and Reset buttons to the bottom of theme tab to minimize UI travel
 - Implemented user option to always show floating New Post button (normally mobile-only)
-- Display reasons for instance specific policies 
+- Display reasons for instance specific policies
 - Added functionality to cancel follow request
 
 ### Fixed
 - Fixed link to external profile not working on user profiles
-- Fixed mobile shoutbox display 
+- Fixed mobile shoutbox display
 - Fixed favicon badge not working in Chrome
-- Escape html more properly in subject/display name 
+- Escape html more properly in subject/display name
 
 
 ## [2.4.0] - 2021-08-08
diff --git a/package.json b/package.json
index 5dd0e067a85d10d018e7e6b6ef3d64ae214d5a21..2f3982e9eedc168b2e4f37fcdf6f52d8efa040e5 100644
--- a/package.json
+++ b/package.json
@@ -22,6 +22,7 @@
     "@fortawesome/free-regular-svg-icons": "5.15.1",
     "@fortawesome/free-solid-svg-icons": "5.15.1",
     "@fortawesome/vue-fontawesome": "2.0.0",
+    "@kazvmoe-infra/pinch-zoom-element": "^1.2.0",
     "body-scroll-lock": "2.6.4",
     "chromatism": "3.0.0",
     "cropperjs": "1.4.3",
diff --git a/src/_variables.scss b/src/_variables.scss
index 9004d551a4c8a30414af50aa6ee3609b99971198..099d36064a03c75c7f4b3847a30c589c80db11b9 100644
--- a/src/_variables.scss
+++ b/src/_variables.scss
@@ -30,3 +30,5 @@ $fallback--attachmentRadius: 10px;
 $fallback--chatMessageRadius: 10px;
 
 $fallback--buttonShadow: 0px 0px 2px 0px rgba(0, 0, 0, 1), 0px 1px 0px 0px rgba(255, 255, 255, 0.2) inset, 0px -1px 0px 0px rgba(0, 0, 0, 0.2) inset;
+
+$status-margin: 0.75em;
diff --git a/src/components/conversation/conversation.js b/src/components/conversation/conversation.js
index 069c0b4077f9e71815b90950ba89851d0106d75e..2ef2977aff51e2c8b4c5e6b2f718d98cbef7c30e 100644
--- a/src/components/conversation/conversation.js
+++ b/src/components/conversation/conversation.js
@@ -1,5 +1,19 @@
 import { reduce, filter, findIndex, clone, get } from 'lodash'
 import Status from '../status/status.vue'
+import ThreadTree from '../thread_tree/thread_tree.vue'
+
+import { library } from '@fortawesome/fontawesome-svg-core'
+import {
+  faAngleDoubleDown,
+  faAngleDoubleLeft,
+  faChevronLeft
+} from '@fortawesome/free-solid-svg-icons'
+
+library.add(
+  faAngleDoubleDown,
+  faAngleDoubleLeft,
+  faChevronLeft
+)
 
 const sortById = (a, b) => {
   const idA = a.type === 'retweet' ? a.retweeted_status.id : a.id
@@ -35,7 +49,10 @@ const conversation = {
   data () {
     return {
       highlight: null,
-      expanded: false
+      expanded: false,
+      threadDisplayStatusObject: {}, // id => 'showing' | 'hidden'
+      statusContentPropertiesObject: {},
+      inlineDivePosition: null
     }
   },
   props: [
@@ -53,13 +70,51 @@ const conversation = {
     }
   },
   computed: {
-    hideStatus () {
+    maxDepthToShowByDefault () {
+      // maxDepthInThread = max number of depths that is *visible*
+      // since our depth starts with 0 and "showing" means "showing children"
+      // there is a -2 here
+      const maxDepth = this.$store.getters.mergedConfig.maxDepthInThread - 2
+      return maxDepth >= 1 ? maxDepth : 1
+    },
+    displayStyle () {
+      return this.$store.getters.mergedConfig.conversationDisplay
+    },
+    isTreeView () {
+      return !this.isLinearView
+    },
+    treeViewIsSimple () {
+      return !this.$store.getters.mergedConfig.conversationTreeAdvanced
+    },
+    isLinearView () {
+      return this.displayStyle === 'linear'
+    },
+    shouldFadeAncestors () {
+      return this.$store.getters.mergedConfig.conversationTreeFadeAncestors
+    },
+    otherRepliesButtonPosition () {
+      return this.$store.getters.mergedConfig.conversationOtherRepliesButton
+    },
+    showOtherRepliesButtonBelowStatus () {
+      return this.otherRepliesButtonPosition === 'below'
+    },
+    showOtherRepliesButtonInsideStatus () {
+      return this.otherRepliesButtonPosition === 'inside'
+    },
+    suspendable () {
+      if (this.isTreeView) {
+        return Object.entries(this.statusContentProperties)
+          .every(([k, prop]) => !prop.replying && prop.mediaPlaying.length === 0)
+      }
       if (this.$refs.statusComponent && this.$refs.statusComponent[0]) {
-        return this.virtualHidden && this.$refs.statusComponent[0].suspendable
+        return this.$refs.statusComponent.every(s => s.suspendable)
       } else {
-        return this.virtualHidden
+        return true
       }
     },
+    hideStatus () {
+      return this.virtualHidden && this.suspendable
+    },
     status () {
       return this.$store.state.statuses.allStatusesObject[this.statusId]
     },
@@ -90,6 +145,121 @@ const conversation = {
 
       return sortAndFilterConversation(conversation, this.status)
     },
+    statusMap () {
+      return this.conversation.reduce((res, s) => {
+        res[s.id] = s
+        return res
+      }, {})
+    },
+    threadTree () {
+      const reverseLookupTable = this.conversation.reduce((table, status, index) => {
+        table[status.id] = index
+        return table
+      }, {})
+
+      const threads = this.conversation.reduce((a, cur) => {
+        const id = cur.id
+        a.forest[id] = this.getReplies(id)
+          .map(s => s.id)
+
+        return a
+      }, {
+        forest: {}
+      })
+
+      const walk = (forest, topLevel, depth = 0, processed = {}) => topLevel.map(id => {
+        if (processed[id]) {
+          return []
+        }
+
+        processed[id] = true
+        return [{
+          status: this.conversation[reverseLookupTable[id]],
+          id,
+          depth
+        }, walk(forest, forest[id], depth + 1, processed)].reduce((a, b) => a.concat(b), [])
+      }).reduce((a, b) => a.concat(b), [])
+
+      const linearized = walk(threads.forest, this.topLevel.map(k => k.id))
+
+      return linearized
+    },
+    replyIds () {
+      return this.conversation.map(k => k.id)
+        .reduce((res, id) => {
+          res[id] = (this.replies[id] || []).map(k => k.id)
+          return res
+        }, {})
+    },
+    totalReplyCount () {
+      const sizes = {}
+      const subTreeSizeFor = (id) => {
+        if (sizes[id]) {
+          return sizes[id]
+        }
+        sizes[id] = 1 + this.replyIds[id].map(cid => subTreeSizeFor(cid)).reduce((a, b) => a + b, 0)
+        return sizes[id]
+      }
+      this.conversation.map(k => k.id).map(subTreeSizeFor)
+      return Object.keys(sizes).reduce((res, id) => {
+        res[id] = sizes[id] - 1 // exclude itself
+        return res
+      }, {})
+    },
+    totalReplyDepth () {
+      const depths = {}
+      const subTreeDepthFor = (id) => {
+        if (depths[id]) {
+          return depths[id]
+        }
+        depths[id] = 1 + this.replyIds[id].map(cid => subTreeDepthFor(cid)).reduce((a, b) => a > b ? a : b, 0)
+        return depths[id]
+      }
+      this.conversation.map(k => k.id).map(subTreeDepthFor)
+      return Object.keys(depths).reduce((res, id) => {
+        res[id] = depths[id] - 1 // exclude itself
+        return res
+      }, {})
+    },
+    depths () {
+      return this.threadTree.reduce((a, k) => {
+        a[k.id] = k.depth
+        return a
+      }, {})
+    },
+    topLevel () {
+      const topLevel = this.conversation.reduce((tl, cur) =>
+        tl.filter(k => this.getReplies(cur.id).map(v => v.id).indexOf(k.id) === -1), this.conversation)
+      return topLevel
+    },
+    otherTopLevelCount () {
+      return this.topLevel.length - 1
+    },
+    showingTopLevel () {
+      if (this.canDive && this.diveRoot) {
+        return [this.statusMap[this.diveRoot]]
+      }
+      return this.topLevel
+    },
+    diveRoot () {
+      const statusId = this.inlineDivePosition || this.statusId
+      const isTopLevel = !this.parentOf(statusId)
+      return isTopLevel ? null : statusId
+    },
+    diveDepth () {
+      return this.canDive && this.diveRoot ? this.depths[this.diveRoot] : 0
+    },
+    diveMode () {
+      return this.canDive && !!this.diveRoot
+    },
+    shouldShowAllConversationButton () {
+      // The "show all conversation" button tells the user that there exist
+      // other toplevel statuses, so do not show it if there is only a single root
+      return this.isTreeView && this.isExpanded && this.diveMode && this.topLevel.length > 1
+    },
+    shouldShowAncestors () {
+      return this.isTreeView && this.isExpanded && this.ancestorsOf(this.diveRoot).length
+    },
     replies () {
       let i = 1
       // eslint-disable-next-line camelcase
@@ -109,15 +279,71 @@ const conversation = {
       }, {})
     },
     isExpanded () {
-      return this.expanded || this.isPage
+      return !!(this.expanded || this.isPage)
     },
     hiddenStyle () {
       const height = (this.status && this.status.virtualHeight) || '120px'
       return this.virtualHidden ? { height } : {}
+    },
+    threadDisplayStatus () {
+      return this.conversation.reduce((a, k) => {
+        const id = k.id
+        const depth = this.depths[id]
+        const status = (() => {
+          if (this.threadDisplayStatusObject[id]) {
+            return this.threadDisplayStatusObject[id]
+          }
+          if ((depth - this.diveDepth) <= this.maxDepthToShowByDefault) {
+            return 'showing'
+          } else {
+            return 'hidden'
+          }
+        })()
+
+        a[id] = status
+        return a
+      }, {})
+    },
+    statusContentProperties () {
+      return this.conversation.reduce((a, k) => {
+        const id = k.id
+        const props = (() => {
+          const def = {
+            showingTall: false,
+            expandingSubject: false,
+            showingLongSubject: false,
+            isReplying: false,
+            mediaPlaying: []
+          }
+
+          if (this.statusContentPropertiesObject[id]) {
+            return {
+              ...def,
+              ...this.statusContentPropertiesObject[id]
+            }
+          }
+          return def
+        })()
+
+        a[id] = props
+        return a
+      }, {})
+    },
+    canDive () {
+      return this.isTreeView && this.isExpanded
+    },
+    focused () {
+      return (id) => {
+        return (this.isExpanded) && id === this.highlight
+      }
+    },
+    maybeHighlight () {
+      return this.isExpanded ? this.highlight : null
     }
   },
   components: {
-    Status
+    Status,
+    ThreadTree
   },
   watch: {
     statusId (newVal, oldVal) {
@@ -132,6 +358,8 @@ const conversation = {
     expanded (value) {
       if (value) {
         this.fetchConversation()
+      } else {
+        this.resetDisplayState()
       }
     },
     virtualHidden (value) {
@@ -161,8 +389,8 @@ const conversation = {
     getReplies (id) {
       return this.replies[id] || []
     },
-    focused (id) {
-      return (this.isExpanded) && id === this.statusId
+    getHighlight () {
+      return this.isExpanded ? this.highlight : null
     },
     setHighlight (id) {
       if (!id) return
@@ -170,15 +398,139 @@ const conversation = {
       this.$store.dispatch('fetchFavsAndRepeats', id)
       this.$store.dispatch('fetchEmojiReactionsBy', id)
     },
-    getHighlight () {
-      return this.isExpanded ? this.highlight : null
-    },
     toggleExpanded () {
       this.expanded = !this.expanded
     },
     getConversationId (statusId) {
       const status = this.$store.state.statuses.allStatusesObject[statusId]
       return get(status, 'retweeted_status.statusnet_conversation_id', get(status, 'statusnet_conversation_id'))
+    },
+    setThreadDisplay (id, nextStatus) {
+      this.threadDisplayStatusObject = {
+        ...this.threadDisplayStatusObject,
+        [id]: nextStatus
+      }
+    },
+    toggleThreadDisplay (id) {
+      const curStatus = this.threadDisplayStatus[id]
+      const nextStatus = curStatus === 'showing' ? 'hidden' : 'showing'
+      this.setThreadDisplay(id, nextStatus)
+    },
+    setThreadDisplayRecursively (id, nextStatus) {
+      this.setThreadDisplay(id, nextStatus)
+      this.getReplies(id).map(k => k.id).map(id => this.setThreadDisplayRecursively(id, nextStatus))
+    },
+    showThreadRecursively (id) {
+      this.setThreadDisplayRecursively(id, 'showing')
+    },
+    setStatusContentProperty (id, name, value) {
+      this.statusContentPropertiesObject = {
+        ...this.statusContentPropertiesObject,
+        [id]: {
+          ...this.statusContentPropertiesObject[id],
+          [name]: value
+        }
+      }
+    },
+    toggleStatusContentProperty (id, name) {
+      this.setStatusContentProperty(id, name, !this.statusContentProperties[id][name])
+    },
+    leastVisibleAncestor (id) {
+      let cur = id
+      let parent = this.parentOf(cur)
+      while (cur) {
+        // if the parent is showing it means cur is visible
+        if (this.threadDisplayStatus[parent] === 'showing') {
+          return cur
+        }
+        parent = this.parentOf(parent)
+        cur = this.parentOf(cur)
+      }
+      // nothing found, fall back to toplevel
+      return this.topLevel[0] ? this.topLevel[0].id : undefined
+    },
+    diveIntoStatus (id, preventScroll) {
+      this.tryScrollTo(id)
+    },
+    diveToTopLevel () {
+      this.tryScrollTo(this.topLevelAncestorOrSelfId(this.diveRoot) || this.topLevel[0].id)
+    },
+    // only used when we are not on a page
+    undive () {
+      this.inlineDivePosition = null
+      this.setHighlight(this.statusId)
+    },
+    tryScrollTo (id) {
+      if (!id) {
+        return
+      }
+      if (this.isPage) {
+        // set statusId
+        this.$router.push({ name: 'conversation', params: { id } })
+      } else {
+        this.inlineDivePosition = id
+      }
+      // Because the conversation can be unmounted when out of sight
+      // and mounted again when it comes into sight,
+      // the `mounted` or `created` function in `status` should not
+      // contain scrolling calls, as we do not want the page to jump
+      // when we scroll with an expanded conversation.
+      //
+      // Now the method is to rely solely on the `highlight` watcher
+      // in `status` components.
+      // In linear views, all statuses are rendered at all times, but
+      // in tree views, it is possible that a change in active status
+      // removes and adds status components (e.g. an originally child
+      // status becomes an ancestor status, and thus they will be
+      // different).
+      // Here, let the components be rendered first, in order to trigger
+      // the `highlight` watcher.
+      this.$nextTick(() => {
+        this.setHighlight(id)
+      })
+    },
+    goToCurrent () {
+      this.tryScrollTo(this.diveRoot || this.topLevel[0].id)
+    },
+    statusById (id) {
+      return this.statusMap[id]
+    },
+    parentOf (id) {
+      const status = this.statusById(id)
+      if (!status) {
+        return undefined
+      }
+      const { in_reply_to_status_id: parentId } = status
+      if (!this.statusMap[parentId]) {
+        return undefined
+      }
+      return parentId
+    },
+    parentOrSelf (id) {
+      return this.parentOf(id) || id
+    },
+    // Ancestors of some status, from top to bottom
+    ancestorsOf (id) {
+      const ancestors = []
+      let cur = this.parentOf(id)
+      while (cur) {
+        ancestors.unshift(this.statusMap[cur])
+        cur = this.parentOf(cur)
+      }
+      return ancestors
+    },
+    topLevelAncestorOrSelfId (id) {
+      let cur = id
+      let parent = this.parentOf(id)
+      while (parent) {
+        cur = this.parentOf(cur)
+        parent = this.parentOf(parent)
+      }
+      return cur
+    },
+    resetDisplayState () {
+      this.undive()
+      this.threadDisplayStatusObject = {}
     }
   }
 }
diff --git a/src/components/conversation/conversation.vue b/src/components/conversation/conversation.vue
index 3fb26d9239b25f408c71bf4d752c3ff3d33e2327..7628ceaa51d3c4795f7342d5d573dd8fcc3bfce1 100644
--- a/src/components/conversation/conversation.vue
+++ b/src/components/conversation/conversation.vue
@@ -18,24 +18,168 @@
         {{ $t('timeline.collapse') }}
       </button>
     </div>
-    <status
-      v-for="status in conversation"
-      :key="status.id"
-      ref="statusComponent"
-      :inline-expanded="collapsable && isExpanded"
-      :statusoid="status"
-      :expandable="!isExpanded"
-      :show-pinned="pinnedStatusIdsObject && pinnedStatusIdsObject[status.id]"
-      :focused="focused(status.id)"
-      :in-conversation="isExpanded"
-      :highlight="getHighlight()"
-      :replies="getReplies(status.id)"
-      :in-profile="inProfile"
-      :profile-user-id="profileUserId"
-      class="conversation-status status-fadein panel-body"
-      @goto="setHighlight"
-      @toggleExpanded="toggleExpanded"
-    />
+    <div class="conversation-body panel-body">
+      <div
+        v-if="isTreeView"
+        class="thread-body"
+      >
+        <div
+          v-if="shouldShowAllConversationButton"
+          class="conversation-dive-to-top-level-box"
+        >
+          <i18n
+            path="status.show_all_conversation_with_icon"
+            tag="button"
+            class="button-unstyled -link"
+            @click.prevent="diveToTopLevel"
+          >
+            <FAIcon
+              place="icon"
+              icon="angle-double-left"
+            />
+            <span place="text">
+              {{ $tc('status.show_all_conversation', otherTopLevelCount, { numStatus: otherTopLevelCount }) }}
+            </span>
+          </i18n>
+        </div>
+        <div
+          v-if="shouldShowAncestors"
+          class="thread-ancestors"
+        >
+          <div
+            v-for="status in ancestorsOf(diveRoot)"
+            :key="status.id"
+            class="thread-ancestor"
+            :class="{'thread-ancestor-has-other-replies': getReplies(status.id).length > 1, '-faded': shouldFadeAncestors}"
+          >
+            <status
+              ref="statusComponent"
+              :inline-expanded="collapsable && isExpanded"
+              :statusoid="status"
+              :expandable="!isExpanded"
+              :show-pinned="pinnedStatusIdsObject && pinnedStatusIdsObject[status.id]"
+              :focused="focused(status.id)"
+              :in-conversation="isExpanded"
+              :highlight="getHighlight()"
+              :replies="getReplies(status.id)"
+              :in-profile="inProfile"
+              :profile-user-id="profileUserId"
+              class="conversation-status status-fadein panel-body"
+
+              :simple-tree="treeViewIsSimple"
+              :toggle-thread-display="toggleThreadDisplay"
+              :thread-display-status="threadDisplayStatus"
+              :show-thread-recursively="showThreadRecursively"
+              :total-reply-count="totalReplyCount"
+              :total-reply-depth="totalReplyDepth"
+              :show-other-replies-as-button="showOtherRepliesButtonInsideStatus"
+              :dive="() => diveIntoStatus(status.id)"
+
+              :controlled-showing-tall="statusContentProperties[status.id].showingTall"
+              :controlled-expanding-subject="statusContentProperties[status.id].expandingSubject"
+              :controlled-showing-long-subject="statusContentProperties[status.id].showingLongSubject"
+              :controlled-replying="statusContentProperties[status.id].replying"
+              :controlled-media-playing="statusContentProperties[status.id].mediaPlaying"
+              :controlled-toggle-showing-tall="() => toggleStatusContentProperty(status.id, 'showingTall')"
+              :controlled-toggle-expanding-subject="() => toggleStatusContentProperty(status.id, 'expandingSubject')"
+              :controlled-toggle-showing-long-subject="() => toggleStatusContentProperty(status.id, 'showingLongSubject')"
+              :controlled-toggle-replying="() => toggleStatusContentProperty(status.id, 'replying')"
+              :controlled-set-media-playing="(newVal) => toggleStatusContentProperty(status.id, 'mediaPlaying', newVal)"
+
+              @goto="setHighlight"
+              @toggleExpanded="toggleExpanded"
+            />
+            <div
+              v-if="showOtherRepliesButtonBelowStatus && getReplies(status.id).length > 1"
+              class="thread-ancestor-dive-box"
+            >
+              <div
+                class="thread-ancestor-dive-box-inner"
+              >
+                <i18n
+                  tag="button"
+                  path="status.ancestor_follow_with_icon"
+                  class="button-unstyled -link thread-tree-show-replies-button"
+                  @click.prevent="diveIntoStatus(status.id)"
+                >
+                  <FAIcon
+                    place="icon"
+                    icon="angle-double-right"
+                  />
+                  <span place="text">
+                    {{ $tc('status.ancestor_follow', getReplies(status.id).length - 1, { numReplies: getReplies(status.id).length - 1 }) }}
+                  </span>
+                </i18n>
+              </div>
+            </div>
+          </div>
+        </div>
+        <thread-tree
+          v-for="status in showingTopLevel"
+          :key="status.id"
+          ref="statusComponent"
+          :depth="0"
+
+          :status="status"
+          :in-profile="inProfile"
+          :conversation="conversation"
+          :collapsable="collapsable"
+          :is-expanded="isExpanded"
+          :pinned-status-ids-object="pinnedStatusIdsObject"
+          :profile-user-id="profileUserId"
+
+          :focused="focused"
+          :get-replies="getReplies"
+          :highlight="maybeHighlight"
+          :set-highlight="setHighlight"
+          :toggle-expanded="toggleExpanded"
+
+          :simple="treeViewIsSimple"
+          :toggle-thread-display="toggleThreadDisplay"
+          :thread-display-status="threadDisplayStatus"
+          :show-thread-recursively="showThreadRecursively"
+          :total-reply-count="totalReplyCount"
+          :total-reply-depth="totalReplyDepth"
+          :status-content-properties="statusContentProperties"
+          :set-status-content-property="setStatusContentProperty"
+          :toggle-status-content-property="toggleStatusContentProperty"
+          :dive="canDive ? diveIntoStatus : undefined"
+        />
+      </div>
+      <div
+        v-if="isLinearView"
+        class="thread-body"
+      >
+        <status
+          v-for="status in conversation"
+          :key="status.id"
+          ref="statusComponent"
+          :inline-expanded="collapsable && isExpanded"
+          :statusoid="status"
+          :expandable="!isExpanded"
+          :show-pinned="pinnedStatusIdsObject && pinnedStatusIdsObject[status.id]"
+          :focused="focused(status.id)"
+          :in-conversation="isExpanded"
+          :highlight="getHighlight()"
+          :replies="getReplies(status.id)"
+          :in-profile="inProfile"
+          :profile-user-id="profileUserId"
+          class="conversation-status status-fadein panel-body"
+
+          :toggle-thread-display="toggleThreadDisplay"
+          :thread-display-status="threadDisplayStatus"
+          :show-thread-recursively="showThreadRecursively"
+          :total-reply-count="totalReplyCount"
+          :total-reply-depth="totalReplyDepth"
+          :status-content-properties="statusContentProperties"
+          :set-status-content-property="setStatusContentProperty"
+          :toggle-status-content-property="toggleStatusContentProperty"
+
+          @goto="setHighlight"
+          @toggleExpanded="toggleExpanded"
+        />
+      </div>
+    </div>
   </div>
   <div
     v-else
@@ -49,19 +193,74 @@
 @import '../../_variables.scss';
 
 .Conversation {
-  .conversation-status {
+  .conversation-dive-to-top-level-box {
+    padding: var(--status-margin, $status-margin);
     border-bottom-width: 1px;
     border-bottom-style: solid;
     border-bottom-color: var(--border, $fallback--border);
     border-radius: 0;
+    /* Make the button stretch along the whole row */
+    display: flex;
+    align-items: stretch;
+    flex-direction: column;
+  }
+
+  .thread-ancestors {
+    margin-left: var(--status-margin, $status-margin);
+    border-left: 2px solid var(--border, $fallback--border);
   }
 
-  &.-expanded {
-    .conversation-status:last-child {
-      border-bottom: none;
-      border-radius: 0 0 $fallback--panelRadius $fallback--panelRadius;
-      border-radius: 0 0 var(--panelRadius, $fallback--panelRadius) var(--panelRadius, $fallback--panelRadius);
+  .thread-ancestor.-faded .StatusContent {
+    --link: var(--faintLink);
+    --text: var(--faint);
+    color: var(--text);
+  }
+  .thread-ancestor-dive-box {
+    padding-left: var(--status-margin, $status-margin);
+    border-bottom-width: 1px;
+    border-bottom-style: solid;
+    border-bottom-color: var(--border, $fallback--border);
+    border-radius: 0;
+    /* Make the button stretch along the whole row */
+    &, &-inner {
+      display: flex;
+      align-items: stretch;
+      flex-direction: column;
     }
   }
+  .thread-ancestor-dive-box-inner {
+    padding: var(--status-margin, $status-margin);
+  }
+
+  .conversation-status {
+    border-bottom-width: 1px;
+    border-bottom-style: solid;
+    border-bottom-color: var(--border, $fallback--border);
+    border-radius: 0;
+  }
+
+  .thread-ancestor-has-other-replies .conversation-status,
+  .thread-ancestor:last-child .conversation-status,
+  .thread-ancestor:last-child .thread-ancestor-dive-box,
+  &.-expanded .thread-tree .conversation-status {
+    border-bottom: none;
+  }
+
+  .thread-ancestors + .thread-tree > .conversation-status {
+    border-top-width: 1px;
+    border-top-style: solid;
+    border-top-color: var(--border, $fallback--border);
+  }
+
+  /* expanded conversation in timeline */
+  &.status-fadein.-expanded .thread-body {
+    border-left-width: 4px;
+    border-left-style: solid;
+    border-left-color: $fallback--cRed;
+    border-left-color: var(--cRed, $fallback--cRed);
+    border-radius: 0 0 $fallback--panelRadius $fallback--panelRadius;
+    border-radius: 0 0 var(--panelRadius, $fallback--panelRadius) var(--panelRadius, $fallback--panelRadius);
+    border-bottom: 1px solid var(--border, $fallback--border);
+  }
 }
 </style>
diff --git a/src/components/media_modal/media_modal.js b/src/components/media_modal/media_modal.js
index b8bce73063b6604c796e69c9430cc7724d25b3ef..01a9037760c90cb18dd92daea143e2ebdc351f0e 100644
--- a/src/components/media_modal/media_modal.js
+++ b/src/components/media_modal/media_modal.js
@@ -1,32 +1,45 @@
 import StillImage from '../still-image/still-image.vue'
 import VideoAttachment from '../video_attachment/video_attachment.vue'
 import Modal from '../modal/modal.vue'
-import fileTypeService from '../../services/file_type/file_type.service.js'
+import PinchZoom from '../pinch_zoom/pinch_zoom.vue'
+import SwipeClick from '../swipe_click/swipe_click.vue'
 import GestureService from '../../services/gesture_service/gesture_service'
 import Flash from 'src/components/flash/flash.vue'
+import fileTypeService from '../../services/file_type/file_type.service.js'
 import { library } from '@fortawesome/fontawesome-svg-core'
 import {
   faChevronLeft,
   faChevronRight,
-  faCircleNotch
+  faCircleNotch,
+  faTimes
 } from '@fortawesome/free-solid-svg-icons'
 
 library.add(
   faChevronLeft,
   faChevronRight,
-  faCircleNotch
+  faCircleNotch,
+  faTimes
 )
 
 const MediaModal = {
   components: {
     StillImage,
     VideoAttachment,
+    PinchZoom,
+    SwipeClick,
     Modal,
     Flash
   },
   data () {
     return {
-      loading: false
+      loading: false,
+      swipeDirection: GestureService.DIRECTION_LEFT,
+      swipeThreshold: () => {
+        const considerableMoveRatio = 1 / 4
+        return window.innerWidth * considerableMoveRatio
+      },
+      pinchZoomMinScale: 1,
+      pinchZoomScaleResetLimit: 1.2
     }
   },
   computed: {
@@ -52,32 +65,26 @@ const MediaModal = {
       return this.currentMedia ? this.getType(this.currentMedia) : null
     }
   },
-  created () {
-    this.mediaSwipeGestureRight = GestureService.swipeGesture(
-      GestureService.DIRECTION_RIGHT,
-      this.goPrev,
-      50
-    )
-    this.mediaSwipeGestureLeft = GestureService.swipeGesture(
-      GestureService.DIRECTION_LEFT,
-      this.goNext,
-      50
-    )
-  },
   methods: {
     getType (media) {
       return fileTypeService.fileType(media.mimetype)
     },
-    mediaTouchStart (e) {
-      GestureService.beginSwipe(e, this.mediaSwipeGestureRight)
-      GestureService.beginSwipe(e, this.mediaSwipeGestureLeft)
-    },
-    mediaTouchMove (e) {
-      GestureService.updateSwipe(e, this.mediaSwipeGestureRight)
-      GestureService.updateSwipe(e, this.mediaSwipeGestureLeft)
-    },
     hide () {
-      this.$store.dispatch('closeMediaViewer')
+      // HACK: Closing immediately via a touch will cause the click
+      // to be processed on the content below the overlay
+      const transitionTime = 100 // ms
+      setTimeout(() => {
+        this.$store.dispatch('closeMediaViewer')
+      }, transitionTime)
+    },
+    hideIfNotSwiped (event) {
+      // If we have swiped over SwipeClick, do not trigger hide
+      const comp = this.$refs.swipeClick
+      if (!comp) {
+        this.hide()
+      } else {
+        comp.$gesture.click(event)
+      }
     },
     goPrev () {
       if (this.canNavigate) {
@@ -102,6 +109,17 @@ const MediaModal = {
     onImageLoaded () {
       this.loading = false
     },
+    handleSwipePreview (offsets) {
+      this.$refs.pinchZoom.setTransform({ scale: 1, x: offsets[0], y: 0 })
+    },
+    handleSwipeEnd (sign) {
+      this.$refs.pinchZoom.setTransform({ scale: 1, x: 0, y: 0 })
+      if (sign > 0) {
+        this.goNext()
+      } else if (sign < 0) {
+        this.goPrev()
+      }
+    },
     handleKeyupEvent (e) {
       if (this.showing && e.keyCode === 27) { // escape
         this.hide()
diff --git a/src/components/media_modal/media_modal.vue b/src/components/media_modal/media_modal.vue
index 8680267bf95aeb88e3d51f471901c12c54c5faf8..708a43c614df83c38c41d787394ba219147768dc 100644
--- a/src/components/media_modal/media_modal.vue
+++ b/src/components/media_modal/media_modal.vue
@@ -2,20 +2,38 @@
   <Modal
     v-if="showing"
     class="media-modal-view"
-    @backdropClicked="hide"
+    @backdropClicked="hideIfNotSwiped"
   >
-    <img
+    <SwipeClick
       v-if="type === 'image'"
-      :class="{ loading }"
-      class="modal-image"
-      :src="currentMedia.url"
-      :alt="currentMedia.description"
-      :title="currentMedia.description"
-      @touchstart.stop="mediaTouchStart"
-      @touchmove.stop="mediaTouchMove"
-      @click="hide"
-      @load="onImageLoaded"
+      ref="swipeClick"
+      class="modal-image-container"
+      :direction="swipeDirection"
+      :threshold="swipeThreshold"
+      @preview-requested="handleSwipePreview"
+      @swipe-finished="handleSwipeEnd"
+      @swipeless-clicked="hide"
     >
+      <PinchZoom
+        ref="pinchZoom"
+        class="modal-image-container-inner"
+        selector=".modal-image"
+        reach-min-scale-strategy="reset"
+        stop-propagate-handled="stop-propgate-handled"
+        :allow-pan-min-scale="pinchZoomMinScale"
+        :min-scale="pinchZoomMinScale"
+        :reset-to-min-scale-limit="pinchZoomScaleResetLimit"
+      >
+        <img
+          :class="{ loading }"
+          class="modal-image"
+          :src="currentMedia.url"
+          :alt="currentMedia.description"
+          :title="currentMedia.description"
+          @load="onImageLoaded"
+        >
+      </PinchZoom>
+    </SwipeClick>
     <VideoAttachment
       v-if="type === 'video'"
       class="modal-image"
@@ -40,25 +58,36 @@
     <button
       v-if="canNavigate"
       :title="$t('media_modal.previous')"
-      class="modal-view-button-arrow modal-view-button-arrow--prev"
+      class="modal-view-button modal-view-button-arrow modal-view-button-arrow--prev"
       @click.stop.prevent="goPrev"
     >
       <FAIcon
-        class="arrow-icon"
+        class="button-icon arrow-icon"
         icon="chevron-left"
       />
     </button>
     <button
       v-if="canNavigate"
       :title="$t('media_modal.next')"
-      class="modal-view-button-arrow modal-view-button-arrow--next"
+      class="modal-view-button modal-view-button-arrow modal-view-button-arrow--next"
       @click.stop.prevent="goNext"
     >
       <FAIcon
-        class="arrow-icon"
+        class="button-icon arrow-icon"
         icon="chevron-right"
       />
     </button>
+    <button
+      class="modal-view-button modal-view-button-hide"
+      :title="$t('media_modal.hide')"
+      @click.stop.prevent="hide"
+    >
+      <FAIcon
+        class="button-icon"
+        icon="times"
+      />
+    </button>
+
     <span
       v-if="description"
       class="description"
@@ -86,11 +115,17 @@
 <script src="./media_modal.js"></script>
 
 <style lang="scss">
+$modal-view-button-icon-height: 3em;
+$modal-view-button-icon-half-height: calc(#{$modal-view-button-icon-height} / 2);
+$modal-view-button-icon-width: 3em;
+$modal-view-button-icon-margin: 0.5em;
+
 .modal-view.media-modal-view {
   z-index: 1001;
   flex-direction: column;
 
-  .modal-view-button-arrow {
+  .modal-view-button-arrow,
+  .modal-view-button-hide {
     opacity: 0.75;
 
     &:focus,
@@ -103,6 +138,7 @@
       opacity: 1;
     }
   }
+  overflow: hidden;
 }
 
 .media-modal-view {
@@ -115,6 +151,29 @@
     }
   }
 
+  .modal-image-container {
+    display: flex;
+    overflow: hidden;
+    align-items: center;
+    flex-direction: column;
+    max-width: 100%;
+    max-height: 100%;
+    width: 100%;
+    height: 100%;
+    flex-grow: 1;
+    justify-content: center;
+
+    &-inner {
+      width: 100%;
+      height: 100%;
+      flex-grow: 1;
+      display: flex;
+      flex-direction: column;
+      align-items: center;
+      justify-content: center;
+    }
+  }
+
   .description,
   .counter {
     /* Hardcoded since background is also hardcoded */
@@ -134,9 +193,8 @@
   }
 
   .modal-image {
-    max-width: 90%;
-    max-height: 90%;
-    box-shadow: 0px 5px 15px 0 rgba(0, 0, 0, 0.5);
+    max-width: 100%;
+    max-height: 100%;
     image-orientation: from-image; // NOTE: only FF supports this
     animation: 0.1s cubic-bezier(0.7, 0, 1, 0.6) media-fadein;
 
@@ -159,13 +217,7 @@
     }
   }
 
-  .modal-view-button-arrow {
-    position: absolute;
-    display: block;
-    top: 50%;
-    margin-top: -50px;
-    width: 70px;
-    height: 100px;
+  .modal-view-button {
     border: 0;
     padding: 0;
     opacity: 0;
@@ -175,14 +227,33 @@
     overflow: visible;
     cursor: pointer;
     transition: opacity 333ms cubic-bezier(.4,0,.22,1);
+    height: $modal-view-button-icon-height;
+    width: $modal-view-button-icon-width;
 
-    .arrow-icon {
+    .button-icon {
       position: absolute;
-      top: 35px;
-      height: 30px;
-      width: 32px;
+      height: $modal-view-button-icon-height;
+      width: $modal-view-button-icon-width;
       font-size: 14px;
-      line-height: 30px;
+      line-height: $modal-view-button-icon-height;
+      color: #FFF;
+      text-align: center;
+      background-color: rgba(0,0,0,.3);
+    }
+  }
+
+  .modal-view-button-arrow {
+    position: absolute;
+    display: block;
+    top: 50%;
+    margin-top: $modal-view-button-icon-half-height;
+    width: $modal-view-button-icon-width;
+    height: $modal-view-button-icon-height;
+
+    .arrow-icon {
+      position: absolute;
+      top: 0;
+      line-height: $modal-view-button-icon-height;
       color: #FFF;
       text-align: center;
       background-color: rgba(0,0,0,.3);
@@ -191,16 +262,26 @@
     &--prev {
       left: 0;
       .arrow-icon {
-        left: 6px;
+        left: $modal-view-button-icon-margin;
       }
     }
 
     &--next {
       right: 0;
       .arrow-icon {
-        right: 6px;
+        right: $modal-view-button-icon-margin;
       }
     }
   }
+
+  .modal-view-button-hide {
+    position: absolute;
+    top: 0;
+    right: 0;
+    .button-icon {
+      top: $modal-view-button-icon-margin;
+      right: $modal-view-button-icon-margin;
+    }
+  }
 }
 </style>
diff --git a/src/components/pinch_zoom/pinch_zoom.js b/src/components/pinch_zoom/pinch_zoom.js
new file mode 100644
index 0000000000000000000000000000000000000000..82670ddfad6c1127386c9917d8e693be756ddd65
--- /dev/null
+++ b/src/components/pinch_zoom/pinch_zoom.js
@@ -0,0 +1,13 @@
+import PinchZoom from '@kazvmoe-infra/pinch-zoom-element'
+
+export default {
+  methods: {
+    setTransform ({ scale, x, y }) {
+      this.$el.setTransform({ scale, x, y })
+    }
+  },
+  created () {
+    // Make lint happy
+    (() => PinchZoom)()
+  }
+}
diff --git a/src/components/pinch_zoom/pinch_zoom.vue b/src/components/pinch_zoom/pinch_zoom.vue
new file mode 100644
index 0000000000000000000000000000000000000000..18d69719781ceab21f45a4c5523e5ad6f494b5a5
--- /dev/null
+++ b/src/components/pinch_zoom/pinch_zoom.vue
@@ -0,0 +1,11 @@
+<template>
+  <pinch-zoom
+    class="pinch-zoom-parent"
+    v-bind="$attrs"
+    v-on="$listeners"
+  >
+    <slot />
+  </pinch-zoom>
+</template>
+
+<script src="./pinch_zoom.js"></script>
diff --git a/src/components/settings_modal/helpers/integer_setting.js b/src/components/settings_modal/helpers/integer_setting.js
new file mode 100644
index 0000000000000000000000000000000000000000..4a19bd7cd092f8b79b7dd45eff95d60cdbbd1bb5
--- /dev/null
+++ b/src/components/settings_modal/helpers/integer_setting.js
@@ -0,0 +1,41 @@
+import { get, set } from 'lodash'
+import ModifiedIndicator from './modified_indicator.vue'
+export default {
+  components: {
+    ModifiedIndicator
+  },
+  props: {
+    path: String,
+    disabled: Boolean,
+    min: Number,
+    expert: Number
+  },
+  computed: {
+    pathDefault () {
+      const [firstSegment, ...rest] = this.path.split('.')
+      return [firstSegment + 'DefaultValue', ...rest].join('.')
+    },
+    state () {
+      const value = get(this.$parent, this.path)
+      if (value === undefined) {
+        return this.defaultState
+      } else {
+        return value
+      }
+    },
+    defaultState () {
+      return get(this.$parent, this.pathDefault)
+    },
+    isChanged () {
+      return this.state !== this.defaultState
+    },
+    matchesExpertLevel () {
+      return (this.expert || 0) <= this.$parent.expertLevel
+    }
+  },
+  methods: {
+    update (e) {
+      set(this.$parent, this.path, parseInt(e.target.value))
+    }
+  }
+}
diff --git a/src/components/settings_modal/helpers/integer_setting.vue b/src/components/settings_modal/helpers/integer_setting.vue
new file mode 100644
index 0000000000000000000000000000000000000000..daf903f323d0e81b2440388609df6601501b94b6
--- /dev/null
+++ b/src/components/settings_modal/helpers/integer_setting.vue
@@ -0,0 +1,23 @@
+<template>
+<span
+  v-if="matchesExpertLevel"
+  class="IntegerSetting"
+>
+    <label :for="path">
+      <slot />
+    </label>
+    <input
+      :id="path"
+      class="number-input"
+      type="number"
+      step="1"
+      :disabled="disabled"
+      :min="min || 0"
+      :value="state"
+      @change="update"
+    >
+    <ModifiedIndicator :changed="isChanged" />
+  </span>
+</template>
+
+<script src="./integer_setting.js"></script>
diff --git a/src/components/settings_modal/tabs/filtering_tab.js b/src/components/settings_modal/tabs/filtering_tab.js
index 4eaf4217f202e8033dd7d1136b790844f51f6032..73413b48828d17792dd47127410d1986c075a463 100644
--- a/src/components/settings_modal/tabs/filtering_tab.js
+++ b/src/components/settings_modal/tabs/filtering_tab.js
@@ -1,6 +1,7 @@
 import { filter, trim } from 'lodash'
 import BooleanSetting from '../helpers/boolean_setting.vue'
 import ChoiceSetting from '../helpers/choice_setting.vue'
+import IntegerSetting from '../helpers/integer_setting.vue'
 
 import SharedComputedObject from '../helpers/shared_computed_object.js'
 
@@ -17,7 +18,8 @@ const FilteringTab = {
   },
   components: {
     BooleanSetting,
-    ChoiceSetting
+    ChoiceSetting,
+    IntegerSetting
   },
   computed: {
     ...SharedComputedObject(),
diff --git a/src/components/settings_modal/tabs/filtering_tab.vue b/src/components/settings_modal/tabs/filtering_tab.vue
index 81e9543b0f271274b7aea7377d4a77cb24c62c96..61c13b66ef7c3ce96dd926b07376adb0864fc189 100644
--- a/src/components/settings_modal/tabs/filtering_tab.vue
+++ b/src/components/settings_modal/tabs/filtering_tab.vue
@@ -39,12 +39,6 @@
             </li>
           </ul>
         </li>
-        <li>
-          <BooleanSetting
-            path="hidePostStats"
-            expert="1"
-          >
-        </li>
         <li>
           <BooleanSetting path="muteBotStatuses">
             {{ $t('settings.mute_bot_posts') }}
@@ -89,7 +83,15 @@
             type="number"
             min="0"
             step="1"
+            />
+        </li>
+        <li>
+          <IntegerSetting
+            path="maxThumbnails"
+            :min="0"
           >
+            {{ $t('settings.max_thumbnails') }}
+          </IntegerSetting>
         </li>
         <li>
           <BooleanSetting path="hideAttachments">
diff --git a/src/components/settings_modal/tabs/general_tab.js b/src/components/settings_modal/tabs/general_tab.js
index fe7deb6ee98c9c6607b4fd96e0e032d96244945d..62d86176d453766439fe5d10df2a9ab1ddf038bd 100644
--- a/src/components/settings_modal/tabs/general_tab.js
+++ b/src/components/settings_modal/tabs/general_tab.js
@@ -1,6 +1,7 @@
 import BooleanSetting from '../helpers/boolean_setting.vue'
 import ChoiceSetting from '../helpers/choice_setting.vue'
 import ScopeSelector from 'src/components/scope_selector/scope_selector.vue'
+import IntegerSetting from '../helpers/integer_setting.vue'
 import InterfaceLanguageSwitcher from 'src/components/interface_language_switcher/interface_language_switcher.vue'
 
 import SharedComputedObject from '../helpers/shared_computed_object.js'
@@ -22,6 +23,16 @@ const GeneralTab = {
         value: mode,
         label: this.$t(`settings.subject_line_${mode === 'masto' ? 'mastodon' : mode}`)
       })),
+      conversationDisplayOptions: ['tree', 'linear'].map(mode => ({
+        key: mode,
+        value: mode,
+        label: this.$t(`settings.conversation_display_${mode}`)
+      })),
+      conversationOtherRepliesButtonOptions: ['below', 'inside'].map(mode => ({
+        key: mode,
+        value: mode,
+        label: this.$t(`settings.conversation_other_replies_button_${mode}`)
+      })),
       mentionLinkDisplayOptions: ['short', 'full_for_remote', 'full'].map(mode => ({
         key: mode,
         value: mode,
@@ -39,6 +50,7 @@ const GeneralTab = {
   components: {
     BooleanSetting,
     ChoiceSetting,
+    IntegerSetting,
     InterfaceLanguageSwitcher,
     ScopeSelector,
     ServerSideIndicator
diff --git a/src/components/settings_modal/tabs/general_tab.vue b/src/components/settings_modal/tabs/general_tab.vue
index ceb6165546c72faf0b71c8892ccd17e39ba8611f..a2c6bffa510559dea4d7830ea23e5716c0a84311 100644
--- a/src/components/settings_modal/tabs/general_tab.vue
+++ b/src/components/settings_modal/tabs/general_tab.vue
@@ -89,6 +89,52 @@
     <div class="setting-item">
       <h2>{{ $t('settings.post_look_feel') }}</h2>
       <ul class="setting-list">
+        <li>
+          <ChoiceSetting
+            id="conversationDisplay"
+            path="conversationDisplay"
+            :options="conversationDisplayOptions"
+          >
+            {{ $t('settings.conversation_display') }}
+          </ChoiceSetting>
+        </li>
+        <ul
+          v-if="conversationDisplay !== 'linear'"
+          class="setting-list suboptions"
+        >
+          <li>
+            <BooleanSetting path="conversationTreeAdvanced">
+              {{ $t('settings.tree_advanced') }}
+            </BooleanSetting>
+          </li>
+          <li>
+            <BooleanSetting
+              path="conversationTreeFadeAncestors"
+              :expert="1"
+            >
+              {{ $t('settings.tree_fade_ancestors') }}
+            </BooleanSetting>
+          </li>
+          <li>
+            <IntegerSetting
+              path="maxDepthInThread"
+              :min="3"
+              :expert="1"
+            >
+              {{ $t('settings.max_depth_in_thread') }}
+            </IntegerSetting>
+          </li>
+          <li>
+            <ChoiceSetting
+              id="conversationOtherRepliesButton"
+              path="conversationOtherRepliesButton"
+              :options="conversationOtherRepliesButtonOptions"
+              :expert="1"
+            >
+              {{ $t('settings.conversation_other_replies_button') }}
+            </ChoiceSetting>
+          </li>
+        </ul>
         <li>
           <BooleanSetting path="collapseMessageWithSubject">
             {{ $t('settings.collapse_subject') }}
diff --git a/src/components/status/status.js b/src/components/status/status.js
index 2c3e079ff561e59c58de6d15e9f3ea3c589993b0..4c0ef3e0b11e5d71c07dd8fcb87d1135feae9f91 100644
--- a/src/components/status/status.js
+++ b/src/components/status/status.js
@@ -35,7 +35,10 @@ import {
   faStar,
   faEyeSlash,
   faEye,
-  faThumbtack
+  faThumbtack,
+  faChevronUp,
+  faChevronDown,
+  faAngleDoubleRight
 } from '@fortawesome/free-solid-svg-icons'
 
 library.add(
@@ -52,9 +55,47 @@ library.add(
   faEllipsisH,
   faEyeSlash,
   faEye,
-  faThumbtack
+  faThumbtack,
+  faChevronUp,
+  faChevronDown,
+  faAngleDoubleRight
 )
 
+const camelCase = name => name.charAt(0).toUpperCase() + name.slice(1)
+
+const controlledOrUncontrolledGetters = list => list.reduce((res, name) => {
+  const camelized = camelCase(name)
+  const toggle = `controlledToggle${camelized}`
+  const controlledName = `controlled${camelized}`
+  const uncontrolledName = `uncontrolled${camelized}`
+  res[name] = function () {
+    return this[toggle] ? this[controlledName] : this[uncontrolledName]
+  }
+  return res
+}, {})
+
+const controlledOrUncontrolledToggle = (obj, name) => {
+  const camelized = camelCase(name)
+  const toggle = `controlledToggle${camelized}`
+  const uncontrolledName = `uncontrolled${camelized}`
+  if (obj[toggle]) {
+    obj[toggle]()
+  } else {
+    obj[uncontrolledName] = !obj[uncontrolledName]
+  }
+}
+
+const controlledOrUncontrolledSet = (obj, name, val) => {
+  const camelized = camelCase(name)
+  const set = `controlledSet${camelized}`
+  const uncontrolledName = `uncontrolled${camelized}`
+  if (obj[set]) {
+    obj[set](val)
+  } else {
+    obj[uncontrolledName] = val
+  }
+}
+
 const Status = {
   name: 'Status',
   components: {
@@ -89,20 +130,38 @@ const Status = {
     'inlineExpanded',
     'showPinned',
     'inProfile',
-    'profileUserId'
+    'profileUserId',
+
+    'simpleTree',
+    'controlledThreadDisplayStatus',
+    'controlledToggleThreadDisplay',
+    'showOtherRepliesAsButton',
+
+    'controlledShowingTall',
+    'controlledToggleShowingTall',
+    'controlledExpandingSubject',
+    'controlledToggleExpandingSubject',
+    'controlledShowingLongSubject',
+    'controlledToggleShowingLongSubject',
+    'controlledReplying',
+    'controlledToggleReplying',
+    'controlledMediaPlaying',
+    'controlledSetMediaPlaying',
+    'dive'
   ],
   data () {
     return {
-      replying: false,
+      uncontrolledReplying: false,
       unmuted: false,
       userExpanded: false,
-      mediaPlaying: [],
+      uncontrolledMediaPlaying: [],
       suspendable: true,
       error: null,
       headTailLinks: null
     }
   },
   computed: {
+    ...controlledOrUncontrolledGetters(['replying', 'mediaPlaying']),
     muteWords () {
       return this.mergedConfig.muteWords
     },
@@ -318,6 +377,12 @@ const Status = {
     },
     isSuspendable () {
       return !this.replying && this.mediaPlaying.length === 0
+    },
+    inThreadForest () {
+      return !!this.controlledThreadDisplayStatus
+    },
+    threadShowing () {
+      return this.controlledThreadDisplayStatus === 'showing'
     }
   },
   methods: {
@@ -340,7 +405,7 @@ const Status = {
       this.error = undefined
     },
     toggleReplying () {
-      this.replying = !this.replying
+      controlledOrUncontrolledToggle(this, 'replying')
     },
     gotoOriginal (id) {
       if (this.inConversation) {
@@ -360,17 +425,19 @@ const Status = {
       return generateProfileLink(id, name, this.$store.state.instance.restrictedNicknames)
     },
     addMediaPlaying (id) {
-      this.mediaPlaying.push(id)
+      controlledOrUncontrolledSet(this, 'mediaPlaying', this.mediaPlaying.concat(id))
     },
     removeMediaPlaying (id) {
-      this.mediaPlaying = this.mediaPlaying.filter(mediaId => mediaId !== id)
+      controlledOrUncontrolledSet(this, 'mediaPlaying', this.mediaPlaying.filter(mediaId => mediaId !== id))
     },
     setHeadTailLinks (headTailLinks) {
       this.headTailLinks = headTailLinks
-    }
-  },
-  watch: {
-    'highlight': function (id) {
+    },
+    toggleThreadDisplay () {
+      this.controlledToggleThreadDisplay()
+    },
+    scrollIfHighlighted (highlightId) {
+      const id = highlightId
       if (this.status.id === id) {
         let rect = this.$el.getBoundingClientRect()
         if (rect.top < 100) {
@@ -384,6 +451,11 @@ const Status = {
           window.scrollBy(0, rect.bottom - window.innerHeight + 50)
         }
       }
+    }
+  },
+  watch: {
+    'highlight': function (id) {
+      this.scrollIfHighlighted(id)
     },
     'status.repeat_num': function (num) {
       // refetch repeats when repeat_num is changed in any way
diff --git a/src/components/status/status.scss b/src/components/status/status.scss
index 2028ade9e0ef15efcc62cb8fc5d910f73df16b35..3f647b2521c36348654c72c9a1adb003188015f2 100644
--- a/src/components/status/status.scss
+++ b/src/components/status/status.scss
@@ -1,7 +1,5 @@
 @import '../../_variables.scss';
 
-$status-margin: 0.75em;
-
 .Status {
   min-width: 0;
   white-space: normal;
@@ -28,15 +26,8 @@ $status-margin: 0.75em;
     --icon: var(--selectedPostIcon, $fallback--icon);
   }
 
-  &.-conversation {
-    border-left-width: 4px;
-    border-left-style: solid;
-    border-left-color: $fallback--cRed;
-    border-left-color: var(--cRed, $fallback--cRed);
-  }
-
   .gravestone {
-    padding: $status-margin;
+    padding: var(--status-margin, $status-margin);
     color: $fallback--faint;
     color: var(--faint, $fallback--faint);
     display: flex;
@@ -49,7 +40,7 @@ $status-margin: 0.75em;
 
   .status-container {
     display: flex;
-    padding: $status-margin;
+    padding: var(--status-margin, $status-margin);
 
     &.-repeat {
       padding-top: 0;
@@ -57,7 +48,7 @@ $status-margin: 0.75em;
   }
 
   .pin {
-    padding: $status-margin $status-margin 0;
+    padding: var(--status-margin, $status-margin) var(--status-margin, $status-margin) 0;
     display: flex;
     align-items: center;
     justify-content: flex-end;
@@ -73,7 +64,7 @@ $status-margin: 0.75em;
   }
 
   .left-side {
-    margin-right: $status-margin;
+    margin-right: var(--status-margin, $status-margin);
   }
 
   .right-side {
@@ -82,7 +73,7 @@ $status-margin: 0.75em;
   }
 
   .usercard {
-    margin-bottom: $status-margin;
+    margin-bottom: var(--status-margin, $status-margin);
   }
 
   .status-username {
@@ -248,7 +239,7 @@ $status-margin: 0.75em;
   }
 
   .repeat-info {
-    padding: 0.4em $status-margin;
+    padding: 0.4em var(--status-margin, $status-margin);
 
     .repeat-icon {
       color: $fallback--cGreen;
@@ -294,7 +285,7 @@ $status-margin: 0.75em;
     position: relative;
     width: 100%;
     display: flex;
-    margin-top: $status-margin;
+    margin-top: var(--status-margin, $status-margin);
 
     > * {
       max-width: 4em;
@@ -362,7 +353,7 @@ $status-margin: 0.75em;
   }
 
   .favs-repeated-users {
-    margin-top: $status-margin;
+    margin-top: var(--status-margin, $status-margin);
   }
 
   .stats {
@@ -389,7 +380,7 @@ $status-margin: 0.75em;
   }
 
   .stat-count {
-    margin-right: $status-margin;
+    margin-right: var(--status-margin, $status-margin);
     user-select: none;
 
     .stat-title {
diff --git a/src/components/status/status.vue b/src/components/status/status.vue
index 8f51a7782865a735516e19922031c55758fd6db2..1679834edb4e6a48474e3d2df98de0ae9bcb3b4c 100644
--- a/src/components/status/status.vue
+++ b/src/components/status/status.vue
@@ -221,6 +221,31 @@
                     class="fa-scale-110"
                   />
                 </button>
+                <button
+                  v-if="inThreadForest && replies && replies.length && !simpleTree"
+                  class="button-unstyled"
+                  :title="threadShowing ? $t('status.thread_hide') : $t('status.thread_show')"
+                  :aria-expanded="threadShowing ? 'true' : 'false'"
+                  @click.prevent="toggleThreadDisplay"
+                >
+                  <FAIcon
+                    fixed-width
+                    class="fa-scale-110"
+                    :icon="threadShowing ? 'chevron-up' : 'chevron-down'"
+                  />
+                </button>
+                <button
+                  v-if="dive && !simpleTree"
+                  class="button-unstyled"
+                  :title="$t('status.show_only_conversation_under_this')"
+                  @click.prevent="dive"
+                >
+                  <FAIcon
+                    fixed-width
+                    class="fa-scale-110"
+                    :icon="'angle-double-right'"
+                  />
+                </button>
               </span>
             </div>
             <div
@@ -308,6 +333,12 @@
             :no-heading="noHeading"
             :highlight="highlight"
             :focused="isFocused"
+            :controlled-showing-tall="controlledShowingTall"
+            :controlled-expanding-subject="controlledExpandingSubject"
+            :controlled-showing-long-subject="controlledShowingLongSubject"
+            :controlled-toggle-showing-tall="controlledToggleShowingTall"
+            :controlled-toggle-expanding-subject="controlledToggleExpandingSubject"
+            :controlled-toggle-showing-long-subject="controlledToggleShowingLongSubject"
             @mediaplay="addMediaPlaying($event)"
             @mediapause="removeMediaPlaying($event)"
             @parseReady="setHeadTailLinks"
@@ -317,7 +348,20 @@
             v-if="inConversation && !isPreview && replies && replies.length"
             class="replies"
           >
-            <span class="faint">{{ $t('status.replies_list') }}</span>
+            <button
+              v-if="showOtherRepliesAsButton && replies.length > 1"
+              class="button-unstyled -link faint"
+              :title="$tc('status.ancestor_follow', replies.length - 1, { numReplies: replies.length - 1 })"
+              @click.prevent="dive"
+            >
+              {{ $tc('status.replies_list_with_others', replies.length - 1, { numReplies: replies.length - 1 }) }}
+            </button>
+            <span
+              v-else
+              class="faint"
+            >
+              {{ $t('status.replies_list') }}
+            </span>
             <StatusPopover
               v-for="reply in replies"
               :key="reply.id"
diff --git a/src/components/status_body/status_body.js b/src/components/status_body/status_body.js
index 91c331359dada3af0f2f201b661f2dee09afda8a..b8f6f9a0b6b08d4c28575b1c9fb95e5b04226788 100644
--- a/src/components/status_body/status_body.js
+++ b/src/components/status_body/status_body.js
@@ -26,14 +26,16 @@ const StatusContent = {
     'focused',
     'noHeading',
     'fullContent',
-    'singleLine'
+    'singleLine',
+    'showingTall',
+    'expandingSubject',
+    'showingLongSubject',
+    'toggleShowingTall',
+    'toggleExpandingSubject',
+    'toggleShowingLongSubject'
   ],
   data () {
     return {
-      showingTall: this.fullContent || (this.inConversation && this.focused),
-      showingLongSubject: false,
-      // not as computed because it sets the initial state which will be changed later
-      expandingSubject: !this.$store.getters.mergedConfig.collapseMessageWithSubject,
       postLength: this.status.text.length,
       parseReadyDone: false
     }
@@ -115,9 +117,9 @@ const StatusContent = {
     },
     toggleShowMore () {
       if (this.mightHideBecauseTall) {
-        this.showingTall = !this.showingTall
+        this.toggleShowingTall()
       } else if (this.mightHideBecauseSubject) {
-        this.expandingSubject = !this.expandingSubject
+        this.toggleExpandingSubject()
       }
     },
     generateTagLink (tag) {
diff --git a/src/components/status_body/status_body.vue b/src/components/status_body/status_body.vue
index a088e6bc85692a359a82c4fdab774e1dadddb6c5..24d842c2a7f7ffe459b4b3b69187f5b3b9255ad1 100644
--- a/src/components/status_body/status_body.vue
+++ b/src/components/status_body/status_body.vue
@@ -17,14 +17,14 @@
         <button
           v-if="longSubject && showingLongSubject"
           class="button-unstyled -link tall-subject-hider"
-          @click.prevent="showingLongSubject=false"
+          @click.prevent="toggleShowingLongSubject"
         >
           {{ $t("status.hide_full_subject") }}
         </button>
         <button
           v-else-if="longSubject"
           class="button-unstyled -link tall-subject-hider"
-          @click.prevent="showingLongSubject=true"
+          @click.prevent="toggleShowingLongSubject"
         >
           {{ $t("status.show_full_subject") }}
         </button>
diff --git a/src/components/status_content/status_content.js b/src/components/status_content/status_content.js
index dec8914a32414cae8b1d9eb0868023229f5d8d26..cf72ccb845aa3748b8b5fc78795e73e48ae0aa83 100644
--- a/src/components/status_content/status_content.js
+++ b/src/components/status_content/status_content.js
@@ -23,6 +23,30 @@ library.add(
   faPollH
 )
 
+const camelCase = name => name.charAt(0).toUpperCase() + name.slice(1)
+
+const controlledOrUncontrolledGetters = list => list.reduce((res, name) => {
+  const camelized = camelCase(name)
+  const toggle = `controlledToggle${camelized}`
+  const controlledName = `controlled${camelized}`
+  const uncontrolledName = `uncontrolled${camelized}`
+  res[name] = function () {
+    return this[toggle] ? this[controlledName] : this[uncontrolledName]
+  }
+  return res
+}, {})
+
+const controlledOrUncontrolledToggle = (obj, name) => {
+  const camelized = camelCase(name)
+  const toggle = `controlledToggle${camelized}`
+  const uncontrolledName = `uncontrolled${camelized}`
+  if (obj[toggle]) {
+    obj[toggle]()
+  } else {
+    obj[uncontrolledName] = !obj[uncontrolledName]
+  }
+}
+
 const StatusContent = {
   name: 'StatusContent',
   props: [
@@ -31,9 +55,22 @@ const StatusContent = {
     'focused',
     'noHeading',
     'fullContent',
-    'singleLine'
+    'singleLine',
+    'controlledShowingTall',
+    'controlledExpandingSubject',
+    'controlledToggleShowingTall',
+    'controlledToggleExpandingSubject'
   ],
+  data () {
+    return {
+      uncontrolledShowingTall: this.fullContent || (this.inConversation && this.focused),
+      uncontrolledShowingLongSubject: false,
+      // not as computed because it sets the initial state which will be changed later
+      uncontrolledExpandingSubject: !this.$store.getters.mergedConfig.collapseMessageWithSubject
+    }
+  },
   computed: {
+    ...controlledOrUncontrolledGetters(['showingTall', 'expandingSubject', 'showingLongSubject']),
     hideAttachments () {
       return (this.mergedConfig.hideAttachments && !this.inConversation) ||
         (this.mergedConfig.hideAttachmentsInConv && this.inConversation)
@@ -71,6 +108,21 @@ const StatusContent = {
     Gallery,
     LinkPreview,
     StatusBody
+  },
+  methods: {
+    toggleShowingTall () {
+      controlledOrUncontrolledToggle(this, 'showingTall')
+    },
+    toggleExpandingSubject () {
+      controlledOrUncontrolledToggle(this, 'expandingSubject')
+    },
+    toggleShowingLongSubject () {
+      controlledOrUncontrolledToggle(this, 'showingLongSubject')
+    },
+    setMedia () {
+      const attachments = this.attachmentSize === 'hide' ? this.status.attachments : this.galleryAttachments
+      return () => this.$store.dispatch('setMedia', attachments)
+    }
   }
 }
 
diff --git a/src/components/status_content/status_content.vue b/src/components/status_content/status_content.vue
index 69635aad19d5ac444e36d7ced08e79e49b0de027..9e7d795622ec217880b109acb1a74996927d3059 100644
--- a/src/components/status_content/status_content.vue
+++ b/src/components/status_content/status_content.vue
@@ -8,6 +8,12 @@
       :status="status"
       :compact="compact"
       :single-line="singleLine"
+      :showing-tall="showingTall"
+      :expanding-subject="expandingSubject"
+      :showing-long-subject="showingLongSubject"
+      :toggle-showing-tall="toggleShowingTall"
+      :toggle-expanding-subject="toggleExpandingSubject"
+      :toggle-showing-long-subject="toggleShowingLongSubject"
       @parseReady="$emit('parseReady', $event)"
     >
       <div v-if="status.poll && status.poll.options && !compact">
@@ -52,10 +58,6 @@
 
 <script src="./status_content.js" ></script>
 <style lang="scss">
-@import '../../_variables.scss';
-
-$status-margin: 0.75em;
-
 .StatusContent {
   flex: 1;
   min-width: 0;
diff --git a/src/components/swipe_click/swipe_click.js b/src/components/swipe_click/swipe_click.js
new file mode 100644
index 0000000000000000000000000000000000000000..238e6df895f2b20e04cd51008302c76218540f42
--- /dev/null
+++ b/src/components/swipe_click/swipe_click.js
@@ -0,0 +1,84 @@
+import GestureService from '../../services/gesture_service/gesture_service'
+
+/**
+ * props:
+ *   direction: a vector that indicates the direction of the intended swipe
+ *   threshold: the minimum distance in pixels the swipe has moved on `direction'
+ *              for swipe-finished() to have a non-zero sign
+ *   perpendicularTolerance: see gesture_service
+ *
+ * Events:
+ *   preview-requested(offsets)
+ *     Emitted when the pointer has moved.
+ *     offsets: the offsets from the start of the swipe to the current cursor position
+ *
+ *   swipe-canceled()
+ *     Emitted when the swipe has been canceled due to a pointercancel event.
+ *
+ *   swipe-finished(sign: 0|-1|1)
+ *     Emitted when the swipe has finished.
+ *     sign: if the swipe does not meet the threshold, 0
+ *           if the swipe meets the threshold in the positive direction, 1
+ *           if the swipe meets the threshold in the negative direction, -1
+ *
+ *   swipeless-clicked()
+ *     Emitted when there is a click without swipe.
+ *     This and swipe-finished() cannot be emitted for the same pointerup event.
+ */
+const SwipeClick = {
+  props: {
+    direction: {
+      type: Array
+    },
+    threshold: {
+      type: Function,
+      default: () => 30
+    },
+    perpendicularTolerance: {
+      type: Number,
+      default: 1.0
+    }
+  },
+  methods: {
+    handlePointerDown (event) {
+      this.$gesture.start(event)
+    },
+    handlePointerMove (event) {
+      this.$gesture.move(event)
+    },
+    handlePointerUp (event) {
+      this.$gesture.end(event)
+    },
+    handlePointerCancel (event) {
+      this.$gesture.cancel(event)
+    },
+    handleNativeClick (event) {
+      this.$gesture.click(event)
+    },
+    preview (offsets) {
+      this.$emit('preview-requested', offsets)
+    },
+    end (sign) {
+      this.$emit('swipe-finished', sign)
+    },
+    click () {
+      this.$emit('swipeless-clicked')
+    },
+    cancel () {
+      this.$emit('swipe-canceled')
+    }
+  },
+  created () {
+    this.$gesture = new GestureService.SwipeAndClickGesture({
+      direction: this.direction,
+      threshold: this.threshold,
+      perpendicularTolerance: this.perpendicularTolerance,
+      swipePreviewCallback: this.preview,
+      swipeEndCallback: this.end,
+      swipeCancelCallback: this.cancel,
+      swipelessClickCallback: this.click
+    })
+  }
+}
+
+export default SwipeClick
diff --git a/src/components/swipe_click/swipe_click.vue b/src/components/swipe_click/swipe_click.vue
new file mode 100644
index 0000000000000000000000000000000000000000..5372071d8b6739e4b39ab6df04d6c54e8f893980
--- /dev/null
+++ b/src/components/swipe_click/swipe_click.vue
@@ -0,0 +1,14 @@
+<template>
+  <div
+    v-bind="$attrs"
+    @pointerdown="handlePointerDown"
+    @pointermove="handlePointerMove"
+    @pointerup="handlePointerUp"
+    @pointercancel="handlePointerCancel"
+    @click="handleNativeClick"
+  >
+    <slot />
+  </div>
+</template>
+
+<script src="./swipe_click.js"></script>
diff --git a/src/components/thread_tree/thread_tree.js b/src/components/thread_tree/thread_tree.js
new file mode 100644
index 0000000000000000000000000000000000000000..71e63725418c662fb9c11793167ac67a1360118a
--- /dev/null
+++ b/src/components/thread_tree/thread_tree.js
@@ -0,0 +1,90 @@
+import Status from '../status/status.vue'
+
+import { library } from '@fortawesome/fontawesome-svg-core'
+import {
+  faAngleDoubleDown,
+  faAngleDoubleRight
+} from '@fortawesome/free-solid-svg-icons'
+
+library.add(
+  faAngleDoubleDown,
+  faAngleDoubleRight
+)
+
+const ThreadTree = {
+  components: {
+    Status
+  },
+  name: 'ThreadTree',
+  props: {
+    depth: Number,
+    status: Object,
+    inProfile: Boolean,
+    conversation: Array,
+    collapsable: Boolean,
+    isExpanded: Boolean,
+    pinnedStatusIdsObject: Object,
+    profileUserId: String,
+
+    focused: Function,
+    highlight: String,
+    getReplies: Function,
+    setHighlight: Function,
+    toggleExpanded: Function,
+
+    simple: Boolean,
+    // to control display of the whole thread forest
+    toggleThreadDisplay: Function,
+    threadDisplayStatus: Object,
+    showThreadRecursively: Function,
+    totalReplyCount: Object,
+    totalReplyDepth: Object,
+    statusContentProperties: Object,
+    setStatusContentProperty: Function,
+    toggleStatusContentProperty: Function,
+    dive: Function
+  },
+  computed: {
+    suspendable () {
+      const selfSuspendable = this.$refs.statusComponent ? this.$refs.statusComponent.suspendable : true
+      if (this.$refs.childComponent) {
+        return selfSuspendable && this.$refs.childComponent.every(s => s.suspendable)
+      }
+      return selfSuspendable
+    },
+    reverseLookupTable () {
+      return this.conversation.reduce((table, status, index) => {
+        table[status.id] = index
+        return table
+      }, {})
+    },
+    currentReplies () {
+      return this.getReplies(this.status.id).map(({ id }) => this.statusById(id))
+    },
+    threadShowing () {
+      return this.threadDisplayStatus[this.status.id] === 'showing'
+    },
+    currentProp () {
+      return this.statusContentProperties[this.status.id]
+    }
+  },
+  methods: {
+    statusById (id) {
+      return this.conversation[this.reverseLookupTable[id]]
+    },
+    collapseThread () {
+    },
+    showThread () {
+    },
+    showAllSubthreads () {
+    },
+    toggleCurrentProp (name) {
+      this.toggleStatusContentProperty(this.status.id, name)
+    },
+    setCurrentProp (name, newVal) {
+      this.setStatusContentProperty(this.status.id, name)
+    }
+  }
+}
+
+export default ThreadTree
diff --git a/src/components/thread_tree/thread_tree.vue b/src/components/thread_tree/thread_tree.vue
new file mode 100644
index 0000000000000000000000000000000000000000..e64455e0b5b5e26a51abef78bc8f37ceec9ed0a1
--- /dev/null
+++ b/src/components/thread_tree/thread_tree.vue
@@ -0,0 +1,127 @@
+<template>
+  <div class="thread-tree panel-body">
+    <status
+      :key="status.id"
+      ref="statusComponent"
+      :inline-expanded="collapsable && isExpanded"
+      :statusoid="status"
+      :expandable="!isExpanded"
+      :show-pinned="pinnedStatusIdsObject && pinnedStatusIdsObject[status.id]"
+      :focused="focused(status.id)"
+      :in-conversation="isExpanded"
+      :highlight="highlight"
+      :replies="getReplies(status.id)"
+      :in-profile="inProfile"
+      :profile-user-id="profileUserId"
+      class="conversation-status conversation-status-treeview status-fadein panel-body"
+
+      :simple-tree="simple"
+      :controlled-thread-display-status="threadDisplayStatus[status.id]"
+      :controlled-toggle-thread-display="() => toggleThreadDisplay(status.id)"
+
+      :controlled-showing-tall="currentProp.showingTall"
+      :controlled-expanding-subject="currentProp.expandingSubject"
+      :controlled-showing-long-subject="currentProp.showingLongSubject"
+      :controlled-replying="currentProp.replying"
+      :controlled-media-playing="currentProp.mediaPlaying"
+      :controlled-toggle-showing-tall="() => toggleCurrentProp('showingTall')"
+      :controlled-toggle-expanding-subject="() => toggleCurrentProp('expandingSubject')"
+      :controlled-toggle-showing-long-subject="() => toggleCurrentProp('showingLongSubject')"
+      :controlled-toggle-replying="() => toggleCurrentProp('replying')"
+      :controlled-set-media-playing="(newVal) => setCurrentProp('mediaPlaying', newVal)"
+      :dive="dive ? () => dive(status.id) : undefined"
+
+      @goto="setHighlight"
+      @toggleExpanded="toggleExpanded"
+    />
+    <div
+      v-if="currentReplies.length && threadShowing"
+      class="thread-tree-replies"
+    >
+      <thread-tree
+        v-for="replyStatus in currentReplies"
+        :key="replyStatus.id"
+        ref="childComponent"
+        :depth="depth + 1"
+        :status="replyStatus"
+
+        :in-profile="inProfile"
+        :conversation="conversation"
+        :collapsable="collapsable"
+        :is-expanded="isExpanded"
+        :pinned-status-ids-object="pinnedStatusIdsObject"
+        :profile-user-id="profileUserId"
+
+        :focused="focused"
+        :get-replies="getReplies"
+        :highlight="highlight"
+        :set-highlight="setHighlight"
+        :toggle-expanded="toggleExpanded"
+
+        :simple="simple"
+        :toggle-thread-display="toggleThreadDisplay"
+        :thread-display-status="threadDisplayStatus"
+        :show-thread-recursively="showThreadRecursively"
+        :total-reply-count="totalReplyCount"
+        :total-reply-depth="totalReplyDepth"
+        :status-content-properties="statusContentProperties"
+        :set-status-content-property="setStatusContentProperty"
+        :toggle-status-content-property="toggleStatusContentProperty"
+        :dive="dive"
+      />
+    </div>
+    <div
+      v-if="currentReplies.length && !threadShowing"
+      class="thread-tree-replies thread-tree-replies-hidden"
+    >
+      <i18n
+        v-if="simple"
+        tag="button"
+        path="status.thread_follow_with_icon"
+        class="button-unstyled -link thread-tree-show-replies-button"
+        @click.prevent="dive(status.id)"
+      >
+        <FAIcon
+          place="icon"
+          icon="angle-double-right"
+        />
+        <span place="text">
+          {{ $tc('status.thread_follow', totalReplyCount[status.id], { numStatus: totalReplyCount[status.id] }) }}
+        </span>
+      </i18n>
+      <i18n
+        v-else
+        tag="button"
+        path="status.thread_show_full_with_icon"
+        class="button-unstyled -link thread-tree-show-replies-button"
+        @click.prevent="showThreadRecursively(status.id)"
+      >
+        <FAIcon
+          place="icon"
+          icon="angle-double-down"
+        />
+        <span place="text">
+          {{ $tc('status.thread_show_full', totalReplyCount[status.id], { numStatus: totalReplyCount[status.id], depth: totalReplyDepth[status.id] }) }}
+        </span>
+      </i18n>
+    </div>
+  </div>
+</template>
+
+<script src="./thread_tree.js"></script>
+
+<style lang="scss">
+@import '../../_variables.scss';
+.thread-tree-replies {
+  margin-left: var(--status-margin, $status-margin);
+  border-left: 2px solid var(--border, $fallback--border);
+}
+
+.thread-tree-replies-hidden {
+  padding: var(--status-margin, $status-margin);
+  /* Make the button stretch along the whole row */
+  display: flex;
+  align-items: stretch;
+  flex-direction: column;
+}
+</style>
diff --git a/src/i18n/en.json b/src/i18n/en.json
index 61dd25383a17d0902050805c33dc83f4169f7440..76a871fb09990e71274a5cb0a6d3a80b90260aa8 100644
--- a/src/i18n/en.json
+++ b/src/i18n/en.json
@@ -119,7 +119,8 @@
   "media_modal": {
     "previous": "Previous",
     "next": "Next",
-    "counter": "{current} / {total}"
+    "counter": "{current} / {total}",
+    "hide": "Close media viewer"
   },
   "nav": {
     "about": "About",
@@ -472,6 +473,15 @@
     "subject_line_email": "Like email: \"re: subject\"",
     "subject_line_mastodon": "Like mastodon: copy as is",
     "subject_line_noop": "Do not copy",
+    "conversation_display": "Conversation display style",
+    "conversation_display_tree": "Tree-style",
+    "tree_advanced": "Allow more flexible navigation in tree view",
+    "tree_fade_ancestors": "Display ancestors of the current status in faint text",
+    "conversation_display_linear": "Linear-style",
+    "conversation_other_replies_button": "Show the \"other replies\" button",
+    "conversation_other_replies_button_below": "Below statuses",
+    "conversation_other_replies_button_inside": "Inside statuses",
+    "max_depth_in_thread": "Maximum number of levels in thread to display by default",
     "post_status_content_type": "Post status content type",
     "sensitive_by_default": "Mark posts as sensitive by default",
     "stop_gifs": "Pause animated images until you hover on them",
@@ -727,6 +737,7 @@
     "reply_to": "Reply to",
     "mentions": "Mentions",
     "replies_list": "Replies:",
+    "replies_list_with_others": "Replies (+{numReplies} other): | Replies (+{numReplies} others):",
     "mute_conversation": "Mute conversation",
     "unmute_conversation": "Unmute conversation",
     "status_unavailable": "Status unavailable",
@@ -753,7 +764,18 @@
     "attachment_stop_flash": "Stop Flash player",
     "move_up": "Shift attachment left",
     "move_down": "Shift attachment right",
-    "open_gallery": "Open gallery"
+    "open_gallery": "Open gallery",
+    "thread_hide": "Hide this thread",
+    "thread_show": "Show this thread",
+    "thread_show_full": "Show everything under this thread ({numStatus} status in total, max depth {depth}) | Show everything under this thread ({numStatus} statuses in total, max depth {depth})",
+    "thread_show_full_with_icon": "{icon} {text}",
+    "thread_follow": "See the remaining part of this thread ({numStatus} status in total) | See the remaining part of this thread ({numStatus} statuses in total)",
+    "thread_follow_with_icon": "{icon} {text}",
+    "ancestor_follow": "See {numReplies} other reply under this status | See {numReplies} other replies under this status",
+    "ancestor_follow_with_icon": "{icon} {text}",
+    "show_all_conversation_with_icon": "{icon} {text}",
+    "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"
   },
   "user_card": {
     "approve": "Approve",
diff --git a/src/main.js b/src/main.js
index 73bd49ed5951721b3905f145de9d23ea246a874d..bdf8368b5b89e63b99152a8f7458ba605c060617 100644
--- a/src/main.js
+++ b/src/main.js
@@ -46,6 +46,8 @@ Vue.use(VueClickOutside)
 Vue.use(PortalVue)
 Vue.use(VBodyScrollLock)
 
+Vue.config.ignoredElements = ['pinch-zoom']
+
 Vue.component('FAIcon', FontAwesomeIcon)
 Vue.component('FALayers', FontAwesomeLayers)
 
diff --git a/src/modules/config.js b/src/modules/config.js
index 1bb41b7d951d9556b41f97fc2466255fb9f8aa96..ef140bcb63f7c64275590ad4e6997d41d628d4f6 100644
--- a/src/modules/config.js
+++ b/src/modules/config.js
@@ -12,6 +12,8 @@ const browserLocale = (window.navigator.language || 'en').split('-')[0]
 export const multiChoiceProperties = [
   'postContentType',
   'subjectLineBehavior',
+  'conversationDisplay', // tree | linear
+  'conversationOtherRepliesButton', // below | inside
   'mentionLinkDisplay' // short | full_for_remote | full
 ]
 
@@ -84,7 +86,12 @@ export const defaultState = {
   hideBotIndication: undefined, // instance default
   hideUserStats: undefined, // instance default
   virtualScrolling: undefined, // instance default
-  sensitiveByDefault: undefined // instance default
+  sensitiveByDefault: undefined, // instance default
+  conversationDisplay: undefined, // instance default
+  conversationTreeAdvanced: undefined, // instance default
+  conversationOtherRepliesButton: undefined, // instance default
+  conversationTreeFadeAncestors: undefined, // instance default
+  maxDepthInThread: undefined // instance default
 }
 
 // caching the instance default properties
diff --git a/src/modules/instance.js b/src/modules/instance.js
index 41bcf3292abfbb05e6020cb5ecfc8a93abfd765e..79c54096a1d497894ba87d2142dc27b3fa78f7cd 100644
--- a/src/modules/instance.js
+++ b/src/modules/instance.js
@@ -55,6 +55,11 @@ const defaultState = {
   theme: 'pleroma-dark',
   virtualScrolling: true,
   sensitiveByDefault: false,
+  conversationDisplay: 'linear',
+  conversationTreeAdvanced: false,
+  conversationOtherRepliesButton: 'below',
+  conversationTreeFadeAncestors: false,
+  maxDepthInThread: 6,
 
   // Nasty stuff
   customEmoji: [],
diff --git a/src/services/gesture_service/gesture_service.js b/src/services/gesture_service/gesture_service.js
index 88a328f3c81b0edb3c72b492228df9f737317893..265a7f25e19e52daccaa4d9303033b8a24796d28 100644
--- a/src/services/gesture_service/gesture_service.js
+++ b/src/services/gesture_service/gesture_service.js
@@ -4,9 +4,15 @@ const DIRECTION_RIGHT = [1, 0]
 const DIRECTION_UP = [0, -1]
 const DIRECTION_DOWN = [0, 1]
 
+const BUTTON_LEFT = 0
+
 const deltaCoord = (oldCoord, newCoord) => [newCoord[0] - oldCoord[0], newCoord[1] - oldCoord[1]]
 
-const touchEventCoord = e => ([e.touches[0].screenX, e.touches[0].screenY])
+const touchCoord = touch => [touch.screenX, touch.screenY]
+
+const touchEventCoord = e => touchCoord(e.touches[0])
+
+const pointerEventCoord = e => [e.clientX, e.clientY]
 
 const vectorLength = v => Math.sqrt(v[0] * v[0] + v[1] * v[1])
 
@@ -61,6 +67,132 @@ const updateSwipe = (event, gesture) => {
   gesture._swiping = false
 }
 
+class SwipeAndClickGesture {
+  // swipePreviewCallback(offsets: Array[Number])
+  //   offsets: the offset vector which the underlying component should move, from the starting position
+  // swipeEndCallback(sign: 0|-1|1)
+  //   sign: if the swipe does not meet the threshold, 0
+  //         if the swipe meets the threshold in the positive direction, 1
+  //         if the swipe meets the threshold in the negative direction, -1
+  constructor ({
+    direction,
+    // swipeStartCallback
+    swipePreviewCallback,
+    swipeEndCallback,
+    swipeCancelCallback,
+    swipelessClickCallback,
+    threshold = 30,
+    perpendicularTolerance = 1.0,
+    disableClickThreshold = 1
+  }) {
+    const nop = () => {}
+    this.direction = direction
+    this.swipePreviewCallback = swipePreviewCallback || nop
+    this.swipeEndCallback = swipeEndCallback || nop
+    this.swipeCancelCallback = swipeCancelCallback || nop
+    this.swipelessClickCallback = swipelessClickCallback || nop
+    this.threshold = typeof threshold === 'function' ? threshold : () => threshold
+    this.disableClickThreshold = typeof disableClickThreshold === 'function' ? disableClickThreshold : () => disableClickThreshold
+    this.perpendicularTolerance = perpendicularTolerance
+    this._reset()
+  }
+
+  _reset () {
+    this._startPos = [0, 0]
+    this._pointerId = -1
+    this._swiping = false
+    this._swiped = false
+    this._preventNextClick = false
+  }
+
+  start (event) {
+    // Only handle left click
+    if (event.button !== BUTTON_LEFT) {
+      return
+    }
+
+    this._startPos = pointerEventCoord(event)
+    this._pointerId = event.pointerId
+    this._swiping = true
+    this._swiped = false
+  }
+
+  move (event) {
+    if (this._swiping && this._pointerId === event.pointerId) {
+      this._swiped = true
+
+      const coord = pointerEventCoord(event)
+      const delta = deltaCoord(this._startPos, coord)
+
+      this.swipePreviewCallback(delta)
+    }
+  }
+
+  cancel (event) {
+    if (!this._swiping || this._pointerId !== event.pointerId) {
+      return
+    }
+
+    this.swipeCancelCallback()
+  }
+
+  end (event) {
+    if (!this._swiping) {
+      return
+    }
+
+    if (this._pointerId !== event.pointerId) {
+      return
+    }
+
+    this._swiping = false
+
+    // movement too small
+    const coord = pointerEventCoord(event)
+    const delta = deltaCoord(this._startPos, coord)
+
+    const sign = (() => {
+      if (vectorLength(delta) < this.threshold()) {
+        return 0
+      }
+      // movement is opposite from direction
+      const isPositive = dotProduct(delta, this.direction) > 0
+
+      // movement perpendicular to direction is too much
+      const towardsDir = project(delta, this.direction)
+      const perpendicularDir = perpendicular(this.direction)
+      const towardsPerpendicular = project(delta, perpendicularDir)
+      if (
+        vectorLength(towardsDir) * this.perpendicularTolerance <
+          vectorLength(towardsPerpendicular)
+      ) {
+        return 0
+      }
+
+      return isPositive ? 1 : -1
+    })()
+
+    if (this._swiped) {
+      this.swipeEndCallback(sign)
+    }
+    this._reset()
+    // Only a mouse will fire click event when
+    // the end point is far from the starting point
+    // so for other kinds of pointers do not check
+    // whether we have swiped
+    if (vectorLength(delta) >= this.disableClickThreshold() && event.pointerType === 'mouse') {
+      this._preventNextClick = true
+    }
+  }
+
+  click (event) {
+    if (!this._preventNextClick) {
+      this.swipelessClickCallback()
+    }
+    this._reset()
+  }
+}
+
 const GestureService = {
   DIRECTION_LEFT,
   DIRECTION_RIGHT,
@@ -68,7 +200,8 @@ const GestureService = {
   DIRECTION_DOWN,
   swipeGesture,
   beginSwipe,
-  updateSwipe
+  updateSwipe,
+  SwipeAndClickGesture
 }
 
 export default GestureService
diff --git a/yarn.lock b/yarn.lock
index 2d873c25b40072f23d6db0004c382cb278985ac1..c704ecca51c5e00fe604c5ada95412be41846569 100644
--- a/yarn.lock
+++ b/yarn.lock
@@ -916,6 +916,13 @@
   resolved "https://registry.yarnpkg.com/@fortawesome/vue-fontawesome/-/vue-fontawesome-2.0.0.tgz#63da3e459147cebb0a8d58eed81d6071db9f5973"
   integrity sha512-N3VKw7KzRfOm8hShUVldpinlm13HpvLBQgT63QS+aCrIRLwjoEUXY5Rcmttbfb6HkzZaeqjLqd/aZCQ53UjQpg==
 
+"@kazvmoe-infra/pinch-zoom-element@^1.2.0":
+  version "1.2.0"
+  resolved "https://registry.yarnpkg.com/@kazvmoe-infra/pinch-zoom-element/-/pinch-zoom-element-1.2.0.tgz#eb3ca34c53b4410c689d60aca02f4a497ce84aba"
+  integrity sha512-HBrhH5O/Fsp2bB7EGTXzCsBAVcMjknSagKC5pBdGpKsF8meHISR0kjDIdw4YoE0S+0oNMwJ6ZUZyIBrdywxPPw==
+  dependencies:
+    pointer-tracker "^2.0.3"
+
 "@nodelib/fs.scandir@2.1.3":
   version "2.1.3"
   resolved "https://registry.yarnpkg.com/@nodelib/fs.scandir/-/fs.scandir-2.1.3.tgz#3a582bdb53804c6ba6d146579c46e52130cf4a3b"
@@ -6995,6 +7002,11 @@ pngjs@^5.0.0:
   resolved "https://registry.yarnpkg.com/pngjs/-/pngjs-5.0.0.tgz#e79dd2b215767fd9c04561c01236df960bce7fbb"
   integrity sha512-40QW5YalBNfQo5yRYmiw7Yz6TKKVr3h6970B2YE+3fQpsWcrbj1PzJgxeJ19DRQjhMbKPIuMY8rFaXc8moolVw==
 
+pointer-tracker@^2.0.3:
+  version "2.4.0"
+  resolved "https://registry.yarnpkg.com/pointer-tracker/-/pointer-tracker-2.4.0.tgz#78721c2d2201486db11ec1094377f03023b621b3"
+  integrity sha512-pWI2tpaM/XNtc9mUTv42Rmjf6mkHvE8LT5DDEq0G7baPNhxNM9E3CepubPplSoSLk9E5bwQrAMyDcPVmJyTW4g==
+
 portal-vue@2.1.7:
   version "2.1.7"
   resolved "https://registry.yarnpkg.com/portal-vue/-/portal-vue-2.1.7.tgz#ea08069b25b640ca08a5b86f67c612f15f4e4ad4"