Pārlūkot izejas kodu

Merge branch 'from/develop/tusooa/tree-threading' into 'develop'

Add the option to display threads as trees

See merge request pleroma/pleroma-fe!1407
HJ 2 gadi atpakaļ
vecāks
revīzija
e34d71fc1f

+ 2 - 0
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;

+ 363 - 11
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 = {}
     }
   }
 }

+ 223 - 24
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>

+ 37 - 0
src/components/settings_modal/helpers/integer_setting.js

@@ -0,0 +1,37 @@
+import { get, set } from 'lodash'
+import ModifiedIndicator from './modified_indicator.vue'
+export default {
+  components: {
+    ModifiedIndicator
+  },
+  props: {
+    path: String,
+    disabled: Boolean,
+    min: 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
+    }
+  },
+  methods: {
+    update (e) {
+      set(this.$parent, this.path, parseInt(e.target.value))
+    }
+  }
+}

+ 20 - 0
src/components/settings_modal/helpers/integer_setting.vue

@@ -0,0 +1,20 @@
+<template>
+  <span 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>

+ 3 - 1
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(),

+ 5 - 10
src/components/settings_modal/tabs/filtering_tab.vue

@@ -70,17 +70,12 @@
         </li>
         <h3>{{ $t('settings.attachments') }}</h3>
         <li>
-          <label for="maxThumbnails">
-            {{ $t('settings.max_thumbnails') }}
-          </label>
-          <input
-            id="maxThumbnails"
-            path.number="maxThumbnails"
-            class="number-input"
-            type="number"
-            min="0"
-            step="1"
+          <IntegerSetting
+            path="maxThumbnails"
+            :min="0"
           >
+            {{ $t('settings.max_thumbnails') }}
+          </IntegerSetting>
         </li>
         <li>
           <BooleanSetting path="hideAttachments">

+ 12 - 0
src/components/settings_modal/tabs/general_tab.js

@@ -1,5 +1,6 @@
 import BooleanSetting from '../helpers/boolean_setting.vue'
 import ChoiceSetting from '../helpers/choice_setting.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'
@@ -20,6 +21,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,
@@ -37,6 +48,7 @@ const GeneralTab = {
   components: {
     BooleanSetting,
     ChoiceSetting,
+    IntegerSetting,
     InterfaceLanguageSwitcher
   },
   computed: {

+ 41 - 0
src/components/settings_modal/tabs/general_tab.vue

@@ -152,6 +152,47 @@
             {{ $t('settings.show_yous') }}
           </BooleanSetting>
         </li>
+        <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">
+              {{ $t('settings.tree_fade_ancestors') }}
+            </BooleanSetting>
+          </li>
+          <li>
+            <IntegerSetting
+              path="maxDepthInThread"
+              :min="3"
+            >
+              {{ $t('settings.max_depth_in_thread') }}
+            </IntegerSetting>
+          </li>
+          <li>
+            <ChoiceSetting
+              id="conversationOtherRepliesButton"
+              path="conversationOtherRepliesButton"
+              :options="conversationOtherRepliesButtonOptions"
+            >
+              {{ $t('settings.conversation_other_replies_button') }}
+            </ChoiceSetting>
+          </li>
+        </ul>
         <li>
           <ChoiceSetting
             id="mentionLinkDisplay"

+ 84 - 12
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

+ 9 - 18
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 {

+ 45 - 1
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"

+ 9 - 7
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) {

+ 2 - 2
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>

+ 53 - 1
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)
+    }
   }
 }
 

+ 6 - 4
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;

+ 90 - 0
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

+ 127 - 0
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>

+ 22 - 1
src/i18n/en.json

@@ -468,6 +468,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",
@@ -724,6 +733,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",
@@ -750,7 +760,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",

+ 8 - 1
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
 ]
 
@@ -83,7 +85,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

+ 5 - 0
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: [],