Jelajahi Sumber

Merge remote-tracking branch 'pleroma/develop' into improve_delete_user_confirmation_message

Ilja 2 tahun lalu
induk
melakukan
c34fdd46da
59 mengubah file dengan 3160 tambahan dan 953 penghapusan
  1. 6 4
      CHANGELOG.md
  2. 33 32
      package.json
  3. 2 0
      src/_variables.scss
  4. 1 0
      src/boot/after_store.js
  5. 363 11
      src/components/conversation/conversation.js
  6. 223 24
      src/components/conversation/conversation.vue
  7. 43 25
      src/components/media_modal/media_modal.js
  8. 114 33
      src/components/media_modal/media_modal.vue
  9. 0 1
      src/components/mention_link/mention_link.scss
  10. 13 0
      src/components/pinch_zoom/pinch_zoom.js
  11. 11 0
      src/components/pinch_zoom/pinch_zoom.vue
  12. 1 0
      src/components/select/select.vue
  13. 12 3
      src/components/settings_modal/helpers/boolean_setting.js
  14. 2 2
      src/components/settings_modal/helpers/boolean_setting.vue
  15. 12 3
      src/components/settings_modal/helpers/choice_setting.js
  16. 2 0
      src/components/settings_modal/helpers/choice_setting.vue
  17. 41 0
      src/components/settings_modal/helpers/integer_setting.js
  18. 23 0
      src/components/settings_modal/helpers/integer_setting.vue
  19. 51 0
      src/components/settings_modal/helpers/server_side_indicator.vue
  20. 9 0
      src/components/settings_modal/helpers/shared_computed_object.js
  21. 11 0
      src/components/settings_modal/settings_modal.js
  22. 12 0
      src/components/settings_modal/settings_modal.scss
  23. 9 1
      src/components/settings_modal/settings_modal.vue
  24. 3 1
      src/components/settings_modal/tabs/filtering_tab.js
  25. 26 42
      src/components/settings_modal/tabs/filtering_tab.vue
  26. 22 1
      src/components/settings_modal/tabs/general_tab.js
  27. 199 70
      src/components/settings_modal/tabs/general_tab.vue
  28. 5 3
      src/components/settings_modal/tabs/notifications_tab.js
  29. 64 17
      src/components/settings_modal/tabs/notifications_tab.vue
  30. 6 17
      src/components/settings_modal/tabs/profile_tab.js
  31. 61 60
      src/components/settings_modal/tabs/profile_tab.vue
  32. 4 0
      src/components/settings_modal/tabs/theme_tab/theme_tab.js
  33. 16 14
      src/components/settings_modal/tabs/theme_tab/theme_tab.scss
  34. 20 15
      src/components/settings_modal/tabs/theme_tab/theme_tab.vue
  35. 105 13
      src/components/status/status.js
  36. 11 20
      src/components/status/status.scss
  37. 53 2
      src/components/status/status.vue
  38. 9 7
      src/components/status_body/status_body.js
  39. 2 2
      src/components/status_body/status_body.vue
  40. 53 1
      src/components/status_content/status_content.js
  41. 6 4
      src/components/status_content/status_content.vue
  42. 1 0
      src/components/still-image/still-image.vue
  43. 84 0
      src/components/swipe_click/swipe_click.js
  44. 14 0
      src/components/swipe_click/swipe_click.vue
  45. 6 0
      src/components/tab_switcher/tab_switcher.js
  46. 90 0
      src/components/thread_tree/thread_tree.js
  47. 127 0
      src/components/thread_tree/thread_tree.vue
  48. 7 0
      src/components/timeline/timeline_quick_settings.js
  49. 9 0
      src/components/timeline/timeline_quick_settings.vue
  50. 12 1
      src/components/user_avatar/user_avatar.js
  51. 66 32
      src/components/user_avatar/user_avatar.vue
  52. 33 5
      src/i18n/en.json
  53. 4 0
      src/main.js
  54. 13 3
      src/modules/config.js
  55. 7 0
      src/modules/instance.js
  56. 137 0
      src/modules/serverSideConfig.js
  57. 1 0
      src/services/entity_normalizer/entity_normalizer.service.js
  58. 135 2
      src/services/gesture_service/gesture_service.js
  59. 755 482
      yarn.lock

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

+ 33 - 32
package.json

@@ -16,43 +16,44 @@
     "lint-fix": "eslint --fix --ext .js,.vue src test/unit/specs test/e2e/specs"
   },
   "dependencies": {
-    "@babel/runtime": "7.7.6",
+    "@babel/runtime": "7.17.8",
     "@chenfengyuan/vue-qrcode": "1.0.2",
-    "@fortawesome/fontawesome-svg-core": "1.2.32",
-    "@fortawesome/free-regular-svg-icons": "5.15.1",
-    "@fortawesome/free-solid-svg-icons": "5.15.1",
-    "@fortawesome/vue-fontawesome": "2.0.0",
-    "body-scroll-lock": "2.6.4",
+    "@fortawesome/fontawesome-svg-core": "1.3.0",
+    "@fortawesome/free-regular-svg-icons": "5.15.4",
+    "@fortawesome/free-solid-svg-icons": "5.15.4",
+    "@fortawesome/vue-fontawesome": "2.0.6",
+    "@kazvmoe-infra/pinch-zoom-element": "1.2.0",
+    "body-scroll-lock": "2.7.1",
     "chromatism": "3.0.0",
-    "cropperjs": "1.4.3",
+    "cropperjs": "1.5.12",
     "diff": "3.5.0",
     "escape-html": "1.0.3",
-    "localforage": "1.7.3",
+    "localforage": "1.10.0",
     "parse-link-header": "1.0.1",
     "phoenix": "1.4.0",
-    "portal-vue": "2.1.4",
+    "portal-vue": "2.1.7",
     "punycode.js": "2.1.0",
-    "ruffle-mirror": "2021.4.11",
-    "v-click-outside": "2.1.3",
+    "ruffle-mirror": "2021.12.31",
+    "v-click-outside": "2.1.5",
     "vue": "2.6.11",
     "vue-i18n": "7.8.1",
     "vue-router": "3.0.2",
     "vue-template-compiler": "2.6.11",
-    "vuelidate": "0.7.4",
+    "vuelidate": "0.7.7",
     "vuex": "3.0.1"
   },
   "devDependencies": {
-    "@babel/core": "7.7.5",
-    "@babel/plugin-transform-runtime": "7.7.6",
-    "@babel/preset-env": "7.7.6",
-    "@babel/register": "7.7.4",
-    "@ungap/event-target": "0.1.0",
+    "@babel/core": "7.17.8",
+    "@babel/plugin-transform-runtime": "7.17.0",
+    "@babel/preset-env": "7.16.11",
+    "@babel/register": "7.17.7",
+    "@ungap/event-target": "0.2.3",
     "@vue/babel-helper-vue-jsx-merge-props": "1.2.1",
     "@vue/babel-preset-jsx": "1.2.4",
     "@vue/test-utils": "1.0.0-beta.28",
     "autoprefixer": "6.7.7",
     "babel-eslint": "7.2.3",
-    "babel-loader": "8.0.6",
+    "babel-loader": "8.2.4",
     "babel-plugin-lodash": "3.3.4",
     "chai": "3.5.0",
     "chalk": "1.1.3",
@@ -65,46 +66,46 @@
     "eslint": "5.16.0",
     "eslint-config-standard": "12.0.0",
     "eslint-friendly-formatter": "2.0.7",
-    "eslint-loader": "2.1.2",
-    "eslint-plugin-import": "2.17.2",
+    "eslint-loader": "2.2.1",
+    "eslint-plugin-import": "2.25.4",
     "eslint-plugin-node": "7.0.1",
-    "eslint-plugin-promise": "4.1.1",
-    "eslint-plugin-standard": "4.0.0",
+    "eslint-plugin-promise": "4.3.1",
+    "eslint-plugin-standard": "4.1.0",
     "eslint-plugin-vue": "5.2.3",
     "eventsource-polyfill": "0.9.6",
-    "express": "4.16.4",
+    "express": "4.17.3",
     "file-loader": "3.0.1",
     "function-bind": "1.1.1",
     "html-webpack-plugin": "3.2.0",
-    "http-proxy-middleware": "0.17.4",
+    "http-proxy-middleware": "0.21.0",
     "inject-loader": "2.0.1",
-    "iso-639-1": "2.0.3",
+    "iso-639-1": "2.1.13",
     "isparta-loader": "2.0.0",
     "json-loader": "0.5.7",
     "karma": "3.1.4",
     "karma-coverage": "1.1.2",
-    "karma-firefox-launcher": "1.1.0",
+    "karma-firefox-launcher": "1.3.0",
     "karma-mocha": "1.3.0",
     "karma-mocha-reporter": "2.2.5",
     "karma-sinon-chai": "2.0.2",
     "karma-sourcemap-loader": "0.3.8",
     "karma-spec-reporter": "0.0.33",
     "karma-webpack": "4.0.2",
-    "lodash": "4.17.11",
+    "lodash": "4.17.21",
     "lolex": "1.6.0",
-    "mini-css-extract-plugin": "0.5.0",
+    "mini-css-extract-plugin": "0.12.0",
     "mocha": "3.5.3",
     "nightwatch": "0.9.21",
     "opn": "4.0.2",
-    "ora": "0.3.0",
+    "ora": "0.4.1",
     "postcss-loader": "3.0.0",
     "raw-loader": "0.5.1",
     "sass": "1.20.1",
-    "sass-loader": "git://github.com/webpack-contrib/sass-loader",
+    "sass-loader": "7.2.0",
     "selenium-server": "2.53.1",
     "semver": "5.6.0",
     "serviceworker-webpack-plugin": "1.0.1",
-    "shelljs": "0.8.4",
+    "shelljs": "0.8.5",
     "sinon": "2.4.1",
     "sinon-chai": "2.14.0",
     "stylelint": "13.6.1",
@@ -114,7 +115,7 @@
     "vue-loader": "14.2.4",
     "vue-style-loader": "4.1.2",
     "webpack": "4.46.0",
-    "webpack-dev-middleware": "3.7.0",
+    "webpack-dev-middleware": "3.7.3",
     "webpack-hot-middleware": "2.24.3",
     "webpack-merge": "0.14.1"
   },

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

+ 1 - 0
src/boot/after_store.js

@@ -115,6 +115,7 @@ const setSettings = async ({ apiConfig, staticConfig, store }) => {
   copyInstanceOption('nsfwCensorImage')
   copyInstanceOption('background')
   copyInstanceOption('hidePostStats')
+  copyInstanceOption('hideBotIndication')
   copyInstanceOption('hideUserStats')
   copyInstanceOption('hideFilteredStatuses')
   copyInstanceOption('logo')

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

+ 43 - 25
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()

+ 114 - 33
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>

+ 0 - 1
src/components/mention_link/mention_link.scss

@@ -65,7 +65,6 @@
       color: var(--link);
       opacity: 0.8;
       display: inline-block;
-      height: 50%;
       line-height: 1;
       padding: 0 0.1em;
       vertical-align: -25%;

+ 13 - 0
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)()
+  }
+}

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

+ 1 - 0
src/components/select/select.vue

@@ -51,6 +51,7 @@
     bottom: 0;
     right: 5px;
     height: 100%;
+    width: 0.875em;
     color: $fallback--text;
     color: var(--inputText, $fallback--text);
     line-height: 28px;

+ 12 - 3
src/components/settings_modal/helpers/boolean_setting.js

@@ -1,14 +1,17 @@
 import { get, set } from 'lodash'
 import Checkbox from 'src/components/checkbox/checkbox.vue'
 import ModifiedIndicator from './modified_indicator.vue'
+import ServerSideIndicator from './server_side_indicator.vue'
 export default {
   components: {
     Checkbox,
-    ModifiedIndicator
+    ModifiedIndicator,
+    ServerSideIndicator
   },
   props: [
     'path',
-    'disabled'
+    'disabled',
+    'expert'
   ],
   computed: {
     pathDefault () {
@@ -26,8 +29,14 @@ export default {
     defaultState () {
       return get(this.$parent, this.pathDefault)
     },
+    isServerSide () {
+      return this.path.startsWith('serverSide_')
+    },
     isChanged () {
-      return this.state !== this.defaultState
+      return !this.path.startsWith('serverSide_') && this.state !== this.defaultState
+    },
+    matchesExpertLevel () {
+      return (this.expert || 0) <= this.$parent.expertLevel
     }
   },
   methods: {

+ 2 - 2
src/components/settings_modal/helpers/boolean_setting.vue

@@ -1,5 +1,6 @@
 <template>
   <label
+    v-if="matchesExpertLevel"
     class="BooleanSetting"
   >
     <Checkbox
@@ -13,8 +14,7 @@
       >
         <slot />
       </span>
-      <ModifiedIndicator :changed="isChanged" />
-    </Checkbox>
+      <ModifiedIndicator :changed="isChanged" /><ServerSideIndicator :server-side="isServerSide" /> </Checkbox>
   </label>
 </template>
 

+ 12 - 3
src/components/settings_modal/helpers/choice_setting.js

@@ -1,15 +1,18 @@
 import { get, set } from 'lodash'
 import Select from 'src/components/select/select.vue'
 import ModifiedIndicator from './modified_indicator.vue'
+import ServerSideIndicator from './server_side_indicator.vue'
 export default {
   components: {
     Select,
-    ModifiedIndicator
+    ModifiedIndicator,
+    ServerSideIndicator
   },
   props: [
     'path',
     'disabled',
-    'options'
+    'options',
+    'expert'
   ],
   computed: {
     pathDefault () {
@@ -27,8 +30,14 @@ export default {
     defaultState () {
       return get(this.$parent, this.pathDefault)
     },
+    isServerSide () {
+      return this.path.startsWith('serverSide_')
+    },
     isChanged () {
-      return this.state !== this.defaultState
+      return !this.path.startsWith('serverSide_') && this.state !== this.defaultState
+    },
+    matchesExpertLevel () {
+      return (this.expert || 0) <= this.$parent.expertLevel
     }
   },
   methods: {

+ 2 - 0
src/components/settings_modal/helpers/choice_setting.vue

@@ -1,5 +1,6 @@
 <template>
   <label
+    v-if="matchesExpertLevel"
     class="ChoiceSetting"
   >
     <slot />
@@ -18,6 +19,7 @@
       </option>
     </Select>
     <ModifiedIndicator :changed="isChanged" />
+    <ServerSideIndicator :server-side="isServerSide" />
   </label>
 </template>
 

+ 41 - 0
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))
+    }
+  }
+}

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

+ 51 - 0
src/components/settings_modal/helpers/server_side_indicator.vue

@@ -0,0 +1,51 @@
+<template>
+  <span
+    v-if="serverSide"
+    class="ServerSideIndicator"
+  >
+    <Popover
+      trigger="hover"
+    >
+      <template v-slot:trigger>
+        &nbsp;
+        <FAIcon
+          icon="server"
+          :aria-label="$t('settings.setting_server_side')"
+        />
+      </template>
+      <template v-slot:content>
+        <div class="serverside-tooltip">
+          {{ $t('settings.setting_server_side') }}
+        </div>
+      </template>
+    </Popover>
+  </span>
+</template>
+
+<script>
+import Popover from 'src/components/popover/popover.vue'
+import { library } from '@fortawesome/fontawesome-svg-core'
+import { faServer } from '@fortawesome/free-solid-svg-icons'
+
+library.add(
+  faServer
+)
+
+export default {
+  components: { Popover },
+  props: ['serverSide']
+}
+</script>
+
+<style lang="scss">
+.ServerSideIndicator {
+  display: inline-block;
+  position: relative;
+
+  .serverside-tooltip {
+    margin: 0.5em 1em;
+    min-width: 10em;
+    text-align: center;
+  }
+}
+</style>

+ 9 - 0
src/components/settings_modal/helpers/shared_computed_object.js

@@ -1,4 +1,5 @@
 import { defaultState as configDefaultState } from 'src/modules/config.js'
+import { defaultState as serverSideConfigDefaultState } from 'src/modules/serverSideConfig.js'
 
 const SharedComputedObject = () => ({
   user () {
@@ -22,6 +23,14 @@ const SharedComputedObject = () => ({
       }
     }])
     .reduce((acc, [key, value]) => ({ ...acc, [key]: value }), {}),
+  ...Object.keys(serverSideConfigDefaultState)
+    .map(key => ['serverSide_' + key, {
+      get () { return this.$store.state.serverSideConfig[key] },
+      set (value) {
+        this.$store.dispatch('setServerSideOption', { name: key, value })
+      }
+    }])
+    .reduce((acc, [key, value]) => ({ ...acc, [key]: value }), {}),
   // Special cases (need to transform values or perform actions first)
   useStreamingApi: {
     get () { return this.$store.getters.mergedConfig.useStreamingApi },

+ 11 - 0
src/components/settings_modal/settings_modal.js

@@ -3,6 +3,7 @@ import PanelLoading from 'src/components/panel_loading/panel_loading.vue'
 import AsyncComponentError from 'src/components/async_component_error/async_component_error.vue'
 import getResettableAsyncComponent from 'src/services/resettable_async_component.js'
 import Popover from '../popover/popover.vue'
+import Checkbox from 'src/components/checkbox/checkbox.vue'
 import { library } from '@fortawesome/fontawesome-svg-core'
 import { cloneDeep } from 'lodash'
 import {
@@ -51,6 +52,7 @@ const SettingsModal = {
   components: {
     Modal,
     Popover,
+    Checkbox,
     SettingsModalContent: getResettableAsyncComponent(
       () => import('./settings_modal_content.vue'),
       {
@@ -159,6 +161,15 @@ const SettingsModal = {
     },
     modalPeeked () {
       return this.$store.state.interface.settingsModalState === 'minimized'
+    },
+    expertLevel: {
+      get () {
+        return this.$store.state.config.expertLevel > 0
+      },
+      set (value) {
+        console.log(value)
+        this.$store.dispatch('setOption', { name: 'expertLevel', value: value ? 1 : 0 })
+      }
     }
   }
 }

+ 12 - 0
src/components/settings_modal/settings_modal.scss

@@ -48,4 +48,16 @@
       }
     }
   }
+
+  .settings-footer {
+    display: flex;
+    >* {
+      margin-right: 0.5em;
+    }
+
+    .extra-content {
+      display: flex;
+      flex-grow: 1;
+    }
+  }
 }

+ 9 - 1
src/components/settings_modal/settings_modal.vue

@@ -53,7 +53,7 @@
       <div class="panel-body">
         <SettingsModalContent v-if="modalOpenedOnce" />
       </div>
-      <div class="panel-footer">
+      <div class="panel-footer settings-footer">
         <Popover
           class="export"
           trigger="click"
@@ -108,6 +108,14 @@
             </div>
           </template>
         </Popover>
+
+        <Checkbox v-model="expertLevel">
+          {{ $t("settings.expert_mode") }}
+        </Checkbox>
+        <portal-target
+          class="extra-content"
+          name="unscrolled-content"
+        />
       </div>
     </div>
   </Modal>

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

+ 26 - 42
src/components/settings_modal/tabs/filtering_tab.vue

@@ -21,6 +21,7 @@
             </li>
             <li>
               <BooleanSetting
+                v-if="user"
                 :disabled="hideFilteredStatuses"
                 path="hideMutedThreads"
               >
@@ -29,6 +30,7 @@
             </li>
             <li>
               <BooleanSetting
+                v-if="user"
                 :disabled="hideFilteredStatuses"
                 path="hideMutedPosts"
               >
@@ -37,12 +39,23 @@
             </li>
           </ul>
         </li>
+        <li>
+          <BooleanSetting path="muteBotStatuses">
+            {{ $t('settings.mute_bot_posts') }}
+          </BooleanSetting>
+        </li>
         <li>
           <BooleanSetting path="hidePostStats">
             {{ $t('settings.hide_post_stats') }}
           </BooleanSetting>
         </li>
+        <li>
+          <BooleanSetting path="hideBotIndication">
+            {{ $t('settings.hide_bot_indication') }}
+          </BooleanSetting>
+        </li>
         <ChoiceSetting
+          v-if="user"
           id="replyVisibility"
           path="replyVisibility"
           :options="replyVisibilityOptions"
@@ -59,7 +72,7 @@
           <div>{{ $t('settings.filtering_explanation') }}</div>
         </li>
         <h3>{{ $t('settings.attachments') }}</h3>
-        <li>
+        <li v-if="expertLevel > 0">
           <label for="maxThumbnails">
             {{ $t('settings.max_thumbnails') }}
           </label>
@@ -72,6 +85,14 @@
             step="1"
           >
         </li>
+        <li>
+          <IntegerSetting
+            path="maxThumbnails"
+            :min="0"
+          >
+            {{ $t('settings.max_thumbnails') }}
+          </IntegerSetting>
+        </li>
         <li>
           <BooleanSetting path="hideAttachments">
             {{ $t('settings.hide_attachments_in_tl') }}
@@ -84,7 +105,10 @@
         </li>
       </ul>
     </div>
-    <div class="setting-item">
+    <div
+      v-if="expertLevel > 0"
+      class="setting-item"
+    >
       <h2>{{ $t('settings.user_profiles') }}</h2>
       <ul class="setting-list">
         <li>
@@ -94,46 +118,6 @@
         </li>
       </ul>
     </div>
-    <div class="setting-item">
-      <h2>{{ $t('settings.notifications') }}</h2>
-      <ul class="setting-list">
-        <li class="select-multiple">
-          <span class="label">{{ $t('settings.notification_visibility') }}</span>
-          <ul class="option-list">
-            <li>
-              <BooleanSetting path="notificationVisibility.likes">
-                {{ $t('settings.notification_visibility_likes') }}
-              </BooleanSetting>
-            </li>
-            <li>
-              <BooleanSetting path="notificationVisibility.repeats">
-                {{ $t('settings.notification_visibility_repeats') }}
-              </BooleanSetting>
-            </li>
-            <li>
-              <BooleanSetting path="notificationVisibility.follows">
-                {{ $t('settings.notification_visibility_follows') }}
-              </BooleanSetting>
-            </li>
-            <li>
-              <BooleanSetting path="notificationVisibility.mentions">
-                {{ $t('settings.notification_visibility_mentions') }}
-              </BooleanSetting>
-            </li>
-            <li>
-              <BooleanSetting path="notificationVisibility.moves">
-                {{ $t('settings.notification_visibility_moves') }}
-              </BooleanSetting>
-            </li>
-            <li>
-              <BooleanSetting path="notificationVisibility.emojiReactions">
-                {{ $t('settings.notification_visibility_emoji_reactions') }}
-              </BooleanSetting>
-            </li>
-          </ul>
-        </li>
-      </ul>
-    </div>
   </div>
 </template>
 <script src="./filtering_tab.js"></script>

+ 22 - 1
src/components/settings_modal/tabs/general_tab.js

@@ -1,8 +1,11 @@
 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'
+import ServerSideIndicator from '../helpers/server_side_indicator.vue'
 import { library } from '@fortawesome/fontawesome-svg-core'
 import {
   faGlobe
@@ -20,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,
@@ -37,7 +50,10 @@ const GeneralTab = {
   components: {
     BooleanSetting,
     ChoiceSetting,
-    InterfaceLanguageSwitcher
+    IntegerSetting,
+    InterfaceLanguageSwitcher,
+    ScopeSelector,
+    ServerSideIndicator
   },
   computed: {
     postFormats () {
@@ -57,6 +73,11 @@ const GeneralTab = {
     },
     instanceShoutboxPresent () { return this.$store.state.instance.shoutAvailable },
     ...SharedComputedObject()
+  },
+  methods: {
+    changeDefaultScope (value) {
+      this.$store.dispatch('setServerSideOption', { name: 'defaultScope', value })
+    }
   }
 }
 

+ 199 - 70
src/components/settings_modal/tabs/general_tab.vue

@@ -45,26 +45,42 @@
           </ul>
         </li>
         <li>
-          <BooleanSetting path="useStreamingApi">
+          <BooleanSetting
+            path="useStreamingApi"
+            expert="1"
+          >
             {{ $t('settings.useStreamingApi') }}
-            <br>
-            <small>
-              {{ $t('settings.useStreamingApiWarning') }}
-            </small>
           </BooleanSetting>
         </li>
         <li>
-          <BooleanSetting path="virtualScrolling">
+          <BooleanSetting
+            path="virtualScrolling"
+            expert="1"
+          >
             {{ $t('settings.virtual_scrolling') }}
           </BooleanSetting>
         </li>
         <li>
-          <BooleanSetting path="autohideFloatingPostButton">
+          <BooleanSetting
+            path="alwaysShowNewPostButton"
+            expert="1"
+          >
+            {{ $t('settings.always_show_post_button') }}
+          </BooleanSetting>
+        </li>
+        <li>
+          <BooleanSetting
+            path="autohideFloatingPostButton"
+            expert="1"
+          >
             {{ $t('settings.autohide_floating_post_button') }}
           </BooleanSetting>
         </li>
         <li v-if="instanceShoutboxPresent">
-          <BooleanSetting path="hideShoutbox">
+          <BooleanSetting
+            path="hideShoutbox"
+            expert="1"
+          >
             {{ $t('settings.hide_shoutbox') }}
           </BooleanSetting>
         </li>
@@ -73,19 +89,80 @@
     <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') }}
           </BooleanSetting>
         </li>
         <li>
-          <BooleanSetting path="emojiReactionsOnTimeline">
+          <BooleanSetting
+            path="emojiReactionsOnTimeline"
+            expert="1"
+          >
             {{ $t('settings.emoji_reactions_on_timeline') }}
           </BooleanSetting>
         </li>
+        <li>
+          <BooleanSetting
+            v-if="user"
+            path="serverSide_stripRichContent"
+            expert="1"
+          >
+            {{ $t('settings.no_rich_text_description') }}
+          </BooleanSetting>
+        </li>
         <h3>{{ $t('settings.attachments') }}</h3>
         <li>
-          <BooleanSetting path="useContainFit">
+          <BooleanSetting
+            path="useContainFit"
+            expert="1"
+          >
             {{ $t('settings.use_contain_fit') }}
           </BooleanSetting>
         </li>
@@ -98,6 +175,7 @@
           <li>
             <BooleanSetting
               path="preloadImage"
+              expert="1"
               :disabled="!hideNsfw"
             >
               {{ $t('settings.preload_images') }}
@@ -106,6 +184,7 @@
           <li>
             <BooleanSetting
               path="useOneClickNsfw"
+              expert="1"
               :disabled="!hideNsfw"
             >
               {{ $t('settings.use_one_click_nsfw') }}
@@ -113,7 +192,10 @@
           </li>
         </ul>
         <li>
-          <BooleanSetting path="loopVideo">
+          <BooleanSetting
+            path="loopVideo"
+            expert="1"
+          >
             {{ $t('settings.loop_video') }}
           </BooleanSetting>
           <ul
@@ -123,6 +205,7 @@
             <li>
               <BooleanSetting
                 path="loopVideoSilentOnly"
+                expert="1"
                 :disabled="!loopVideo || !loopSilentAvailable"
               >
                 {{ $t('settings.loop_video_silent_only') }}
@@ -137,21 +220,14 @@
           </ul>
         </li>
         <li>
-          <BooleanSetting path="playVideosInModal">
+          <BooleanSetting
+            path="playVideosInModal"
+            expert="1"
+          >
             {{ $t('settings.play_videos_in_modal') }}
           </BooleanSetting>
         </li>
-        <h3>{{ $t('settings.fun') }}</h3>
-        <li>
-          <BooleanSetting path="greentext">
-            {{ $t('settings.greentext') }}
-          </BooleanSetting>
-        </li>
-        <li>
-          <BooleanSetting path="mentionLinkShowYous">
-            {{ $t('settings.show_yous') }}
-          </BooleanSetting>
-        </li>
+        <h3>{{ $t('settings.mention_links') }}</h3>
         <li>
           <ChoiceSetting
             id="mentionLinkDisplay"
@@ -164,47 +240,103 @@
         <ul
           class="setting-list suboptions"
         >
-          <li
-            v-if="mentionLinkDisplay === 'short'"
-          >
-            <BooleanSetting path="mentionLinkShowTooltip">
+          <li v-if="mentionLinkDisplay === 'short'">
+            <BooleanSetting
+              path="mentionLinkShowTooltip"
+              expert="1"
+            >
               {{ $t('settings.mention_link_show_tooltip') }}
             </BooleanSetting>
           </li>
-          <li>
-            <BooleanSetting path="useAtIcon">
-              {{ $t('settings.use_at_icon') }}
-            </BooleanSetting>
-          </li>
-          <li>
-            <BooleanSetting path="mentionLinkShowAvatar">
-              {{ $t('settings.mention_link_show_avatar') }}
-            </BooleanSetting>
-          </li>
-          <li>
-            <BooleanSetting path="mentionLinkFadeDomain">
-              {{ $t('settings.mention_link_fade_domain') }}
-            </BooleanSetting>
-          </li>
-          <li>
-            <BooleanSetting path="mentionLinkBoldenYou">
-              {{ $t('settings.mention_link_bolden_you') }}
-            </BooleanSetting>
-          </li>
         </ul>
+        <li>
+          <BooleanSetting
+            path="useAtIcon"
+            expert="1"
+          >
+            {{ $t('settings.use_at_icon') }}
+          </BooleanSetting>
+        </li>
+        <li>
+          <BooleanSetting path="mentionLinkShowAvatar">
+            {{ $t('settings.mention_link_show_avatar') }}
+          </BooleanSetting>
+        </li>
+        <li>
+          <BooleanSetting
+            path="mentionLinkFadeDomain"
+            expert="1"
+          >
+            {{ $t('settings.mention_link_fade_domain') }}
+          </BooleanSetting>
+        </li>
+        <li v-if="user">
+          <BooleanSetting
+            path="mentionLinkBoldenYou"
+            expert="1"
+          >
+            {{ $t('settings.mention_link_bolden_you') }}
+          </BooleanSetting>
+        </li>
+        <h3 v-if="expertLevel > 0">
+          {{ $t('settings.fun') }}
+        </h3>
+        <li>
+          <BooleanSetting
+            path="greentext"
+            expert="1"
+          >
+            {{ $t('settings.greentext') }}
+          </BooleanSetting>
+        </li>
+        <li v-if="user">
+          <BooleanSetting
+            path="mentionLinkShowYous"
+            expert="1"
+          >
+            {{ $t('settings.show_yous') }}
+          </BooleanSetting>
+        </li>
       </ul>
     </div>
 
-    <div class="setting-item">
+    <div
+      v-if="user"
+      class="setting-item"
+    >
       <h2>{{ $t('settings.composing') }}</h2>
       <ul class="setting-list">
         <li>
-          <BooleanSetting path="scopeCopy">
+          <label for="default-vis">
+            {{ $t('settings.default_vis') }} <ServerSideIndicator :server-side="true" />
+            <ScopeSelector
+              class="scope-selector"
+              :show-all="true"
+              :user-default="serverSide_defaultScope"
+              :initial-scope="serverSide_defaultScope"
+              :on-scope-change="changeDefaultScope"
+            />
+          </label>
+        </li>
+        <li>
+          <!-- <BooleanSetting path="serverSide_defaultNSFW"> -->
+          <BooleanSetting path="sensitiveByDefault">
+            {{ $t('settings.sensitive_by_default') }}
+          </BooleanSetting>
+        </li>
+        <li>
+          <BooleanSetting
+            path="scopeCopy"
+            expert="1"
+          >
             {{ $t('settings.scope_copy') }}
           </BooleanSetting>
         </li>
         <li>
-          <BooleanSetting path="alwaysShowSubjectInput">
+          <BooleanSetting
+            path="alwaysShowSubjectInput"
+            expert="1"
+          >
             {{ $t('settings.subject_input_always_show') }}
           </BooleanSetting>
         </li>
@@ -213,6 +345,7 @@
             id="subjectLineBehavior"
             path="subjectLineBehavior"
             :options="subjectLineOptions"
+            expert="1"
           >
             {{ $t('settings.subject_line_behavior') }}
           </ChoiceSetting>
@@ -227,43 +360,39 @@
           </ChoiceSetting>
         </li>
         <li>
-          <BooleanSetting path="minimalScopesMode">
+          <BooleanSetting
+            path="minimalScopesMode"
+            expert="1"
+          >
             {{ $t('settings.minimal_scopes_mode') }}
           </BooleanSetting>
         </li>
         <li>
-          <BooleanSetting path="sensitiveByDefault">
-            {{ $t('settings.sensitive_by_default') }}
-          </BooleanSetting>
-        </li>
-        <li>
-          <BooleanSetting path="alwaysShowNewPostButton">
+          <BooleanSetting
+            path="alwaysShowNewPostButton"
+            expert="1"
+          >
             {{ $t('settings.always_show_post_button') }}
           </BooleanSetting>
         </li>
         <li>
-          <BooleanSetting path="autohideFloatingPostButton">
+          <BooleanSetting
+            path="autohideFloatingPostButton"
+            expert="1"
+          >
             {{ $t('settings.autohide_floating_post_button') }}
           </BooleanSetting>
         </li>
         <li>
-          <BooleanSetting path="padEmoji">
+          <BooleanSetting
+            path="padEmoji"
+            expert="1"
+          >
             {{ $t('settings.pad_emoji') }}
           </BooleanSetting>
         </li>
       </ul>
     </div>
-
-    <div class="setting-item">
-      <h2>{{ $t('settings.notifications') }}</h2>
-      <ul class="setting-list">
-        <li>
-          <BooleanSetting path="webPushNotifications">
-            {{ $t('settings.enable_web_push_notifications') }}
-          </BooleanSetting>
-        </li>
-      </ul>
-    </div>
   </div>
 </template>
 

+ 5 - 3
src/components/settings_modal/tabs/notifications_tab.js

@@ -1,4 +1,5 @@
-import Checkbox from 'src/components/checkbox/checkbox.vue'
+import BooleanSetting from '../helpers/boolean_setting.vue'
+import SharedComputedObject from '../helpers/shared_computed_object.js'
 
 const NotificationsTab = {
   data () {
@@ -9,12 +10,13 @@ const NotificationsTab = {
     }
   },
   components: {
-    Checkbox
+    BooleanSetting
   },
   computed: {
     user () {
       return this.$store.state.users.currentUser
-    }
+    },
+    ...SharedComputedObject()
   },
   methods: {
     updateNotificationSettings () {

+ 64 - 17
src/components/settings_modal/tabs/notifications_tab.vue

@@ -2,30 +2,77 @@
   <div :label="$t('settings.notifications')">
     <div class="setting-item">
       <h2>{{ $t('settings.notification_setting_filters') }}</h2>
-      <p>
-        <Checkbox v-model="notificationSettings.block_from_strangers">
-          {{ $t('settings.notification_setting_block_from_strangers') }}
-        </Checkbox>
-      </p>
+      <ul class="setting-list">
+        <li>
+          <BooleanSetting path="serverSide_blockNotificationsFromStrangers">
+            {{ $t('settings.notification_setting_block_from_strangers') }}
+          </BooleanSetting>
+        </li>
+        <li class="select-multiple">
+          <span class="label">{{ $t('settings.notification_visibility') }}</span>
+          <ul class="option-list">
+            <li>
+              <BooleanSetting path="notificationVisibility.likes">
+                {{ $t('settings.notification_visibility_likes') }}
+              </BooleanSetting>
+            </li>
+            <li>
+              <BooleanSetting path="notificationVisibility.repeats">
+                {{ $t('settings.notification_visibility_repeats') }}
+              </BooleanSetting>
+            </li>
+            <li>
+              <BooleanSetting path="notificationVisibility.follows">
+                {{ $t('settings.notification_visibility_follows') }}
+              </BooleanSetting>
+            </li>
+            <li>
+              <BooleanSetting path="notificationVisibility.mentions">
+                {{ $t('settings.notification_visibility_mentions') }}
+              </BooleanSetting>
+            </li>
+            <li>
+              <BooleanSetting path="notificationVisibility.moves">
+                {{ $t('settings.notification_visibility_moves') }}
+              </BooleanSetting>
+            </li>
+            <li>
+              <BooleanSetting path="notificationVisibility.emojiReactions">
+                {{ $t('settings.notification_visibility_emoji_reactions') }}
+              </BooleanSetting>
+            </li>
+          </ul>
+        </li>
+      </ul>
     </div>
 
-    <div class="setting-item">
+    <div
+      v-if="expertLevel > 0"
+      class="setting-item"
+    >
       <h2>{{ $t('settings.notification_setting_privacy') }}</h2>
-      <p>
-        <Checkbox v-model="notificationSettings.hide_notification_contents">
-          {{ $t('settings.notification_setting_hide_notification_contents') }}
-        </Checkbox>
-      </p>
+      <ul class="setting-list">
+        <li>
+          <BooleanSetting
+            path="webPushNotifications"
+            expert="1"
+          >
+            {{ $t('settings.enable_web_push_notifications') }}
+          </BooleanSetting>
+        </li>
+        <li>
+          <BooleanSetting
+            path="serverSide_webPushHideContents"
+            expert="1"
+          >
+            {{ $t('settings.notification_setting_hide_notification_contents') }}
+          </BooleanSetting>
+        </li>
+      </ul>
     </div>
     <div class="setting-item">
       <p>{{ $t('settings.notification_mutes') }}</p>
       <p>{{ $t('settings.notification_blocks') }}</p>
-      <button
-        class="btn button-default"
-        @click="updateNotificationSettings"
-      >
-        {{ $t('settings.save') }}
-      </button>
     </div>
   </div>
 </template>

+ 6 - 17
src/components/settings_modal/tabs/profile_tab.js

@@ -8,6 +8,9 @@ import EmojiInput from 'src/components/emoji_input/emoji_input.vue'
 import suggestor from 'src/components/emoji_input/suggestor.js'
 import Autosuggest from 'src/components/autosuggest/autosuggest.vue'
 import Checkbox from 'src/components/checkbox/checkbox.vue'
+import BooleanSetting from '../helpers/boolean_setting.vue'
+import SharedComputedObject from '../helpers/shared_computed_object.js'
+
 import { library } from '@fortawesome/fontawesome-svg-core'
 import {
   faTimes,
@@ -27,18 +30,10 @@ const ProfileTab = {
       newName: this.$store.state.users.currentUser.name_unescaped,
       newBio: unescape(this.$store.state.users.currentUser.description),
       newLocked: this.$store.state.users.currentUser.locked,
-      newNoRichText: this.$store.state.users.currentUser.no_rich_text,
-      newDefaultScope: this.$store.state.users.currentUser.default_scope,
       newFields: this.$store.state.users.currentUser.fields.map(field => ({ name: field.name, value: field.value })),
-      hideFollows: this.$store.state.users.currentUser.hide_follows,
-      hideFollowers: this.$store.state.users.currentUser.hide_followers,
-      hideFollowsCount: this.$store.state.users.currentUser.hide_follows_count,
-      hideFollowersCount: this.$store.state.users.currentUser.hide_followers_count,
       showRole: this.$store.state.users.currentUser.show_role,
       role: this.$store.state.users.currentUser.role,
-      discoverable: this.$store.state.users.currentUser.discoverable,
       bot: this.$store.state.users.currentUser.bot,
-      allowFollowingMove: this.$store.state.users.currentUser.allow_following_move,
       pickAvatarBtnVisible: true,
       bannerUploading: false,
       backgroundUploading: false,
@@ -54,12 +49,14 @@ const ProfileTab = {
     EmojiInput,
     Autosuggest,
     ProgressButton,
-    Checkbox
+    Checkbox,
+    BooleanSetting
   },
   computed: {
     user () {
       return this.$store.state.users.currentUser
     },
+    ...SharedComputedObject(),
     emojiUserSuggestor () {
       return suggestor({
         emoji: [
@@ -123,15 +120,7 @@ const ProfileTab = {
             /* eslint-disable camelcase */
             display_name: this.newName,
             fields_attributes: this.newFields.filter(el => el != null),
-            default_scope: this.newDefaultScope,
-            no_rich_text: this.newNoRichText,
-            hide_follows: this.hideFollows,
-            hide_followers: this.hideFollowers,
-            discoverable: this.discoverable,
             bot: this.bot,
-            allow_following_move: this.allowFollowingMove,
-            hide_follows_count: this.hideFollowsCount,
-            hide_followers_count: this.hideFollowersCount,
             show_role: this.showRole
             /* eslint-enable camelcase */
           } }).then((user) => {

+ 61 - 60
src/components/settings_modal/tabs/profile_tab.vue

@@ -25,61 +25,6 @@
           class="bio resize-height"
         />
       </EmojiInput>
-      <p>
-        <Checkbox v-model="newLocked">
-          {{ $t('settings.lock_account_description') }}
-        </Checkbox>
-      </p>
-      <div>
-        <label for="default-vis">{{ $t('settings.default_vis') }}</label>
-        <div
-          id="default-vis"
-          class="visibility-tray"
-        >
-          <scope-selector
-            :show-all="true"
-            :user-default="newDefaultScope"
-            :initial-scope="newDefaultScope"
-            :on-scope-change="changeVis"
-          />
-        </div>
-      </div>
-      <p>
-        <Checkbox v-model="newNoRichText">
-          {{ $t('settings.no_rich_text_description') }}
-        </Checkbox>
-      </p>
-      <p>
-        <Checkbox v-model="hideFollows">
-          {{ $t('settings.hide_follows_description') }}
-        </Checkbox>
-      </p>
-      <p class="setting-subitem">
-        <Checkbox
-          v-model="hideFollowsCount"
-          :disabled="!hideFollows"
-        >
-          {{ $t('settings.hide_follows_count_description') }}
-        </Checkbox>
-      </p>
-      <p>
-        <Checkbox v-model="hideFollowers">
-          {{ $t('settings.hide_followers_description') }}
-        </Checkbox>
-      </p>
-      <p class="setting-subitem">
-        <Checkbox
-          v-model="hideFollowersCount"
-          :disabled="!hideFollowers"
-        >
-          {{ $t('settings.hide_followers_count_description') }}
-        </Checkbox>
-      </p>
-      <p>
-        <Checkbox v-model="allowFollowingMove">
-          {{ $t('settings.allow_following_move') }}
-        </Checkbox>
-      </p>
       <p v-if="role === 'admin' || role === 'moderator'">
         <Checkbox v-model="showRole">
           <template v-if="role === 'admin'">
@@ -90,11 +35,6 @@
           </template>
         </Checkbox>
       </p>
-      <p>
-        <Checkbox v-model="discoverable">
-          {{ $t('settings.discoverable') }}
-        </Checkbox>
-      </p>
       <div v-if="maxFields > 0">
         <p>{{ $t('settings.profile_fields.label') }}</p>
         <div
@@ -269,6 +209,67 @@
         {{ $t('settings.save') }}
       </button>
     </div>
+    <div class="setting-item">
+      <h2>{{ $t('settings.account_privacy') }}</h2>
+      <ul class="setting-list">
+        <li>
+          <BooleanSetting path="serverSide_locked">
+            {{ $t('settings.lock_account_description') }}
+          </BooleanSetting>
+        </li>
+        <li>
+          <BooleanSetting path="serverSide_discoverable">
+            {{ $t('settings.discoverable') }}
+          </BooleanSetting>
+        </li>
+        <li>
+          <BooleanSetting path="serverSide_allowFollowingMove">
+            {{ $t('settings.allow_following_move') }}
+          </BooleanSetting>
+        </li>
+        <li>
+          <BooleanSetting path="serverSide_hideFavorites">
+            {{ $t('settings.hide_favorites_description') }}
+          </BooleanSetting>
+        </li>
+        <li>
+          <BooleanSetting path="serverSide_hideFollowers">
+            {{ $t('settings.hide_followers_description') }}
+          </BooleanSetting>
+          <ul
+            class="setting-list suboptions"
+            :class="[{disabled: !serverSide_hideFollowers}]"
+          >
+            <li>
+              <BooleanSetting
+                path="serverSide_hideFollowersCount"
+                :disabled="!serverSide_hideFollowers"
+              >
+                {{ $t('settings.hide_followers_count_description') }}
+              </BooleanSetting>
+            </li>
+          </ul>
+        </li>
+        <li>
+          <BooleanSetting path="serverSide_hideFollows">
+            {{ $t('settings.hide_follows_description') }}
+          </BooleanSetting>
+          <ul
+            class="setting-list suboptions"
+            :class="[{disabled: !serverSide_hideFollows}]"
+          >
+            <li>
+              <BooleanSetting
+                path="serverSide_hideFollowsCount"
+                :disabled="!serverSide_hideFollows"
+              >
+                {{ $t('settings.hide_follows_count_description') }}
+              </BooleanSetting>
+            </li>
+          </ul>
+        </li>
+      </ul>
+    </div>
   </div>
 </template>
 

+ 4 - 0
src/components/settings_modal/tabs/theme_tab/theme_tab.js

@@ -378,6 +378,10 @@ export default {
         // To separate from other random JSON files and possible future source formats
         _pleroma_theme_version: 2, theme, source
       }
+    },
+    isActive () {
+      const tabSwitcher = this.$parent
+      return tabSwitcher ? tabSwitcher.isActive('theme') : false
     }
   },
   components: {

+ 16 - 14
src/components/settings_modal/tabs/theme_tab/theme_tab.scss

@@ -268,13 +268,6 @@
     }
   }
 
-  .apply-container {
-    justify-content: center;
-    position: absolute;
-    bottom: 8px;
-    right: 5px;
-  }
-
   .radius-item,
   .color-item {
     min-width: 20em;
@@ -334,16 +327,25 @@
     padding: 20px;
   }
 
+  .btn {
+    margin-left: .25em;
+    margin-right: .25em;
+  }
+}
+
+.extra-content {
   .apply-container {
+    display: flex;
+    flex-direction: row;
+    justify-content: space-around;
+    flex-grow: 1;
+
     .btn {
+      flex-grow: 1;
       min-height: 28px;
-      min-width: 10em;
-      padding: 0 2em;
+      min-width: 0;
+      max-width: 10em;
+      padding: 0;
     }
   }
-
-  .btn {
-    margin-left: .25em;
-    margin-right: .25em;
-  }
 }

+ 20 - 15
src/components/settings_modal/tabs/theme_tab/theme_tab.vue

@@ -1016,21 +1016,26 @@
       </tab-switcher>
     </keep-alive>
 
-    <div class="apply-container">
-      <button
-        class="btn button-default submit"
-        :disabled="!themeValid"
-        @click="setCustomTheme"
-      >
-        {{ $t('general.apply') }}
-      </button>
-      <button
-        class="btn button-default"
-        @click="clearAll"
-      >
-        {{ $t('settings.style.switcher.reset') }}
-      </button>
-    </div>
+    <portal
+      v-if="isActive"
+      to="unscrolled-content"
+    >
+      <div class="apply-container">
+        <button
+          class="btn button-default submit"
+          :disabled="!themeValid"
+          @click="setCustomTheme"
+        >
+          {{ $t('general.apply') }}
+        </button>
+        <button
+          class="btn button-default"
+          @click="clearAll"
+        >
+          {{ $t('settings.style.switcher.reset') }}
+        </button>
+      </div>
+    </portal>
   </div>
 </template>
 

+ 105 - 13
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
     },
@@ -166,6 +225,18 @@ const Status = {
     muteWordHits () {
       return muteWordHits(this.status, this.muteWords)
     },
+    rtBotStatus () {
+      return this.statusoid.user.bot
+    },
+    botStatus () {
+      return this.status.user.bot
+    },
+    botIndicator () {
+      return this.botStatus && !this.hideBotIndication
+    },
+    rtBotIndicator () {
+      return this.rtBotStatus && !this.hideBotIndication
+    },
     mentionsLine () {
       if (!this.headTailLinks) return []
       const writtenSet = new Set(this.headTailLinks.writtenMentions.map(_ => _.url))
@@ -191,7 +262,9 @@ const Status = {
         // Thread is muted
         status.thread_muted ||
         // Wordfiltered
-        this.muteWordHits.length > 0
+        this.muteWordHits.length > 0 ||
+        // bot status
+        (this.muteBotStatuses && this.botStatus && !this.compact)
       return !this.unmuted && !this.shouldNotMute && reasonsToMute
     },
     userIsMuted () {
@@ -293,6 +366,12 @@ const Status = {
     hidePostStats () {
       return this.mergedConfig.hidePostStats
     },
+    muteBotStatuses () {
+      return this.mergedConfig.muteBotStatuses
+    },
+    hideBotIndication () {
+      return this.mergedConfig.hideBotIndication
+    },
     currentUser () {
       return this.$store.state.users.currentUser
     },
@@ -304,6 +383,12 @@ const Status = {
     },
     isSuspendable () {
       return !this.replying && this.mediaPlaying.length === 0
+    },
+    inThreadForest () {
+      return !!this.controlledThreadDisplayStatus
+    },
+    threadShowing () {
+      return this.controlledThreadDisplayStatus === 'showing'
     }
   },
   methods: {
@@ -326,7 +411,7 @@ const Status = {
       this.error = undefined
     },
     toggleReplying () {
-      this.replying = !this.replying
+      controlledOrUncontrolledToggle(this, 'replying')
     },
     gotoOriginal (id) {
       if (this.inConversation) {
@@ -346,17 +431,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) {
@@ -370,6 +457,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

+ 11 - 20
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 {
@@ -415,13 +406,13 @@ $status-margin: 0.75em;
       margin-left: 20px;
     }
 
-    .avatar:not(.repeater-avatar) {
+    .post-avatar {
       width: 40px;
       height: 40px;
 
       // TODO define those other way somehow?
       // stylelint-disable rscss/class-format
-      &.avatar-compact {
+      &.-compact {
         width: 32px;
         height: 32px;
       }

+ 53 - 2
src/components/status/status.vue

@@ -77,6 +77,7 @@
         <UserAvatar
           v-if="retweet"
           class="left-side repeater-avatar"
+          :bot="rtBotIndicator"
           :better-shadow="betterShadow"
           :user="statusoid.user"
         />
@@ -124,6 +125,8 @@
             @click.stop.prevent.capture.native="toggleUserExpanded"
           >
             <UserAvatar
+              class="post-avatar"
+              :bot="botIndicator"
               :compact="compact"
               :better-shadow="betterShadow"
               :user="status.user"
@@ -219,6 +222,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
@@ -306,6 +334,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"
@@ -315,7 +349,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"
@@ -407,7 +454,11 @@
         class="gravestone"
       >
         <div class="left-side">
-          <UserAvatar :compact="compact" />
+          <UserAvatar
+            class="post-avatar"
+            :compact="compact"
+            :bot="botIndicator"
+          />
         </div>
         <div class="right-side">
           <div class="deleted-text">

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

+ 1 - 0
src/components/still-image/still-image.vue

@@ -19,6 +19,7 @@
       @load="onLoad"
       @error="onError"
     >
+    <slot />
   </div>
 </template>
 

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

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

+ 6 - 0
src/components/tab_switcher/tab_switcher.js

@@ -47,6 +47,12 @@ export default Vue.component('tab-switcher', {
         return this.active
       }
     },
+    isActive () {
+      return tabName => {
+        const isWanted = slot => slot.data && slot.data.attrs['data-tab-name'] === tabName
+        return this.$slots.default.findIndex(isWanted) === this.activeIndex
+      }
+    },
     settingsModalVisible () {
       return this.settingsModalState === 'visible'
     },

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

+ 7 - 0
src/components/timeline/timeline_quick_settings.js

@@ -53,6 +53,13 @@ const TimelineQuickSettings = {
         const value = !this.hideMutedPosts
         this.$store.dispatch('setOption', { name: 'hideFilteredStatuses', value })
       }
+    },
+    muteBotStatuses: {
+      get () { return this.mergedConfig.muteBotStatuses },
+      set () {
+        const value = !this.muteBotStatuses
+        this.$store.dispatch('setOption', { name: 'muteBotStatuses', value })
+      }
     }
   }
 }

+ 9 - 0
src/components/timeline/timeline_quick_settings.vue

@@ -39,6 +39,15 @@
             class="dropdown-divider"
           />
         </div>
+        <button
+          class="button-default dropdown-item"
+          @click="muteBotStatuses = !muteBotStatuses"
+        >
+          <span
+            class="menu-checkbox"
+            :class="{ 'menu-checkbox-checked': muteBotStatuses }"
+          />{{ $t('settings.mute_bot_posts') }}
+        </button>
         <button
           class="button-default dropdown-item"
           @click="hideMedia = !hideMedia"

+ 12 - 1
src/components/user_avatar/user_avatar.js

@@ -1,10 +1,21 @@
 import StillImage from '../still-image/still-image.vue'
 
+import { library } from '@fortawesome/fontawesome-svg-core'
+
+import {
+  faRobot
+} from '@fortawesome/free-solid-svg-icons'
+
+library.add(
+  faRobot
+)
+
 const UserAvatar = {
   props: [
     'user',
     'betterShadow',
-    'compact'
+    'compact',
+    'bot'
   ],
   data () {
     return {

+ 66 - 32
src/components/user_avatar/user_avatar.vue

@@ -1,18 +1,28 @@
 <template>
-  <StillImage
-    v-if="user"
+  <span
     class="Avatar"
-    :alt="user.screen_name_ui"
-    :title="user.screen_name_ui"
-    :src="imgSrc(user.profile_image_url_original)"
-    :class="{ 'avatar-compact': compact, 'better-shadow': betterShadow }"
-    :image-load-error="imageLoadError"
-  />
-  <div
-    v-else
-    class="Avatar -placeholder"
-    :class="{ 'avatar-compact': compact }"
-  />
+    :class="{ '-compact': compact }"
+    >
+    <StillImage
+      v-if="user"
+      class="avatar"
+      :alt="user.screen_name_ui"
+      :title="user.screen_name_ui"
+      :src="imgSrc(user.profile_image_url_original)"
+      :image-load-error="imageLoadError"
+      :class="{ '-compact': compact, '-better-shadow': betterShadow }"
+    />
+    <div
+      v-else
+      class="avatar -placeholder"
+      :class="{ '-compact': compact }"
+    />
+    <FAIcon
+      v-if="bot"
+      icon="robot"
+      class="bot-indicator"
+    />
+  </span>
 </template>
 
 <script src="./user_avatar.js"></script>
@@ -25,36 +35,60 @@
   --_avatarShadowInset: var(--avatarStatusShadowInset);
   --_still-image-label-visibility: hidden;
 
+  display: inline-block;
+  position: relative;
   width: 48px;
   height: 48px;
-  box-shadow: var(--_avatarShadowBox);
-  border-radius: $fallback--avatarRadius;
-  border-radius: var(--avatarRadius, $fallback--avatarRadius);
 
-  img {
+  &.-compact {
+    width: 32px;
+    height: 32px;
+    border-radius: $fallback--avatarAltRadius;
+    border-radius: var(--avatarAltRadius, $fallback--avatarAltRadius);
+  }
+
+  .avatar {
     width: 100%;
     height: 100%;
-  }
+    box-shadow: var(--_avatarShadowBox);
+    border-radius: $fallback--avatarRadius;
+    border-radius: var(--avatarRadius, $fallback--avatarRadius);
 
-  &.better-shadow {
-    box-shadow: var(--_avatarShadowInset);
-    filter: var(--_avatarShadowFilter);
-  }
+    &.-better-shadow {
+      box-shadow: var(--_avatarShadowInset);
+      filter: var(--_avatarShadowFilter);
+    }
+
+    &.-animated::before {
+      display: none;
+    }
 
-  &.animated::before {
-    display: none;
+    &.-compact {
+      border-radius: $fallback--avatarAltRadius;
+      border-radius: var(--avatarAltRadius, $fallback--avatarAltRadius);
+    }
+
+    &.-placeholder {
+      background-color: $fallback--fg;
+      background-color: var(--fg, $fallback--fg);
+    }
   }
 
-  &.avatar-compact {
-    width: 32px;
-    height: 32px;
-    border-radius: $fallback--avatarAltRadius;
-    border-radius: var(--avatarAltRadius, $fallback--avatarAltRadius);
+  img {
+    width: 100%;
+    height: 100%;
   }
 
-  &.-placeholder {
-    background-color: $fallback--fg;
-    background-color: var(--fg, $fallback--fg);
+  .bot-indicator {
+    position: absolute;
+    bottom: 0;
+    right: 0;
+    margin: -0.2em;
+    padding: 0.2em;
+    background: rgba(127, 127, 127, 0.5);
+    color: #fff;
+    border-radius: var(--tooltipRadius);
   }
+
 }
 </style>

+ 33 - 5
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",
@@ -259,11 +260,14 @@
   },
   "settings": {
     "app_name": "App name",
+    "expert_mode": "Show advanced",
     "save": "Save changes",
     "security": "Security",
     "setting_changed": "Setting is different from default",
+    "setting_server_side": "This setting is tied to your profile and affects all sessions and clients",
     "enter_current_password_to_confirm": "Enter your current password to confirm your identity",
     "post_look_feel": "Posts Look & Feel",
+    "mention_links": "Mention links",
     "mfa": {
       "otp": "OTP",
       "setup_otp": "Setup OTP",
@@ -351,6 +355,8 @@
     "hide_attachments_in_tl": "Hide attachments in timeline",
     "hide_media_previews": "Hide media previews",
     "hide_muted_posts": "Hide posts of muted users",
+    "mute_bot_posts": "Mute bot posts",
+    "hide_bot_indication": "Hide bot indication in posts",
     "hide_all_muted_posts": "Hide muted posts",
     "max_thumbnails": "Maximum amount of thumbnails per post (empty = no limit)",
     "hide_isp": "Hide instance-specific panel",
@@ -400,6 +406,7 @@
       "name": "Label",
       "value": "Content"
     },
+    "account_privacy": "Privacy",
     "use_contain_fit": "Don't crop the attachment in thumbnails",
     "name": "Name",
     "name_bio": "Name & bio",
@@ -417,6 +424,7 @@
     "no_rich_text_description": "Strip rich text formatting from all posts",
     "no_blocks": "No blocks",
     "no_mutes": "No mutes",
+    "hide_favorites_description": "Don't show list of my favorites (people still get notified)",
     "hide_follows_description": "Don't show who I'm following",
     "hide_followers_description": "Don't show who's following me",
     "hide_follows_count_description": "Don't show follow count",
@@ -430,7 +438,7 @@
     "valid_until": "Valid until",
     "revoke_token": "Revoke",
     "panelRadius": "Panels",
-    "pause_on_unfocused": "Pause streaming when tab is not focused",
+    "pause_on_unfocused": "Pause when tab is not focused",
     "presets": "Presets",
     "profile_background": "Profile background",
     "profile_banner": "Profile banner",
@@ -465,13 +473,21 @@
     "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",
-    "streaming": "Enable automatic streaming of new posts when scrolled to the top",
+    "streaming": "Automatically show new posts when scrolled to the top",
     "user_mutes": "Users",
     "useStreamingApi": "Receive posts and notifications real-time",
-    "useStreamingApiWarning": "(Not recommended, experimental, known to skip posts)",
     "text": "Text",
     "theme": "Theme",
     "theme_help": "Use hex color codes (#rrggbb) to customize your color theme.",
@@ -721,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",
@@ -747,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",

+ 4 - 0
src/main.js

@@ -11,6 +11,7 @@ import statusesModule from './modules/statuses.js'
 import usersModule from './modules/users.js'
 import apiModule from './modules/api.js'
 import configModule from './modules/config.js'
+import serverSideConfigModule from './modules/serverSideConfig.js'
 import shoutModule from './modules/shout.js'
 import oauthModule from './modules/oauth.js'
 import authFlowModule from './modules/auth_flow.js'
@@ -45,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)
 
@@ -88,6 +91,7 @@ const persistedStateOptions = {
       users: usersModule,
       api: apiModule,
       config: configModule,
+      serverSideConfig: serverSideConfigModule,
       shout: shoutModule,
       oauth: oauthModule,
       authFlow: authFlowModule,

+ 13 - 3
src/modules/config.js

@@ -12,10 +12,13 @@ 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
 ]
 
 export const defaultState = {
+  expertLevel: 0, // used to track which settings to show and hide
   colors: {},
   theme: undefined,
   customTheme: undefined,
@@ -27,6 +30,7 @@ export const defaultState = {
   hideMutedPosts: undefined, // instance default
   hideMutedThreads: undefined, // instance default
   hideWordFilteredPosts: undefined, // instance default
+  muteBotStatuses: undefined, // instance default
   collapseMessageWithSubject: undefined, // instance default
   padEmoji: true,
   hideAttachments: false,
@@ -41,7 +45,7 @@ export const defaultState = {
   alwaysShowNewPostButton: false,
   autohideFloatingPostButton: false,
   pauseOnUnfocused: true,
-  stopGifs: false,
+  stopGifs: true,
   replyVisibility: 'all',
   notificationVisibility: {
     follows: true,
@@ -69,7 +73,7 @@ export const defaultState = {
   hideFilteredStatuses: undefined, // instance default
   playVideosInModal: false,
   useOneClickNsfw: false,
-  useContainFit: false,
+  useContainFit: true,
   greentext: undefined, // instance default
   useAtIcon: undefined, // instance default
   mentionLinkDisplay: undefined, // instance default
@@ -79,9 +83,15 @@ export const defaultState = {
   mentionLinkShowYous: undefined, // instance default
   mentionLinkBoldenYou: undefined, // instance default
   hidePostStats: undefined, // instance default
+  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

+ 7 - 0
src/modules/instance.js

@@ -33,8 +33,10 @@ const defaultState = {
   hideMutedThreads: true,
   hideWordFilteredPosts: false,
   hidePostStats: false,
+  hideBotIndication: false,
   hideSitename: false,
   hideUserStats: false,
+  muteBotStatuses: false,
   loginMethod: 'password',
   logo: '/static/logo.svg',
   logoMargin: '.2em',
@@ -53,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: [],

+ 137 - 0
src/modules/serverSideConfig.js

@@ -0,0 +1,137 @@
+import { get, set } from 'lodash'
+
+const defaultApi = ({ rootState, commit }, { path, value }) => {
+  const params = {}
+  set(params, path, value)
+  return rootState
+    .api
+    .backendInteractor
+    .updateProfile({ params })
+    .then(result => {
+      commit('addNewUsers', [result])
+      commit('setCurrentUser', result)
+    })
+}
+
+const notificationsApi = ({ rootState, commit }, { path, value, oldValue }) => {
+  const settings = {}
+  set(settings, path, value)
+  return rootState
+    .api
+    .backendInteractor
+    .updateNotificationSettings({ settings })
+    .then(result => {
+      if (result.status === 'success') {
+        commit('confirmServerSideOption', { name, value })
+      } else {
+        commit('confirmServerSideOption', { name, value: oldValue })
+      }
+    })
+}
+
+/**
+ * Map that stores relation between path for reading (from user profile),
+ * for writing (into API) an what API to use.
+ *
+ * Shorthand - instead of { get, set, api? } object it's possible to use string
+ * in case default api is used and get = set
+ *
+ * If no api is specified, defaultApi is used (see above)
+ */
+export const settingsMap = {
+  'defaultScope': 'source.privacy',
+  'defaultNSFW': 'source.sensitive', // BROKEN: pleroma/pleroma#2837
+  'stripRichContent': {
+    get: 'source.pleroma.no_rich_text',
+    set: 'no_rich_text'
+  },
+  // Privacy
+  'locked': 'locked',
+  'acceptChatMessages': {
+    get: 'pleroma.accepts_chat_messages',
+    set: 'accepts_chat_messages'
+  },
+  'allowFollowingMove': {
+    get: 'pleroma.allow_following_move',
+    set: 'allow_following_move'
+  },
+  'discoverable': 'source.discoverable',
+  'hideFavorites': {
+    get: 'pleroma.hide_favorites',
+    set: 'hide_favorites'
+  },
+  'hideFollowers': {
+    get: 'pleroma.hide_followers',
+    set: 'hide_followers'
+  },
+  'hideFollows': {
+    get: 'pleroma.hide_follows',
+    set: 'hide_follows'
+  },
+  'hideFollowersCount': {
+    get: 'pleroma.hide_followers_count',
+    set: 'hide_followers_count'
+  },
+  'hideFollowsCount': {
+    get: 'pleroma.hide_follows_count',
+    set: 'hide_follows_count'
+  },
+  // NotificationSettingsAPIs
+  'webPushHideContents': {
+    get: 'pleroma.notification_settings.hide_notification_contents',
+    set: 'hide_notification_contents',
+    api: notificationsApi
+  },
+  'blockNotificationsFromStrangers': {
+    get: 'pleroma.notification_settings.block_from_strangers',
+    set: 'block_from_strangers',
+    api: notificationsApi
+  }
+}
+
+export const defaultState = Object.fromEntries(Object.keys(settingsMap).map(key => [key, null]))
+
+const serverSideConfig = {
+  state: { ...defaultState },
+  mutations: {
+    confirmServerSideOption (state, { name, value }) {
+      set(state, name, value)
+    },
+    wipeServerSideOption (state, { name }) {
+      set(state, name, null)
+    },
+    wipeAllServerSideOptions (state) {
+      Object.keys(settingsMap).forEach(key => {
+        set(state, key, null)
+      })
+    },
+    // Set the settings based on their path location
+    setCurrentUser (state, user) {
+      Object.entries(settingsMap).forEach((map) => {
+        const [name, value] = map
+        const { get: path = value } = value
+        set(state, name, get(user._original, path))
+      })
+    }
+  },
+  actions: {
+    setServerSideOption ({ rootState, state, commit, dispatch }, { name, value }) {
+      const oldValue = get(state, name)
+      const map = settingsMap[name]
+      if (!map) throw new Error('Invalid server-side setting')
+      const { set: path = map, api = defaultApi } = map
+      commit('wipeServerSideOption', { name })
+
+      api({ rootState, commit }, { path, value, oldValue })
+        .catch((e) => {
+          console.warn('Error setting server-side option:', e)
+          commit('confirmServerSideOption', { name, value: oldValue })
+        })
+    },
+    logout ({ commit }) {
+      commit('wipeAllServerSideOptions')
+    }
+  }
+}
+
+export default serverSideConfig

+ 1 - 0
src/services/entity_normalizer/entity_normalizer.service.js

@@ -44,6 +44,7 @@ export const parseUser = (data) => {
   const mastoShort = masto && !data.hasOwnProperty('avatar')
 
   output.id = String(data.id)
+  output._original = data // used for server-side settings
 
   if (masto) {
     output.screen_name = data.acct

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

File diff ditekan karena terlalu besar
+ 755 - 482
yarn.lock


Beberapa file tidak ditampilkan karena terlalu banyak file yang berubah dalam diff ini