Browse Source

Merge branch 'develop' of https://git.pleroma.social/pleroma/pleroma-fe into fine_grained_moderation_privileges

Ilja 2 years ago
parent
commit
5541d0ec29
100 changed files with 2998 additions and 497 deletions
  1. 1 1
      .babelrc
  2. 1 0
      .gitignore
  3. 2 0
      CONTRIBUTORS.md
  4. 3 0
      build/build.js
  5. 16 11
      build/dev-server.js
  6. 27 0
      build/update-emoji.js
  7. 15 17
      build/webpack.base.conf.js
  8. 1 1
      build/webpack.dev.conf.js
  9. 8 5
      build/webpack.prod.conf.js
  10. 57 56
      package.json
  11. 17 0
      src/App.js
  12. 54 13
      src/App.scss
  13. 8 2
      src/App.vue
  14. 17 0
      src/_mixins.scss
  15. BIN
      src/assets/pleromatan_apology.png
  16. BIN
      src/assets/pleromatan_apology_fox.png
  17. BIN
      src/assets/pleromatan_apology_fox_mask.png
  18. BIN
      src/assets/pleromatan_apology_mask.png
  19. 4 1
      src/boot/after_store.js
  20. 12 2
      src/boot/routes.js
  21. 6 1
      src/components/account_actions/account_actions.js
  22. 8 0
      src/components/account_actions/account_actions.vue
  23. 3 0
      src/components/attachment/attachment.js
  24. 3 1
      src/components/basic_user_card/basic_user_card.js
  25. 3 5
      src/components/basic_user_card/basic_user_card.vue
  26. 6 4
      src/components/chat/chat.js
  27. 20 2
      src/components/conversation/conversation.js
  28. 8 0
      src/components/conversation/conversation.vue
  29. 24 0
      src/components/desktop_nav/desktop_nav.scss
  30. 1 0
      src/components/desktop_nav/desktop_nav.vue
  31. 75 0
      src/components/edit_status_modal/edit_status_modal.js
  32. 48 0
      src/components/edit_status_modal/edit_status_modal.vue
  33. 50 4
      src/components/emoji_input/emoji_input.js
  34. 16 1
      src/components/emoji_input/emoji_input.vue
  35. 23 22
      src/components/emoji_input/suggestor.js
  36. 210 92
      src/components/emoji_picker/emoji_picker.js
  37. 44 8
      src/components/emoji_picker/emoji_picker.scss
  38. 38 14
      src/components/emoji_picker/emoji_picker.vue
  39. 43 3
      src/components/extra_buttons/extra_buttons.js
  40. 58 4
      src/components/extra_buttons/extra_buttons.vue
  41. 10 2
      src/components/favorite_button/favorite_button.js
  42. 46 5
      src/components/favorite_button/favorite_button.vue
  43. 3 1
      src/components/follow_card/follow_card.js
  44. 11 0
      src/components/follow_card/follow_card.vue
  45. 2 2
      src/components/global_notice_list/global_notice_list.vue
  46. 4 1
      src/components/interactions/interactions.js
  47. 9 0
      src/components/interactions/interactions.vue
  48. 27 0
      src/components/lists/lists.js
  49. 33 0
      src/components/lists/lists.vue
  50. 16 0
      src/components/lists_card/lists_card.js
  51. 51 0
      src/components/lists_card/lists_card.vue
  52. 145 0
      src/components/lists_edit/lists_edit.js
  53. 228 0
      src/components/lists_edit/lists_edit.vue
  54. 22 0
      src/components/lists_menu/lists_menu_content.js
  55. 12 0
      src/components/lists_menu/lists_menu_content.vue
  56. 36 0
      src/components/lists_timeline/lists_timeline.js
  57. 10 0
      src/components/lists_timeline/lists_timeline.vue
  58. 51 0
      src/components/lists_user_search/lists_user_search.js
  59. 47 0
      src/components/lists_user_search/lists_user_search.vue
  60. 2 0
      src/components/mention_link/mention_link.js
  61. 3 0
      src/components/mention_link/mention_link.vue
  62. 7 2
      src/components/mobile_nav/mobile_nav.js
  63. 16 13
      src/components/mobile_nav/mobile_nav.vue
  64. 2 1
      src/components/mobile_post_status_button/mobile_post_status_button.js
  65. 70 10
      src/components/nav_panel/nav_panel.js
  66. 91 125
      src/components/nav_panel/nav_panel.vue
  67. 18 0
      src/components/navigation/filter.js
  68. 75 0
      src/components/navigation/navigation.js
  69. 51 0
      src/components/navigation/navigation_entry.js
  70. 133 0
      src/components/navigation/navigation_entry.vue
  71. 88 0
      src/components/navigation/navigation_pins.js
  72. 76 0
      src/components/navigation/navigation_pins.vue
  73. 5 1
      src/components/notification/notification.js
  74. 17 11
      src/components/notification/notification.vue
  75. 4 2
      src/components/notifications/notifications.scss
  76. 23 0
      src/components/optional_router_link/optional_router_link.vue
  77. 23 2
      src/components/popover/popover.js
  78. 7 0
      src/components/popover/popover.vue
  79. 41 13
      src/components/post_status_form/post_status_form.js
  80. 18 0
      src/components/post_status_form/post_status_form.vue
  81. 5 2
      src/components/quick_filter_settings/quick_filter_settings.js
  82. 8 10
      src/components/quick_filter_settings/quick_filter_settings.vue
  83. 69 0
      src/components/quick_view_settings/quick_view_settings.js
  84. 94 0
      src/components/quick_view_settings/quick_view_settings.vue
  85. 17 4
      src/components/react_button/react_button.js
  86. 36 5
      src/components/react_button/react_button.vue
  87. 25 0
      src/components/remove_follower_button/remove_follower_button.js
  88. 13 0
      src/components/remove_follower_button/remove_follower_button.vue
  89. 10 2
      src/components/reply_button/reply_button.js
  90. 31 4
      src/components/reply_button/reply_button.vue
  91. 34 0
      src/components/report/report.js
  92. 43 0
      src/components/report/report.scss
  93. 74 0
      src/components/report/report.vue
  94. 12 2
      src/components/retweet_button/retweet_button.js
  95. 46 5
      src/components/retweet_button/retweet_button.vue
  96. 2 0
      src/components/search_bar/search_bar.vue
  97. 3 0
      src/components/settings_modal/helpers/boolean_setting.js
  98. 6 1
      src/components/settings_modal/helpers/boolean_setting.vue
  99. 3 0
      src/components/settings_modal/helpers/choice_setting.js
  100. 4 1
      src/components/settings_modal/helpers/choice_setting.vue

+ 1 - 1
.babelrc

@@ -1,5 +1,5 @@
 {
   "presets": ["@babel/preset-env"],
   "plugins": ["@babel/plugin-transform-runtime", "lodash", "@vue/babel-plugin-jsx"],
-  "comments": false
+  "comments": true
 }

+ 1 - 0
.gitignore

@@ -7,3 +7,4 @@ test/e2e/reports
 selenium-debug.log
 .idea/
 config/local.json
+static/emoji.json

+ 2 - 0
CONTRIBUTORS.md

@@ -10,3 +10,5 @@ Contributors of this project.
 - shpuld (shpuld@shitposter.club): CSS and styling
 - Vincent Guth (https://unsplash.com/photos/XrwVIFy6rTw): Background images.
 - hj (hj@shigusegubu.club): Code
+- Sean King (seanking@freespeechextremist.com): Code
+- Tusooa Zhu (tusooa@kazv.moe): Code

+ 3 - 0
build/build.js

@@ -18,6 +18,9 @@ console.log(
 var spinner = ora('building for production...')
 spinner.start()
 
+var updateEmoji = require('./update-emoji').updateEmoji
+updateEmoji()
+
 var assetsPath = path.join(config.build.assetsRoot, config.build.assetsSubDirectory)
 rm('-rf', assetsPath)
 mkdir('-p', assetsPath)

+ 16 - 11
build/dev-server.js

@@ -10,6 +10,9 @@ var webpackConfig = process.env.NODE_ENV === 'testing'
   ? require('./webpack.prod.conf')
   : require('./webpack.dev.conf')
 
+var updateEmoji = require('./update-emoji').updateEmoji
+updateEmoji()
+
 // default port where dev server listens for incoming traffic
 var port = process.env.PORT || config.dev.port
 // Define HTTP proxies to your custom API backend
@@ -29,18 +32,20 @@ var devMiddleware = require('webpack-dev-middleware')(compiler, {
 })
 
 var hotMiddleware = require('webpack-hot-middleware')(compiler)
+
+// FIXME: The statement below gives error about hooks being required in webpack 5.
 // force page reload when html-webpack-plugin template changes
-compiler.plugin('compilation', function (compilation) {
-  compilation.plugin('html-webpack-plugin-after-emit', function (data, cb) {
-    // FIXME: This supposed to reload whole page when index.html is changed,
-    // however now it reloads entire page on every breath, i suppose the order
-    // of plugins changed or something. It's a minor thing and douesn't hurt
-    // disabling it, constant reloads hurt much more
+// compiler.plugin('compilation', function (compilation) {
+//   compilation.plugin('html-webpack-plugin-after-emit', function (data, cb) {
+//     // FIXME: This supposed to reload whole page when index.html is changed,
+//     // however now it reloads entire page on every breath, i suppose the order
+//     // of plugins changed or something. It's a minor thing and douesn't hurt
+//     // disabling it, constant reloads hurt much more
 
-    // hotMiddleware.publish({ action: 'reload' })
-    // cb()
-  })
-})
+//     // hotMiddleware.publish({ action: 'reload' })
+//     // cb()
+//   })
+// })
 
 // proxy api requests
 Object.keys(proxyTable).forEach(function (context) {
@@ -48,7 +53,7 @@ Object.keys(proxyTable).forEach(function (context) {
   if (typeof options === 'string') {
     options = { target: options }
   }
-  app.use(proxyMiddleware(context, options))
+  app.use(proxyMiddleware.createProxyMiddleware(context, options))
 })
 
 // handle fallback for HTML5 history API

+ 27 - 0
build/update-emoji.js

@@ -0,0 +1,27 @@
+
+module.exports = {
+  updateEmoji () {
+    const emojis = require('@kazvmoe-infra/unicode-emoji-json/data-by-group')
+    const fs = require('fs')
+
+    Object.keys(emojis)
+      .map(k => {
+        emojis[k].map(e => {
+          delete e.unicode_version
+          delete e.emoji_version
+          delete e.skin_tone_support_unicode_version
+        })
+      })
+
+    const res = {}
+    Object.keys(emojis)
+      .map(k => {
+        const groupId = k.replace('&', 'and').replace(/ /g, '-').toLowerCase()
+        res[groupId] = emojis[k]
+      })
+
+    console.info('Updating emojis...')
+    fs.writeFileSync('static/emoji.json', JSON.stringify(res))
+    console.info('Done.')
+  }
+}

+ 15 - 17
build/webpack.base.conf.js

@@ -2,7 +2,7 @@ var path = require('path')
 var config = require('../config')
 var utils = require('./utils')
 var projectRoot = path.resolve(__dirname, '../')
-var ServiceWorkerWebpackPlugin = require('serviceworker-webpack-plugin')
+var ServiceWorkerWebpackPlugin = require('serviceworker-webpack5-plugin')
 var CopyPlugin = require('copy-webpack-plugin');
 var { VueLoaderPlugin } = require('vue-loader')
 var ESLintPlugin = require('eslint-webpack-plugin');
@@ -24,7 +24,8 @@ module.exports = {
   output: {
     path: config.build.assetsRoot,
     publicPath: process.env.NODE_ENV === 'production' ? config.build.assetsPublicPath : config.dev.assetsPublicPath,
-    filename: '[name].js'
+    filename: '[name].js',
+    chunkFilename: '[name].js'
   },
   optimization: {
     splitChunks: {
@@ -42,6 +43,10 @@ module.exports = {
       'assets': path.resolve(__dirname, '../src/assets'),
       'components': path.resolve(__dirname, '../src/components'),
       'vue-i18n': 'vue-i18n/dist/vue-i18n.runtime.esm-bundler.js'
+    },
+    fallback: {
+      'querystring': require.resolve('querystring-es3'),
+      'url': require.resolve('url/')
     }
   },
   module: {
@@ -78,22 +83,16 @@ module.exports = {
       },
       {
         test: /\.(png|jpe?g|gif|svg)(\?.*)?$/,
-        use: {
-          loader: 'url-loader',
-          options: {
-            limit: 10000,
-            name: utils.assetsPath('img/[name].[hash:7].[ext]')
-          }
+        type: 'asset',
+        generator: {
+          filename: utils.assetsPath('img/[name].[hash:7][ext]')
         }
       },
       {
         test: /\.(woff2?|eot|ttf|otf)(\?.*)?$/,
-        use: {
-          loader: 'url-loader',
-          options: {
-            limit: 10000,
-            name: utils.assetsPath('fonts/[name].[hash:7].[ext]')
-          }
+        type: 'asset',
+        generator: {
+          filename: utils.assetsPath('fonts/[name].[hash:7][ext]')
         }
       },
       {
@@ -117,9 +116,8 @@ module.exports = {
     new CopyPlugin({
       patterns: [
         {
-          from: "node_modules/@ruffle-rs/ruffle/*",
-          to: "static/ruffle",
-          flatten: true
+          from: "node_modules/@ruffle-rs/ruffle/**/*",
+          to: "static/ruffle/[name][ext]"
         },
       ],
       options: {

+ 1 - 1
build/webpack.dev.conf.js

@@ -16,7 +16,7 @@ module.exports = merge(baseWebpackConfig, {
   },
   mode: 'development',
   // eval-source-map is faster for development
-  devtool: '#eval-source-map',
+  devtool: 'eval-source-map',
   plugins: [
     new webpack.DefinePlugin({
       'process.env': config.dev.env,

+ 8 - 5
build/webpack.prod.conf.js

@@ -5,6 +5,7 @@ var webpack = require('webpack')
 var merge = require('webpack-merge')
 var baseWebpackConfig = require('./webpack.base.conf')
 var MiniCssExtractPlugin = require('mini-css-extract-plugin')
+const CssMinimizerPlugin = require("css-minimizer-webpack-plugin")
 var HtmlWebpackPlugin = require('html-webpack-plugin')
 var env = process.env.NODE_ENV === 'testing'
     ? require('../config/test.env')
@@ -19,12 +20,16 @@ var webpackConfig = merge(baseWebpackConfig, {
   module: {
     rules: utils.styleLoaders({ sourceMap: config.dev.cssSourceMap, extract: true })
   },
-  devtool: config.build.productionSourceMap ? '#source-map' : false,
+  devtool: config.build.productionSourceMap ? 'source-map' : false,
   optimization: {
     minimize: true,
     splitChunks: {
       chunks: 'all'
-    }
+    },
+    minimizer: [
+      `...`,
+      new CssMinimizerPlugin()
+    ]
   },
   output: {
     path: config.build.assetsRoot,
@@ -60,9 +65,7 @@ var webpackConfig = merge(baseWebpackConfig, {
         ignoreCustomComments: [/server-generated-meta/]
         // more options:
         // https://github.com/kangax/html-minifier#options-quick-reference
-      },
-      // necessary to consistently work with multiple chunks via CommonsChunkPlugin
-      chunksSortMode: 'dependency'
+      }
     }),
     // split vendor js into its own file
     // extract webpack runtime and module manifest to its own file in order to

+ 57 - 56
package.json

@@ -18,13 +18,14 @@
   "dependencies": {
     "@babel/runtime": "7.18.9",
     "@chenfengyuan/vue-qrcode": "2.0.0",
-    "@fortawesome/fontawesome-svg-core": "6.1.2",
-    "@fortawesome/free-regular-svg-icons": "6.1.2",
-    "@fortawesome/free-solid-svg-icons": "6.1.2",
+    "@fortawesome/fontawesome-svg-core": "6.2.0",
+    "@fortawesome/free-regular-svg-icons": "6.2.0",
+    "@fortawesome/free-solid-svg-icons": "6.2.0",
     "@fortawesome/vue-fontawesome": "3.0.1",
     "@kazvmoe-infra/pinch-zoom-element": "1.2.0",
-    "@ruffle-rs/ruffle": "^0.1.0-nightly.2022.7.12",
-    "@vuelidate/core": "2.0.0-alpha.43",
+    "@kazvmoe-infra/unicode-emoji-json": "^0.4.0",
+    "@ruffle-rs/ruffle": "0.1.0-nightly.2022.7.12",
+    "@vuelidate/core": "2.0.0-alpha.44",
     "@vuelidate/validators": "2.0.0-alpha.31",
     "body-scroll-lock": "3.1.5",
     "chromatism": "3.0.0",
@@ -32,95 +33,95 @@
     "cropperjs": "1.5.12",
     "diff": "3.5.0",
     "escape-html": "1.0.3",
-    "js-cookie": "^3.0.1",
+    "js-cookie": "3.0.1",
     "localforage": "1.10.0",
-    "parse-link-header": "1.0.1",
+    "lozad": "^1.16.0",
+    "parse-link-header": "2.0.0",
     "phoenix": "1.6.2",
     "punycode.js": "2.1.0",
-    "qrcode": "1",
-    "utf8": "^3.0.0",
-    "vue": "3.2.37",
-    "vue-i18n": "9.2.0",
-    "vue-router": "4.1.3",
-    "vue-template-compiler": "2.7.8",
+    "qrcode": "1.5.0",
+    "querystring-es3": "0.2.1",
+    "url": "0.11.0",
+    "utf8": "3.0.0",
+    "vue": "3.2.38",
+    "vue-i18n": "9.2.2",
+    "vue-router": "4.1.5",
+    "vue-template-compiler": "2.7.10",
     "vuex": "4.0.2"
   },
   "devDependencies": {
-    "@babel/core": "7.18.9",
-    "@babel/plugin-transform-runtime": "7.18.9",
-    "@babel/preset-env": "7.18.9",
-    "@babel/register": "7.18.9",
+    "@babel/core": "7.18.13",
     "@babel/eslint-parser": "7.18.9",
-    "@intlify/vue-i18n-loader": "^5.0.0",
+    "@babel/plugin-transform-runtime": "7.18.10",
+    "@babel/preset-env": "7.18.10",
+    "@babel/register": "7.18.9",
+    "@intlify/vue-i18n-loader": "5.0.0",
     "@ungap/event-target": "0.2.3",
-    "@vue/babel-helper-vue-jsx-merge-props": "1.2.1",
+    "@vue/babel-helper-vue-jsx-merge-props": "1.4.0",
     "@vue/babel-plugin-jsx": "1.1.1",
-    "@vue/compiler-sfc": "3.2.37",
+    "@vue/compiler-sfc": "3.2.38",
     "@vue/test-utils": "2.0.2",
-    "autoprefixer": "6.7.7",
+    "autoprefixer": "10.4.8",
     "babel-loader": "8.2.5",
     "babel-plugin-lodash": "3.3.4",
-    "chai": "3.5.0",
+    "chai": "4.3.6",
     "chalk": "1.1.3",
-    "chromedriver": "103.0.0",
-    "connect-history-api-fallback": "1.6.0",
-    "copy-webpack-plugin": "6.4.1",
-    "cross-spawn": "4.0.2",
-    "css-loader": "0.28.11",
+    "chromedriver": "104.0.0",
+    "connect-history-api-fallback": "2.0.0",
+    "copy-webpack-plugin": "11.0.0",
+    "cross-spawn": "7.0.3",
+    "css-loader": "6.7.1",
+    "css-minimizer-webpack-plugin": "4.0.0",
     "custom-event-polyfill": "1.0.7",
-    "eslint": "8.20.0",
+    "eslint": "8.23.0",
     "eslint-config-standard": "17.0.0",
     "eslint-formatter-friendly": "7.0.0",
-    "eslint-webpack-plugin": "2.7.0",
     "eslint-plugin-import": "2.26.0",
-    "eslint-plugin-n": "15.2.4",
-    "eslint-plugin-promise": "6.0.0",
-    "eslint-plugin-vue": "9.3.0",
+    "eslint-plugin-n": "15.2.5",
+    "eslint-plugin-promise": "6.0.1",
+    "eslint-plugin-vue": "9.4.0",
+    "eslint-webpack-plugin": "3.2.0",
     "eventsource-polyfill": "0.9.6",
     "express": "4.18.1",
-    "file-loader": "3.0.1",
     "function-bind": "1.1.1",
-    "html-webpack-plugin": "3.2.0",
-    "http-proxy-middleware": "0.21.0",
-    "inject-loader": "2.0.1",
+    "html-webpack-plugin": "5.5.0",
+    "http-proxy-middleware": "2.0.6",
     "iso-639-1": "2.1.15",
-    "isparta-loader": "2.0.0",
     "json-loader": "0.5.7",
     "karma": "6.4.0",
-    "karma-coverage": "1.1.2",
-    "karma-firefox-launcher": "1.3.0",
+    "karma-coverage": "2.2.0",
+    "karma-firefox-launcher": "2.1.2",
     "karma-mocha": "2.0.1",
     "karma-mocha-reporter": "2.2.5",
     "karma-sinon-chai": "2.0.2",
     "karma-sourcemap-loader": "0.3.8",
     "karma-spec-reporter": "0.0.34",
-    "karma-webpack": "4.0.2",
+    "karma-webpack": "5.0.0",
     "lodash": "4.17.21",
     "lolex": "1.6.0",
-    "mini-css-extract-plugin": "0.12.0",
-    "mocha": "3.5.3",
-    "nightwatch": "0.9.21",
-    "opn": "4.0.2",
+    "mini-css-extract-plugin": "2.6.1",
+    "mocha": "10.0.0",
+    "nightwatch": "2.3.3",
+    "opn": "5.5.0",
     "ora": "0.4.1",
-    "postcss-loader": "3.0.0",
-    "raw-loader": "0.5.1",
-    "sass": "1.54.0",
-    "sass-loader": "7.3.1",
+    "postcss": "8.4.16",
+    "postcss-loader": "7.0.1",
+    "sass": "1.54.8",
+    "sass-loader": "13.0.2",
     "selenium-server": "2.53.1",
-    "semver": "5.7.1",
-    "serviceworker-webpack-plugin": "1.0.1",
+    "semver": "7.3.7",
+    "serviceworker-webpack5-plugin": "2.0.0",
     "shelljs": "0.8.5",
-    "sinon": "2.4.1",
-    "sinon-chai": "2.14.0",
+    "sinon": "14.0.0",
+    "sinon-chai": "3.7.0",
     "stylelint": "13.13.1",
     "stylelint-config-standard": "20.0.0",
     "stylelint-rscss": "0.4.0",
-    "url-loader": "1.1.2",
-    "vue-loader": "^16.0.0",
+    "vue-loader": "17.0.0",
     "vue-style-loader": "4.1.3",
-    "webpack": "4.46.0",
+    "webpack": "5.74.0",
     "webpack-dev-middleware": "3.7.3",
-    "webpack-hot-middleware": "2.25.1",
+    "webpack-hot-middleware": "2.25.2",
     "webpack-merge": "0.20.0"
   },
   "engines": {

+ 17 - 0
src/App.js

@@ -10,7 +10,9 @@ import MobilePostStatusButton from './components/mobile_post_status_button/mobil
 import MobileNav from './components/mobile_nav/mobile_nav.vue'
 import DesktopNav from './components/desktop_nav/desktop_nav.vue'
 import UserReportingModal from './components/user_reporting_modal/user_reporting_modal.vue'
+import EditStatusModal from './components/edit_status_modal/edit_status_modal.vue'
 import PostStatusModal from './components/post_status_modal/post_status_modal.vue'
+import StatusHistoryModal from './components/status_history_modal/status_history_modal.vue'
 import GlobalNoticeList from './components/global_notice_list/global_notice_list.vue'
 import { windowWidth, windowHeight } from './services/window_utils/window_utils'
 import { mapGetters } from 'vuex'
@@ -32,8 +34,11 @@ export default {
     MobileNav,
     DesktopNav,
     SettingsModal: defineAsyncComponent(() => import('./components/settings_modal/settings_modal.vue')),
+    UpdateNotification: defineAsyncComponent(() => import('./components/update_notification/update_notification.vue')),
     UserReportingModal,
     PostStatusModal,
+    EditStatusModal,
+    StatusHistoryModal,
     GlobalNoticeList
   },
   data: () => ({
@@ -59,6 +64,13 @@ export default {
         '-' + this.layoutType
       ]
     },
+    navClasses () {
+      const { navbarColumnStretch } = this.$store.getters.mergedConfig
+      return [
+        '-' + this.layoutType,
+        ...(navbarColumnStretch ? ['-column-stretch'] : [])
+      ]
+    },
     currentUser () { return this.$store.state.users.currentUser },
     userBackground () { return this.currentUser.background_image },
     instanceBackground () {
@@ -84,11 +96,16 @@ export default {
     isChats () {
       return this.$route.name === 'chat' || this.$route.name === 'chats'
     },
+    isListEdit () {
+      return this.$route.name === 'lists-edit'
+    },
     newPostButtonShown () {
       if (this.isChats) return false
+      if (this.isListEdit) return false
       return this.$store.getters.mergedConfig.alwaysShowNewPostButton || this.layoutType === 'mobile'
     },
     showFeaturesPanel () { return this.$store.state.instance.showFeaturesPanel },
+    editingAvailable () { return this.$store.state.instance.editingAvailable },
     shoutboxPosition () {
       return this.$store.getters.mergedConfig.alwaysShowNewPostButton || false
     },

+ 54 - 13
src/App.scss

@@ -5,12 +5,12 @@
   --navbar-height: 3.5rem;
   --post-line-height: 1.4;
   // Z-Index stuff
-  --ZI_media_modal: 90000;
-  --ZI_modals_popovers: 85000;
-  --ZI_modals: 80000;
-  --ZI_navbar_popovers: 75000;
-  --ZI_navbar: 70000;
-  --ZI_popovers: 60000;
+  --ZI_media_modal: 9000;
+  --ZI_modals_popovers: 8500;
+  --ZI_modals: 8000;
+  --ZI_navbar_popovers: 7500;
+  --ZI_navbar: 7000;
+  --ZI_popovers: 6000;
 }
 
 html {
@@ -117,12 +117,28 @@ h4 {
   margin: 0;
 }
 
+.iconLetter {
+  display: inline-block;
+  text-align: center;
+  font-weight: 1000;
+}
+
 i[class*=icon-],
-.svg-inline--fa {
+.svg-inline--fa,
+.iconLetter {
   color: $fallback--icon;
   color: var(--icon, $fallback--icon);
 }
 
+.button-unstyled:hover,
+a:hover {
+  > i[class*=icon-],
+  > .svg-inline--fa,
+  > .iconLetter {
+    color: var(--text);
+  }
+}
+
 nav {
   z-index: var(--ZI_navbar);
   color: var(--topBarText);
@@ -141,6 +157,11 @@ nav {
   grid-area: sidebar;
 }
 
+#modal {
+  position: absolute;
+  z-index: var(--ZI_modals);
+}
+
 .column.-scrollable {
   top: var(--navbar-height);
   position: sticky;
@@ -182,13 +203,18 @@ nav {
 
 .app-layout {
   --miniColumn: 25rem;
-  --maxiColumn: minmax(var(--miniColumn), 45rem);
+  --maxiColumn: 45rem;
   --columnGap: 1em;
   --status-margin: 0.75em;
+  --effectiveSidebarColumnWidth: minmax(var(--miniColumn), var(--sidebarColumnWidth, var(--miniColumn)));
+  --effectiveNotifsColumnWidth: minmax(var(--miniColumn), var(--notifsColumnWidth, var(--miniColumn)));
+  --effectiveContentColumnWidth: minmax(var(--miniColumn), var(--contentColumnWidth, var(--maxiColumn)));
 
   position: relative;
   display: grid;
-  grid-template-columns: var(--miniColumn) var(--maxiColumn);
+  grid-template-columns:
+    var(--effectiveSidebarColumnWidth)
+    var(--effectiveContentColumnWidth);
   grid-template-areas: "sidebar content";
   grid-template-rows: 1fr;
   box-sizing: border-box;
@@ -282,15 +308,24 @@ nav {
   }
 
   &.-reverse:not(.-wide):not(.-mobile) {
-    grid-template-columns: var(--maxiColumn) var(--miniColumn);
+    grid-template-columns:
+      var(--effectiveContentColumnWidth)
+      var(--effectiveSidebarColumnWidth);
     grid-template-areas: "content sidebar";
   }
 
   &.-wide {
-    grid-template-columns: var(--miniColumn) var(--maxiColumn) var(--miniColumn);
+    grid-template-columns:
+      var(--effectiveSidebarColumnWidth)
+      var(--effectiveContentColumnWidth)
+      var(--effectiveNotifsColumnWidth);
     grid-template-areas: "sidebar content notifs";
 
     &.-reverse {
+      grid-template-columns:
+        var(--effectiveNotifsColumnWidth)
+        var(--effectiveContentColumnWidth)
+        var(--effectiveSidebarColumnWidth);
       grid-template-areas: "notifs content sidebar";
     }
   }
@@ -746,17 +781,23 @@ option {
 }
 
 .fa-scale-110 {
-  &.svg-inline--fa {
+  &.svg-inline--fa,
+  &.iconLetter {
     font-size: 1.1em;
   }
 }
 
 .fa-old-padding {
-  &.svg-inline--fa {
+  &.iconLetter,
+  &.svg-inline--fa, &-layer {
     padding: 0 0.3em;
   }
 }
 
+.veryfaint {
+  opacity: 0.25;
+}
+
 .login-hint {
   text-align: center;
 

+ 8 - 2
src/App.vue

@@ -8,7 +8,10 @@
       class="app-bg-wrapper"
     />
     <MobileNav v-if="layoutType === 'mobile'" />
-    <DesktopNav v-else />
+    <DesktopNav
+      v-else
+      :class="navClasses"
+    />
     <Notifications v-if="currentUser" />
     <div
       id="content"
@@ -33,7 +36,7 @@
       <div
         id="main-scroller"
         class="column main"
-        :class="{ '-full-height': isChats }"
+        :class="{ '-full-height': isChats || isListEdit }"
       >
         <div
           v-if="!currentUser"
@@ -64,7 +67,10 @@
     <MobilePostStatusButton />
     <UserReportingModal />
     <PostStatusModal />
+    <EditStatusModal v-if="editingAvailable" />
+    <StatusHistoryModal v-if="editingAvailable" />
     <SettingsModal />
+    <UpdateNotification />
     <div id="modal" />
     <GlobalNoticeList />
     <div id="popovers" />

+ 17 - 0
src/_mixins.scss

@@ -0,0 +1,17 @@
+@mixin unfocused-style {
+  @content;
+
+  &:focus:not(:focus-visible):not(:hover) {
+    @content;
+  }
+}
+
+@mixin focused-style {
+  &:hover, &:focus {
+    @content;
+  }
+
+  &:focus-visible {
+    @content;
+  }
+}

BIN
src/assets/pleromatan_apology.png


BIN
src/assets/pleromatan_apology_fox.png


BIN
src/assets/pleromatan_apology_fox_mask.png


BIN
src/assets/pleromatan_apology_mask.png


+ 4 - 1
src/boot/after_store.js

@@ -12,7 +12,7 @@ import { windowWidth, windowHeight } from '../services/window_utils/window_utils
 import { getOrCreateApp, getClientToken } from '../services/new_api/oauth.js'
 import backendInteractorService from '../services/backend_interactor_service/backend_interactor_service.js'
 import { CURRENT_VERSION } from '../services/theme_data/theme_data.service.js'
-import { applyTheme } from '../services/style_setter/style_setter.js'
+import { applyTheme, applyConfig } from '../services/style_setter/style_setter.js'
 import FaviconService from '../services/favicon_service/favicon_service.js'
 
 let staticInitialResults = null
@@ -251,6 +251,7 @@ const getNodeInfo = async ({ store }) => {
       store.dispatch('setInstanceOption', { name: 'pleromaChatMessagesAvailable', value: features.includes('pleroma_chat_messages') })
       store.dispatch('setInstanceOption', { name: 'gopherAvailable', value: features.includes('gopher') })
       store.dispatch('setInstanceOption', { name: 'pollsAvailable', value: features.includes('polls') })
+      store.dispatch('setInstanceOption', { name: 'editingAvailable', value: features.includes('editing') })
       store.dispatch('setInstanceOption', { name: 'pollLimits', value: metadata.pollLimits })
       store.dispatch('setInstanceOption', { name: 'mailerEnabled', value: metadata.mailerEnabled })
 
@@ -360,6 +361,8 @@ const afterStoreSetup = async ({ store, i18n }) => {
     console.error('Failed to load any theme!')
   }
 
+  applyConfig(store.state.config)
+
   // Now we can try getting the server settings and logging in
   // Most of these are preloaded into the index.html so blocking is minimized
   await Promise.all([

+ 12 - 2
src/boot/routes.js

@@ -20,6 +20,10 @@ import ShoutPanel from 'components/shout_panel/shout_panel.vue'
 import WhoToFollow from 'components/who_to_follow/who_to_follow.vue'
 import About from 'components/about/about.vue'
 import RemoteUserResolver from 'components/remote_user_resolver/remote_user_resolver.vue'
+import Lists from 'components/lists/lists.vue'
+import ListsTimeline from 'components/lists_timeline/lists_timeline.vue'
+import ListsEdit from 'components/lists_edit/lists_edit.vue'
+import NavPanel from 'src/components/nav_panel/nav_panel.vue'
 
 export default (store) => {
   const validateAuthenticatedRoute = (to, from, next) => {
@@ -58,7 +62,7 @@ export default (store) => {
       component: RemoteUserResolver,
       beforeEnter: validateAuthenticatedRoute
     },
-    { name: 'external-user-profile', path: '/users/:id', component: UserProfile },
+    { name: 'external-user-profile', path: '/users/$:id', component: UserProfile },
     { name: 'interactions', path: '/users/:username/interactions', component: Interactions, beforeEnter: validateAuthenticatedRoute },
     { name: 'dms', path: '/users/:username/dms', component: DMs, beforeEnter: validateAuthenticatedRoute },
     { name: 'registration', path: '/registration', component: Registration },
@@ -72,7 +76,13 @@ export default (store) => {
     { name: 'search', path: '/search', component: Search, props: (route) => ({ query: route.query.query }) },
     { name: 'who-to-follow', path: '/who-to-follow', component: WhoToFollow, beforeEnter: validateAuthenticatedRoute },
     { name: 'about', path: '/about', component: About },
-    { name: 'user-profile', path: '/:_(users)?/:name', component: UserProfile }
+    { name: 'user-profile', path: '/users/:name', component: UserProfile },
+    { name: 'legacy-user-profile', path: '/:name', component: UserProfile },
+    { name: 'lists', path: '/lists', component: Lists },
+    { name: 'lists-timeline', path: '/lists/:id', component: ListsTimeline },
+    { name: 'lists-edit', path: '/lists/:id/edit', component: ListsEdit },
+    { name: 'lists-new', path: '/lists/new', component: ListsEdit },
+    { name: 'edit-navigation', path: '/nav-edit', component: NavPanel, props: () => ({ forceExpand: true, forceEditMode: true }), beforeEnter: validateAuthenticatedRoute }
   ]
 
   if (store.state.instance.pleromaChatMessagesAvailable) {

+ 6 - 1
src/components/account_actions/account_actions.js

@@ -1,6 +1,7 @@
 import { mapState } from 'vuex'
 import ProgressButton from '../progress_button/progress_button.vue'
 import Popover from '../popover/popover.vue'
+import UserListMenu from 'src/components/user_list_menu/user_list_menu.vue'
 import { library } from '@fortawesome/fontawesome-svg-core'
 import {
   faEllipsisV
@@ -19,7 +20,8 @@ const AccountActions = {
   },
   components: {
     ProgressButton,
-    Popover
+    Popover,
+    UserListMenu
   },
   methods: {
     showRepeats () {
@@ -34,6 +36,9 @@ const AccountActions = {
     unblockUser () {
       this.$store.dispatch('unblockUser', this.user.id)
     },
+    removeUserFromFollowers () {
+      this.$store.dispatch('removeUserFromFollowers', this.user.id)
+    },
     reportUser () {
       this.$store.dispatch('openUserReportingModal', { userId: this.user.id })
     },

+ 8 - 0
src/components/account_actions/account_actions.vue

@@ -28,6 +28,14 @@
               class="dropdown-divider"
             />
           </template>
+          <UserListMenu :user="user" />
+          <button
+            v-if="relationship.followed_by"
+            class="btn button-default btn-block dropdown-item"
+            @click="removeUserFromFollowers"
+          >
+            {{ $t('user_card.remove_follower') }}
+          </button>
           <button
             v-if="relationship.blocking"
             class="btn button-default btn-block dropdown-item"

+ 3 - 0
src/components/attachment/attachment.js

@@ -129,6 +129,9 @@ const Attachment = {
     ...mapGetters(['mergedConfig'])
   },
   watch: {
+    'attachment.description' (newVal) {
+      this.localDescription = newVal
+    },
     localDescription (newVal) {
       this.onEdit(newVal)
     }

+ 3 - 1
src/components/basic_user_card/basic_user_card.js

@@ -1,5 +1,6 @@
 import UserPopover from '../user_popover/user_popover.vue'
 import UserAvatar from '../user_avatar/user_avatar.vue'
+import UserLink from '../user_link/user_link.vue'
 import RichContent from 'src/components/rich_content/rich_content.jsx'
 import generateProfileLink from 'src/services/user_profile_link_generator/user_profile_link_generator'
 
@@ -10,7 +11,8 @@ const BasicUserCard = {
   components: {
     UserPopover,
     UserAvatar,
-    RichContent
+    RichContent,
+    UserLink
   },
   methods: {
     userProfileLink (user) {

+ 3 - 5
src/components/basic_user_card/basic_user_card.vue

@@ -30,12 +30,10 @@
         />
       </div>
       <div>
-        <router-link
+        <user-link
           class="basic-user-card-screen-name"
-          :to="userProfileLink(user)"
-        >
-          @{{ user.screen_name_ui }}
-        </router-link>
+          :user="user"
+        />
       </div>
       <slot />
     </div>

+ 6 - 4
src/components/chat/chat.js

@@ -57,6 +57,7 @@ const Chat = {
   },
   unmounted () {
     window.removeEventListener('scroll', this.handleScroll)
+    window.removeEventListener('resize', this.handleResize)
     if (typeof document.hidden !== 'undefined') document.removeEventListener('visibilitychange', this.handleVisibilityChange, false)
     this.$store.dispatch('clearCurrentChat')
   },
@@ -135,7 +136,7 @@ const Chat = {
     },
     // "Sticks" scroll to bottom instead of top, helps with OSK resizing the viewport
     handleResize (opts = {}) {
-      const { expand = false, delayed = false } = opts
+      const { delayed = false } = opts
 
       if (delayed) {
         setTimeout(() => {
@@ -146,10 +147,10 @@ const Chat = {
 
       this.$nextTick(() => {
         const { offsetHeight = undefined } = getScrollPosition()
-        const diff = this.lastScrollPosition.offsetHeight - offsetHeight
-        if (diff !== 0 || (!this.bottomedOut() && expand)) {
+        const diff = offsetHeight - this.lastScrollPosition.offsetHeight
+        if (diff !== 0 && !this.bottomedOut()) {
           this.$nextTick(() => {
-            window.scrollTo({ top: window.scrollY + diff })
+            window.scrollBy({ top: -Math.trunc(diff) })
           })
         }
         this.lastScrollPosition = getScrollPosition()
@@ -187,6 +188,7 @@ const Chat = {
       }, 5000)
     },
     handleScroll: _.throttle(function () {
+      this.lastScrollPosition = getScrollPosition()
       if (!this.currentChat) { return }
 
       if (this.reachedTop()) {

+ 20 - 2
src/components/conversation/conversation.js

@@ -1,6 +1,10 @@
 import { reduce, filter, findIndex, clone, get } from 'lodash'
 import Status from '../status/status.vue'
 import ThreadTree from '../thread_tree/thread_tree.vue'
+import { WSConnectionStatus } from '../../services/api/api.service.js'
+import { mapGetters, mapState } from 'vuex'
+import QuickFilterSettings from '../quick_filter_settings/quick_filter_settings.vue'
+import QuickViewSettings from '../quick_view_settings/quick_view_settings.vue'
 
 import { library } from '@fortawesome/fontawesome-svg-core'
 import {
@@ -77,6 +81,9 @@ const conversation = {
       const maxDepth = this.$store.getters.mergedConfig.maxDepthInThread - 2
       return maxDepth >= 1 ? maxDepth : 1
     },
+    streamingEnabled () {
+      return this.mergedConfig.useStreamingApi && this.mastoUserSocketStatus === WSConnectionStatus.JOINED
+    },
     displayStyle () {
       return this.$store.getters.mergedConfig.conversationDisplay
     },
@@ -339,11 +346,17 @@ const conversation = {
     },
     maybeHighlight () {
       return this.isExpanded ? this.highlight : null
-    }
+    },
+    ...mapGetters(['mergedConfig']),
+    ...mapState({
+      mastoUserSocketStatus: state => state.api.mastoUserSocketStatus
+    })
   },
   components: {
     Status,
-    ThreadTree
+    ThreadTree,
+    QuickFilterSettings,
+    QuickViewSettings
   },
   watch: {
     statusId (newVal, oldVal) {
@@ -395,6 +408,11 @@ const conversation = {
     setHighlight (id) {
       if (!id) return
       this.highlight = id
+
+      if (!this.streamingEnabled) {
+        this.$store.dispatch('fetchStatus', id)
+      }
+
       this.$store.dispatch('fetchFavsAndRepeats', id)
       this.$store.dispatch('fetchEmojiReactionsBy', id)
     },

+ 8 - 0
src/components/conversation/conversation.vue

@@ -17,6 +17,14 @@
       >
         {{ $t('timeline.collapse') }}
       </button>
+      <QuickFilterSettings
+        v-if="!collapsable"
+        :conversation="true"
+      />
+      <QuickViewSettings
+        v-if="!collapsable"
+        :conversation="true"
+      />
     </div>
     <div class="conversation-body panel-body">
       <div

+ 24 - 0
src/components/desktop_nav/desktop_nav.scss

@@ -23,6 +23,26 @@
     max-width: 980px;
   }
 
+  &.-column-stretch .inner-nav {
+    --miniColumn: 25rem;
+    --maxiColumn: 45rem;
+    --columnGap: 1em;
+    max-width: calc(
+      var(--sidebarColumnWidth, var(--miniColumn)) +
+      var(--contentColumnWidth, var(--maxiColumn)) +
+      var(--columnGap)
+    );
+  }
+
+  &.-column-stretch.-wide .inner-nav {
+    max-width: calc(
+      var(--sidebarColumnWidth, var(--miniColumn)) +
+      var(--contentColumnWidth, var(--maxiColumn)) +
+      var(--notifsColumnWidth, var(--miniColumn)) +
+      var(--columnGap)
+    );
+  }
+
   &.-logoLeft .inner-nav {
     grid-template-columns: auto 2fr 2fr;
     grid-template-areas: "logo sitename actions";
@@ -117,4 +137,8 @@
       text-align: right;
     }
   }
+
+  .spacer {
+    width: 1em;
+  }
 }

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

@@ -61,6 +61,7 @@
             :title="$t('nav.administration')"
           />
         </a>
+        <span class="spacer" />
         <button
           v-if="currentUser"
           class="button-unstyled nav-icon"

+ 75 - 0
src/components/edit_status_modal/edit_status_modal.js

@@ -0,0 +1,75 @@
+import PostStatusForm from '../post_status_form/post_status_form.vue'
+import Modal from '../modal/modal.vue'
+import statusPosterService from '../../services/status_poster/status_poster.service.js'
+import get from 'lodash/get'
+
+const EditStatusModal = {
+  components: {
+    PostStatusForm,
+    Modal
+  },
+  data () {
+    return {
+      resettingForm: false
+    }
+  },
+  computed: {
+    isLoggedIn () {
+      return !!this.$store.state.users.currentUser
+    },
+    modalActivated () {
+      return this.$store.state.editStatus.modalActivated
+    },
+    isFormVisible () {
+      return this.isLoggedIn && !this.resettingForm && this.modalActivated
+    },
+    params () {
+      return this.$store.state.editStatus.params || {}
+    }
+  },
+  watch: {
+    params (newVal, oldVal) {
+      if (get(newVal, 'statusId') !== get(oldVal, 'statusId')) {
+        this.resettingForm = true
+        this.$nextTick(() => {
+          this.resettingForm = false
+        })
+      }
+    },
+    isFormVisible (val) {
+      if (val) {
+        this.$nextTick(() => this.$el && this.$el.querySelector('textarea').focus())
+      }
+    }
+  },
+  methods: {
+    doEditStatus ({ status, spoilerText, sensitive, media, contentType, poll }) {
+      const params = {
+        store: this.$store,
+        statusId: this.$store.state.editStatus.params.statusId,
+        status,
+        spoilerText,
+        sensitive,
+        poll,
+        media,
+        contentType
+      }
+
+      return statusPosterService.editStatus(params)
+        .then((data) => {
+          return data
+        })
+        .catch((err) => {
+          console.error('Error editing status', err)
+          return {
+            error: err.message
+          }
+        })
+    },
+    closeModal () {
+      this.$store.dispatch('closeEditStatusModal')
+    }
+  }
+}
+
+export default EditStatusModal

+ 48 - 0
src/components/edit_status_modal/edit_status_modal.vue

@@ -0,0 +1,48 @@
+<template>
+  <Modal
+    v-if="isFormVisible"
+    class="edit-form-modal-view"
+    @backdropClicked="closeModal"
+  >
+    <div class="edit-form-modal-panel panel">
+      <div class="panel-heading">
+        {{ $t('post_status.edit_status') }}
+      </div>
+      <PostStatusForm
+        class="panel-body"
+        v-bind="params"
+        :post-handler="doEditStatus"
+        :disable-polls="true"
+        :disable-visibility-selector="true"
+        @posted="closeModal"
+      />
+    </div>
+  </Modal>
+</template>
+
+<script src="./edit_status_modal.js"></script>
+
+<style lang="scss">
+.modal-view.edit-form-modal-view {
+  align-items: flex-start;
+}
+.edit-form-modal-panel {
+  flex-shrink: 0;
+  margin-top: 25%;
+  margin-bottom: 2em;
+  width: 100%;
+  max-width: 700px;
+
+  @media (orientation: landscape) {
+    margin-top: 8%;
+  }
+
+  .form-bottom-left {
+    max-width: 6.5em;
+
+    .emoji-icon {
+      justify-content: right;
+    }
+  }
+}
+</style>

+ 50 - 4
src/components/emoji_input/emoji_input.js

@@ -1,8 +1,9 @@
 import Completion from '../../services/completion/completion.js'
 import EmojiPicker from '../emoji_picker/emoji_picker.vue'
+import UnicodeDomainIndicator from '../unicode_domain_indicator/unicode_domain_indicator.vue'
 import { take } from 'lodash'
 import { findOffset } from '../../services/offset_finder/offset_finder.service.js'
-
+import { ensureFinalFallback } from '../../i18n/languages.js'
 import { library } from '@fortawesome/fontawesome-svg-core'
 import {
   faSmileBeam
@@ -120,7 +121,8 @@ const EmojiInput = {
     }
   },
   components: {
-    EmojiPicker
+    EmojiPicker,
+    UnicodeDomainIndicator
   },
   computed: {
     padEmoji () {
@@ -141,6 +143,51 @@ const EmojiInput = {
         const word = Completion.wordAtPosition(this.modelValue, this.caret - 1) || {}
         return word
       }
+    },
+    languages () {
+      return ensureFinalFallback(this.$store.getters.mergedConfig.interfaceLanguage)
+    },
+    maybeLocalizedEmojiNamesAndKeywords () {
+      return emoji => {
+        const names = [emoji.displayText]
+        const keywords = []
+
+        if (emoji.displayTextI18n) {
+          names.push(this.$t(emoji.displayTextI18n.key, emoji.displayTextI18n.args))
+        }
+
+        if (emoji.annotations) {
+          this.languages.forEach(lang => {
+            names.push(emoji.annotations[lang]?.name)
+
+            keywords.push(...(emoji.annotations[lang]?.keywords || []))
+          })
+        }
+
+        return {
+          names: names.filter(k => k),
+          keywords: keywords.filter(k => k)
+        }
+      }
+    },
+    maybeLocalizedEmojiName () {
+      return emoji => {
+        if (!emoji.annotations) {
+          return emoji.displayText
+        }
+
+        if (emoji.displayTextI18n) {
+          return this.$t(emoji.displayTextI18n.key, emoji.displayTextI18n.args)
+        }
+
+        for (const lang of this.languages) {
+          if (emoji.annotations[lang]?.name) {
+            return emoji.annotations[lang].name
+          }
+        }
+
+        return emoji.displayText
+      }
     }
   },
   mounted () {
@@ -179,7 +226,7 @@ const EmojiInput = {
       const firstchar = newWord.charAt(0)
       this.suggestions = []
       if (newWord === firstchar) return
-      const matchedSuggestions = await this.suggest(newWord)
+      const matchedSuggestions = await this.suggest(newWord, this.maybeLocalizedEmojiNamesAndKeywords)
       // Async: cancel if textAtCaret has changed during wait
       if (this.textAtCaret !== newWord) return
       if (matchedSuggestions.length <= 0) return
@@ -205,7 +252,6 @@ const EmojiInput = {
     },
     triggerShowPicker () {
       this.showPicker = true
-      this.$refs.picker.startEmojiLoad()
       this.$nextTick(() => {
         this.scrollIntoView()
         this.focusPickerInput()

+ 16 - 1
src/components/emoji_input/emoji_input.vue

@@ -19,6 +19,7 @@
         v-if="enableEmojiPicker"
         ref="picker"
         :class="{ hide: !showPicker }"
+        :showing="showPicker"
         :enable-sticker-picker="enableStickerPicker"
         class="emoji-picker-panel"
         @emoji="insert"
@@ -50,7 +51,21 @@
             <span v-else>{{ suggestion.replacement }}</span>
           </span>
           <div class="label">
-            <span class="displayText">{{ suggestion.displayText }}</span>
+            <span
+              v-if="suggestion.user"
+              class="displayText"
+            >
+              {{ suggestion.displayText }}<UnicodeDomainIndicator
+                :user="suggestion.user"
+                :at="false"
+              />
+            </span>
+            <span
+              v-if="!suggestion.user"
+              class="displayText"
+            >
+              {{ maybeLocalizedEmojiName(suggestion) }}
+            </span>
             <span class="detailText">{{ suggestion.detailText }}</span>
           </div>
         </div>

+ 23 - 22
src/components/emoji_input/suggestor.js

@@ -2,7 +2,7 @@
  * suggest - generates a suggestor function to be used by emoji-input
  * data: object providing source information for specific types of suggestions:
  * data.emoji - optional, an array of all emoji available i.e.
- *   (state.instance.emoji + state.instance.customEmoji)
+ *   (getters.standardEmojiList + state.instance.customEmoji)
  * data.users - optional, an array of all known users
  * updateUsersList - optional, a function to search and append to users
  *
@@ -13,10 +13,10 @@
 export default data => {
   const emojiCurry = suggestEmoji(data.emoji)
   const usersCurry = data.store && suggestUsers(data.store)
-  return input => {
+  return (input, nameKeywordLocalizer) => {
     const firstChar = input[0]
     if (firstChar === ':' && data.emoji) {
-      return emojiCurry(input)
+      return emojiCurry(input, nameKeywordLocalizer)
     }
     if (firstChar === '@' && usersCurry) {
       return usersCurry(input)
@@ -25,34 +25,34 @@ export default data => {
   }
 }
 
-export const suggestEmoji = emojis => input => {
+export const suggestEmoji = emojis => (input, nameKeywordLocalizer) => {
   const noPrefix = input.toLowerCase().substr(1)
   return emojis
-    .filter(({ displayText }) => displayText.toLowerCase().match(noPrefix))
-    .sort((a, b) => {
-      let aScore = 0
-      let bScore = 0
+    .map(emoji => ({ ...emoji, ...nameKeywordLocalizer(emoji) }))
+    .filter((emoji) => (emoji.names.concat(emoji.keywords)).filter(kw => kw.toLowerCase().match(noPrefix)).length)
+    .map(k => {
+      let score = 0
 
       // An exact match always wins
-      aScore += a.displayText.toLowerCase() === noPrefix ? 200 : 0
-      bScore += b.displayText.toLowerCase() === noPrefix ? 200 : 0
+      score += Math.max(...k.names.map(name => name.toLowerCase() === noPrefix ? 200 : 0), 0)
 
       // Prioritize custom emoji a lot
-      aScore += a.imageUrl ? 100 : 0
-      bScore += b.imageUrl ? 100 : 0
+      score += k.imageUrl ? 100 : 0
 
       // Prioritize prefix matches somewhat
-      aScore += a.displayText.toLowerCase().startsWith(noPrefix) ? 10 : 0
-      bScore += b.displayText.toLowerCase().startsWith(noPrefix) ? 10 : 0
+      score += Math.max(...k.names.map(kw => kw.toLowerCase().startsWith(noPrefix) ? 10 : 0), 0)
 
       // Sort by length
-      aScore -= a.displayText.length
-      bScore -= b.displayText.length
+      score -= k.displayText.length
 
+      k.score = score
+      return k
+    })
+    .sort((a, b) => {
       // Break ties alphabetically
       const alphabetically = a.displayText > b.displayText ? 0.5 : -0.5
 
-      return bScore - aScore + alphabetically
+      return b.score - a.score + alphabetically
     })
 }
 
@@ -116,11 +116,12 @@ export const suggestUsers = ({ dispatch, state }) => {
 
       return diff + nameAlphabetically + screenNameAlphabetically
       /* eslint-disable camelcase */
-    }).map(({ screen_name, screen_name_ui, name, profile_image_url_original }) => ({
-      displayText: screen_name_ui,
-      detailText: name,
-      imageUrl: profile_image_url_original,
-      replacement: '@' + screen_name + ' '
+    }).map((user) => ({
+      user,
+      displayText: user.screen_name_ui,
+      detailText: user.name,
+      imageUrl: user.profile_image_url_original,
+      replacement: '@' + user.screen_name + ' '
     }))
     /* eslint-enable camelcase */
 

+ 210 - 92
src/components/emoji_picker/emoji_picker.js

@@ -1,33 +1,76 @@
 import { defineAsyncComponent } from 'vue'
 import Checkbox from '../checkbox/checkbox.vue'
+import StillImage from '../still-image/still-image.vue'
+import { ensureFinalFallback } from '../../i18n/languages.js'
+import lozad from 'lozad'
 import { library } from '@fortawesome/fontawesome-svg-core'
 import {
   faBoxOpen,
   faStickyNote,
-  faSmileBeam
+  faSmileBeam,
+  faSmile,
+  faUser,
+  faPaw,
+  faIceCream,
+  faBus,
+  faBasketballBall,
+  faLightbulb,
+  faCode,
+  faFlag
 } from '@fortawesome/free-solid-svg-icons'
-import { trim } from 'lodash'
+import { debounce, trim } from 'lodash'
 
 library.add(
   faBoxOpen,
   faStickyNote,
-  faSmileBeam
+  faSmileBeam,
+  faSmile,
+  faUser,
+  faPaw,
+  faIceCream,
+  faBus,
+  faBasketballBall,
+  faLightbulb,
+  faCode,
+  faFlag
 )
 
-// At widest, approximately 20 emoji are visible in a row,
-// loading 3 rows, could be overkill for narrow picker
-const LOAD_EMOJI_BY = 60
+const UNICODE_EMOJI_GROUP_ICON = {
+  'smileys-and-emotion': 'smile',
+  'people-and-body': 'user',
+  'animals-and-nature': 'paw',
+  'food-and-drink': 'ice-cream',
+  'travel-and-places': 'bus',
+  activities: 'basketball-ball',
+  objects: 'lightbulb',
+  symbols: 'code',
+  flags: 'flag'
+}
 
-// When to start loading new batch emoji, in pixels
-const LOAD_EMOJI_MARGIN = 64
+const maybeLocalizedKeywords = (emoji, languages, nameLocalizer) => {
+  const res = [emoji.displayText, nameLocalizer(emoji)]
+  if (emoji.annotations) {
+    languages.forEach(lang => {
+      const keywords = emoji.annotations[lang]?.keywords || []
+      const name = emoji.annotations[lang]?.name
+      res.push(...(keywords.concat([name]).filter(k => k)))
+    })
+  }
+  return res
+}
 
-const filterByKeyword = (list, keyword = '') => {
+const filterByKeyword = (list, keyword = '', languages, nameLocalizer) => {
   if (keyword === '') return list
 
   const keywordLowercase = keyword.toLowerCase()
   const orderedEmojiList = []
   for (const emoji of list) {
-    const indexOfKeyword = emoji.displayText.toLowerCase().indexOf(keywordLowercase)
+    const indices = maybeLocalizedKeywords(emoji, languages, nameLocalizer)
+      .map(k => k.toLowerCase().indexOf(keywordLowercase))
+      .filter(k => k > -1)
+
+    const indexOfKeyword = indices.length ? Math.min(...indices) : -1
+
     if (indexOfKeyword > -1) {
       if (!Array.isArray(orderedEmojiList[indexOfKeyword])) {
         orderedEmojiList[indexOfKeyword] = []
@@ -44,6 +87,10 @@ const EmojiPicker = {
       required: false,
       type: Boolean,
       default: false
+    },
+    showing: {
+      required: true,
+      type: Boolean
     }
   },
   data () {
@@ -53,16 +100,26 @@ const EmojiPicker = {
       showingStickers: false,
       groupsScrolledClass: 'scrolled-top',
       keepOpen: false,
-      customEmojiBufferSlice: LOAD_EMOJI_BY,
       customEmojiTimeout: null,
-      customEmojiLoadAllConfirmed: false
+      // Lazy-load only after the first time `showing` becomes true.
+      contentLoaded: false,
+      groupRefs: {},
+      emojiRefs: {},
+      filteredEmojiGroups: []
     }
   },
   components: {
     StickerPicker: defineAsyncComponent(() => import('../sticker_picker/sticker_picker.vue')),
-    Checkbox
+    Checkbox,
+    StillImage
   },
   methods: {
+    setGroupRef (name) {
+      return el => { this.groupRefs[name] = el }
+    },
+    setEmojiRef (name) {
+      return el => { this.emojiRefs[name] = el }
+    },
     onStickerUploaded (e) {
       this.$emit('sticker-uploaded', e)
     },
@@ -77,10 +134,38 @@ const EmojiPicker = {
       const target = (e && e.target) || this.$refs['emoji-groups']
       this.updateScrolledClass(target)
       this.scrolledGroup(target)
-      this.triggerLoadMore(target)
+    },
+    scrolledGroup (target) {
+      const top = target.scrollTop + 5
+      this.$nextTick(() => {
+        this.allEmojiGroups.forEach(group => {
+          const ref = this.groupRefs['group-' + group.id]
+          if (ref && ref.offsetTop <= top) {
+            this.activeGroup = group.id
+          }
+        })
+        this.scrollHeader()
+      })
+    },
+    scrollHeader () {
+      // Scroll the active tab's header into view
+      const headerRef = this.groupRefs['group-header-' + this.activeGroup]
+      const left = headerRef.offsetLeft
+      const right = left + headerRef.offsetWidth
+      const headerCont = this.$refs.header
+      const currentScroll = headerCont.scrollLeft
+      const currentScrollRight = currentScroll + headerCont.clientWidth
+      const setScroll = s => { headerCont.scrollLeft = s }
+
+      const margin = 7 // .emoji-tabs-item: padding
+      if (left - margin < currentScroll) {
+        setScroll(left - margin)
+      } else if (right + margin > currentScrollRight) {
+        setScroll(right + margin - headerCont.clientWidth)
+      }
     },
     highlight (key) {
-      const ref = this.$refs['group-' + key]
+      const ref = this.groupRefs['group-' + key]
       const top = ref.offsetTop
       this.setShowStickers(false)
       this.activeGroup = key
@@ -97,73 +182,90 @@ const EmojiPicker = {
         this.groupsScrolledClass = 'scrolled-middle'
       }
     },
-    triggerLoadMore (target) {
-      const ref = this.$refs['group-end-custom']
-      if (!ref) return
-      const bottom = ref.offsetTop + ref.offsetHeight
-
-      const scrollerBottom = target.scrollTop + target.clientHeight
-      const scrollerTop = target.scrollTop
-      const scrollerMax = target.scrollHeight
-
-      // Loads more emoji when they come into view
-      const approachingBottom = bottom - scrollerBottom < LOAD_EMOJI_MARGIN
-      // Always load when at the very top in case there's no scroll space yet
-      const atTop = scrollerTop < 5
-      // Don't load when looking at unicode category or at the very bottom
-      const bottomAboveViewport = bottom < scrollerTop || scrollerBottom === scrollerMax
-      if (!bottomAboveViewport && (approachingBottom || atTop)) {
-        this.loadEmoji()
-      }
+    toggleStickers () {
+      this.showingStickers = !this.showingStickers
     },
-    scrolledGroup (target) {
-      const top = target.scrollTop + 5
+    setShowStickers (value) {
+      this.showingStickers = value
+    },
+    filterByKeyword (list, keyword) {
+      return filterByKeyword(list, keyword, this.languages, this.maybeLocalizedEmojiName)
+    },
+    initializeLazyLoad () {
+      this.destroyLazyLoad()
       this.$nextTick(() => {
-        this.emojisView.forEach(group => {
-          const ref = this.$refs['group-' + group.id]
-          if (ref.offsetTop <= top) {
-            this.activeGroup = group.id
+        this.$lozad = lozad('.still-image.emoji-picker-emoji', {
+          load: el => {
+            const name = el.getAttribute('data-emoji-name')
+            const vn = this.emojiRefs[name]
+            if (!vn) {
+              return
+            }
+
+            vn.loadLazy()
           }
         })
+        this.$lozad.observe()
       })
     },
-    loadEmoji () {
-      const allLoaded = this.customEmojiBuffer.length === this.filteredEmoji.length
-
-      if (allLoaded) {
-        return
-      }
-
-      this.customEmojiBufferSlice += LOAD_EMOJI_BY
+    waitForDomAndInitializeLazyLoad () {
+      this.$nextTick(() => this.initializeLazyLoad())
     },
-    startEmojiLoad (forceUpdate = false) {
-      if (!forceUpdate) {
-        this.keyword = ''
-      }
-      this.$nextTick(() => {
-        this.$refs['emoji-groups'].scrollTop = 0
-      })
-      const bufferSize = this.customEmojiBuffer.length
-      const bufferPrefilledAll = bufferSize === this.filteredEmoji.length
-      if (bufferPrefilledAll && !forceUpdate) {
-        return
+    destroyLazyLoad () {
+      if (this.$lozad) {
+        if (this.$lozad.observer) {
+          this.$lozad.observer.disconnect()
+        }
+        if (this.$lozad.mutationObserver) {
+          this.$lozad.mutationObserver.disconnect()
+        }
       }
-      this.customEmojiBufferSlice = LOAD_EMOJI_BY
     },
-    toggleStickers () {
-      this.showingStickers = !this.showingStickers
+    onShowing () {
+      const oldContentLoaded = this.contentLoaded
+      this.contentLoaded = true
+      this.waitForDomAndInitializeLazyLoad()
+      this.filteredEmojiGroups = this.getFilteredEmojiGroups()
+      if (!oldContentLoaded) {
+        this.$nextTick(() => {
+          if (this.defaultGroup) {
+            this.highlight(this.defaultGroup)
+          }
+        })
+      }
     },
-    setShowStickers (value) {
-      this.showingStickers = value
+    getFilteredEmojiGroups () {
+      return this.allEmojiGroups
+        .map(group => ({
+          ...group,
+          emojis: this.filterByKeyword(group.emojis, trim(this.keyword))
+        }))
+        .filter(group => group.emojis.length > 0)
     }
   },
   watch: {
     keyword () {
-      this.customEmojiLoadAllConfirmed = false
       this.onScroll()
-      this.startEmojiLoad(true)
+      this.debouncedHandleKeywordChange()
+    },
+    allCustomGroups () {
+      this.waitForDomAndInitializeLazyLoad()
+      this.filteredEmojiGroups = this.getFilteredEmojiGroups()
+    },
+    showing (val) {
+      if (val) {
+        this.onShowing()
+      }
     }
   },
+  mounted () {
+    if (this.showing) {
+      this.onShowing()
+    }
+  },
+  destroyed () {
+    this.destroyLazyLoad()
+  },
   computed: {
     activeGroupView () {
       return this.showingStickers ? '' : this.activeGroup
@@ -174,39 +276,55 @@ const EmojiPicker = {
       }
       return 0
     },
-    filteredEmoji () {
-      return filterByKeyword(
-        this.$store.state.instance.customEmoji || [],
-        trim(this.keyword)
-      )
+    allCustomGroups () {
+      return this.$store.getters.groupedCustomEmojis
     },
-    customEmojiBuffer () {
-      return this.filteredEmoji.slice(0, this.customEmojiBufferSlice)
+    defaultGroup () {
+      return Object.keys(this.allCustomGroups)[0]
     },
-    emojis () {
-      const standardEmojis = this.$store.state.instance.emoji || []
-      const customEmojis = this.customEmojiBuffer
-
-      return [
-        {
-          id: 'custom',
-          text: this.$t('emoji.custom'),
-          icon: 'smile-beam',
-          emojis: customEmojis
-        },
-        {
-          id: 'standard',
-          text: this.$t('emoji.unicode'),
-          icon: 'box-open',
-          emojis: filterByKeyword(standardEmojis, trim(this.keyword))
-        }
-      ]
+    unicodeEmojiGroups () {
+      return this.$store.getters.standardEmojiGroupList.map(group => ({
+        id: `standard-${group.id}`,
+        text: this.$t(`emoji.unicode_groups.${group.id}`),
+        icon: UNICODE_EMOJI_GROUP_ICON[group.id],
+        emojis: group.emojis
+      }))
     },
-    emojisView () {
-      return this.emojis.filter(value => value.emojis.length > 0)
+    allEmojiGroups () {
+      return Object.entries(this.allCustomGroups)
+        .map(([_, v]) => v)
+        .concat(this.unicodeEmojiGroups)
     },
     stickerPickerEnabled () {
       return (this.$store.state.instance.stickers || []).length !== 0
+    },
+    debouncedHandleKeywordChange () {
+      return debounce(() => {
+        this.waitForDomAndInitializeLazyLoad()
+        this.filteredEmojiGroups = this.getFilteredEmojiGroups()
+      }, 500)
+    },
+    languages () {
+      return ensureFinalFallback(this.$store.getters.mergedConfig.interfaceLanguage)
+    },
+    maybeLocalizedEmojiName () {
+      return emoji => {
+        if (!emoji.annotations) {
+          return emoji.displayText
+        }
+
+        if (emoji.displayTextI18n) {
+          return this.$t(emoji.displayTextI18n.key, emoji.displayTextI18n.args)
+        }
+
+        for (const lang of this.languages) {
+          if (emoji.annotations[lang]?.name) {
+            return emoji.annotations[lang].name
+          }
+        }
+
+        return emoji.displayText
+      }
     }
   }
 }

+ 44 - 8
src/components/emoji_picker/emoji_picker.scss

@@ -1,5 +1,10 @@
 @import '../../_variables.scss';
 
+$emoji-picker-header-height: 36px;
+$emoji-picker-header-picture-width: 32px;
+$emoji-picker-header-picture-height: 32px;
+$emoji-picker-emoji-size: 32px;
+
 .emoji-picker {
   display: flex;
   flex-direction: column;
@@ -19,6 +24,23 @@
   --lightText: var(--popoverLightText, $fallback--lightText);
   --icon: var(--popoverIcon, $fallback--icon);
 
+  &-header-image {
+    display: inline-flex;
+    justify-content: center;
+    align-items: center;
+    width: $emoji-picker-header-picture-width;
+    max-width: $emoji-picker-header-picture-width;
+    height: $emoji-picker-header-picture-height;
+    max-height: $emoji-picker-header-picture-height;
+    .still-image {
+      max-width: 100%;
+      max-height: 100%;
+      height: 100%;
+      width: 100%;
+      object-fit: contain;
+    }
+  }
+
   .keep-open,
   .too-many-emoji {
     padding: 7px;
@@ -37,7 +59,6 @@
 
   .heading {
     display: flex;
-    height: 32px;
     padding: 10px 7px 5px;
   }
 
@@ -50,6 +71,10 @@
 
   .emoji-tabs {
     flex-grow: 1;
+    display: flex;
+    flex-direction: row;
+    flex-wrap: nowrap;
+    overflow-x: auto;
   }
 
   .emoji-groups {
@@ -57,6 +82,8 @@
   }
 
   .additional-tabs {
+    display: flex;
+    flex: 1;
     border-left: 1px solid;
     border-left-color: $fallback--icon;
     border-left-color: var(--icon, $fallback--icon);
@@ -66,15 +93,20 @@
 
   .additional-tabs,
   .emoji-tabs {
-    display: block;
-    min-width: 0;
     flex-basis: auto;
-    flex-shrink: 1;
+    display: flex;
+    align-content: center;
 
     &-item {
       padding: 0 7px;
       cursor: pointer;
       font-size: 1.85em;
+      width: $emoji-picker-header-picture-width;
+      max-width: $emoji-picker-header-picture-width;
+      height: $emoji-picker-header-picture-height;
+      max-height: $emoji-picker-header-picture-height;
+      display: flex;
+      align-items: center;
 
       &.disabled {
         opacity: 0.5;
@@ -164,22 +196,26 @@
     }
 
     &-item {
-      width: 32px;
-      height: 32px;
+      width: $emoji-picker-emoji-size;
+      height: $emoji-picker-emoji-size;
       box-sizing: border-box;
       display: flex;
-      font-size: 32px;
+      line-height: $emoji-picker-emoji-size;
       align-items: center;
       justify-content: center;
       margin: 4px;
 
       cursor: pointer;
 
-      img {
+      .emoji-picker-emoji.-custom {
         object-fit: contain;
         max-width: 100%;
         max-height: 100%;
       }
+      .emoji-picker-emoji.-unicode {
+        font-size: 24px;
+        overflow: hidden;
+      }
     }
 
   }

+ 38 - 14
src/components/emoji_picker/emoji_picker.vue

@@ -1,19 +1,34 @@
 <template>
-  <div class="emoji-picker panel panel-default panel-body">
+  <div
+    class="emoji-picker panel panel-default panel-body"
+  >
     <div class="heading">
-      <span class="emoji-tabs">
+      <span
+        ref="header"
+        class="emoji-tabs"
+      >
         <span
-          v-for="group in emojis"
+          v-for="group in filteredEmojiGroups"
+          :ref="setGroupRef('group-header-' + group.id)"
           :key="group.id"
           class="emoji-tabs-item"
           :class="{
-            active: activeGroupView === group.id,
-            disabled: group.emojis.length === 0
+            active: activeGroupView === group.id
           }"
           :title="group.text"
           @click.prevent="highlight(group.id)"
         >
+          <span
+            v-if="group.image"
+            class="emoji-picker-header-image"
+          >
+            <still-image
+              :alt="group.text"
+              :src="group.image"
+            />
+          </span>
           <FAIcon
+            v-else
             :icon="group.icon"
             fixed-width
           />
@@ -36,7 +51,10 @@
         </span>
       </span>
     </div>
-    <div class="content">
+    <div
+      v-if="contentLoaded"
+      class="content"
+    >
       <div
         class="emoji-content"
         :class="{hidden: showingStickers}"
@@ -57,12 +75,12 @@
           @scroll="onScroll"
         >
           <div
-            v-for="group in emojisView"
+            v-for="group in filteredEmojiGroups"
             :key="group.id"
             class="emoji-group"
           >
             <h6
-              :ref="'group-' + group.id"
+              :ref="setGroupRef('group-' + group.id)"
               class="emoji-group-title"
             >
               {{ group.text }}
@@ -70,17 +88,23 @@
             <span
               v-for="emoji in group.emojis"
               :key="group.id + emoji.displayText"
-              :title="emoji.displayText"
+              :title="maybeLocalizedEmojiName(emoji)"
               class="emoji-item"
               @click.stop.prevent="onEmoji(emoji)"
             >
-              <span v-if="!emoji.imageUrl">{{ emoji.replacement }}</span>
-              <img
+              <span
+                v-if="!emoji.imageUrl"
+                class="emoji-picker-emoji -unicode"
+              >{{ emoji.replacement }}</span>
+              <still-image
                 v-else
-                :src="emoji.imageUrl"
-              >
+                :ref="setEmojiRef(group.id + emoji.displayText)"
+                class="emoji-picker-emoji -custom"
+                :data-src="emoji.imageUrl"
+                :data-emoji-name="group.id + emoji.displayText"
+              />
             </span>
-            <span :ref="'group-end-' + group.id" />
+            <span :ref="setGroupRef('group-end-' + group.id)" />
           </div>
         </div>
         <div class="keep-open">

+ 43 - 3
src/components/extra_buttons/extra_buttons.js

@@ -6,7 +6,10 @@ import {
   faEyeSlash,
   faThumbtack,
   faShareAlt,
-  faExternalLinkAlt
+  faExternalLinkAlt,
+  faHistory,
+  faPlus,
+  faTimes
 } from '@fortawesome/free-solid-svg-icons'
 import {
   faBookmark as faBookmarkReg,
@@ -21,13 +24,27 @@ library.add(
   faThumbtack,
   faShareAlt,
   faExternalLinkAlt,
-  faFlag
+  faFlag,
+  faHistory,
+  faPlus,
+  faTimes
 )
 
 const ExtraButtons = {
   props: ['status'],
   components: { Popover },
+  data () {
+    return {
+      expanded: false
+    }
+  },
   methods: {
+    onShow () {
+      this.expanded = true
+    },
+    onClose () {
+      this.expanded = false
+    },
     deleteStatus () {
       const confirmed = window.confirm(this.$t('status.delete_confirm'))
       if (confirmed) {
@@ -71,6 +88,25 @@ const ExtraButtons = {
     },
     reportStatus () {
       this.$store.dispatch('openUserReportingModal', { userId: this.status.user.id, statusIds: [this.status.id] })
+    },
+    editStatus () {
+      this.$store.dispatch('fetchStatusSource', { id: this.status.id })
+        .then(data => this.$store.dispatch('openEditStatusModal', {
+          statusId: this.status.id,
+          subject: data.spoiler_text,
+          statusText: data.text,
+          statusIsSensitive: this.status.nsfw,
+          statusPoll: this.status.poll,
+          statusFiles: [...this.status.attachments],
+          visibility: this.status.visibility,
+          statusContentType: data.content_type
+        }))
+    },
+    showStatusHistory () {
+      const originalStatus = { ...this.status }
+      const stripFieldsList = ['attachments', 'created_at', 'emojis', 'text', 'raw_html', 'nsfw', 'poll', 'summary', 'summary_raw_html']
+      stripFieldsList.forEach(p => delete originalStatus[p])
+      this.$store.dispatch('openStatusHistoryModal', originalStatus)
     }
   },
   computed: {
@@ -93,7 +129,11 @@ const ExtraButtons = {
     },
     statusLink () {
       return `${this.$store.state.instance.server}${this.$router.resolve({ name: 'conversation', params: { id: this.status.id } }).href}`
-    }
+    },
+    isEdited () {
+      return this.status.edited_at !== null
+    },
+    editingAvailable () { return this.$store.state.instance.editingAvailable }
   }
 }
 

+ 58 - 4
src/components/extra_buttons/extra_buttons.vue

@@ -6,6 +6,8 @@
     :offset="{ y: 5 }"
     :bound-to="{ x: 'container' }"
     remove-padding
+    @show="onShow"
+    @close="onClose"
   >
     <template #content="{close}">
       <div class="dropdown-menu">
@@ -75,6 +77,28 @@
             /><span>{{ $t("status.unbookmark") }}</span>
           </button>
         </template>
+        <button
+          v-if="ownStatus && editingAvailable"
+          class="button-default dropdown-item dropdown-item-icon"
+          @click.prevent="editStatus"
+          @click="close"
+        >
+          <FAIcon
+            fixed-width
+            icon="pen"
+          /><span>{{ $t("status.edit") }}</span>
+        </button>
+        <button
+          v-if="isEdited && editingAvailable"
+          class="button-default dropdown-item dropdown-item-icon"
+          @click.prevent="showStatusHistory"
+          @click="close"
+        >
+          <FAIcon
+            fixed-width
+            icon="history"
+          /><span>{{ $t("status.status_history") }}</span>
+        </button>
         <button
           v-if="canDelete"
           class="button-default dropdown-item dropdown-item-icon"
@@ -122,10 +146,24 @@
     </template>
     <template #trigger>
       <span class="button-unstyled popover-trigger">
-        <FAIcon
-          class="fa-scale-110 fa-old-padding"
-          icon="ellipsis-h"
-        />
+        <FALayers class="fa-old-padding-layer">
+          <FAIcon
+            class="fa-scale-110 "
+            icon="ellipsis-h"
+          />
+          <FAIcon
+            v-show="!expanded"
+            class="focus-marker"
+            transform="shrink-6 up-8 right-16"
+            icon="plus"
+          />
+          <FAIcon
+            v-show="expanded"
+            class="focus-marker"
+            transform="shrink-6 up-8 right-16"
+            icon="times"
+          />
+        </FALayers>
       </span>
     </template>
   </Popover>
@@ -135,6 +173,7 @@
 
 <style lang="scss">
 @import '../../_variables.scss';
+@import '../../_mixins.scss';
 
 .ExtraButtons {
   /* override of popover internal stuff */
@@ -151,6 +190,21 @@
       color: $fallback--text;
       color: var(--text, $fallback--text);
     }
+
+  }
+
+  .popover-trigger-button {
+    @include unfocused-style {
+      .focus-marker {
+        visibility: hidden;
+      }
+    }
+
+    @include focused-style {
+      .focus-marker {
+        visibility: visible;
+      }
+    }
   }
 }
 </style>

+ 10 - 2
src/components/favorite_button/favorite_button.js

@@ -1,13 +1,21 @@
 import { mapGetters } from 'vuex'
 import { library } from '@fortawesome/fontawesome-svg-core'
-import { faStar } from '@fortawesome/free-solid-svg-icons'
+import {
+  faStar,
+  faPlus,
+  faMinus,
+  faCheck
+} from '@fortawesome/free-solid-svg-icons'
 import {
   faStar as faStarRegular
 } from '@fortawesome/free-regular-svg-icons'
 
 library.add(
   faStar,
-  faStarRegular
+  faStarRegular,
+  faPlus,
+  faMinus,
+  faCheck
 )
 
 const FavoriteButton = {

+ 46 - 5
src/components/favorite_button/favorite_button.vue

@@ -7,11 +7,31 @@
       :title="$t('tool_tip.favorite')"
       @click.prevent="favorite()"
     >
-      <FAIcon
-        class="fa-scale-110 fa-old-padding"
-        :icon="[status.favorited ? 'fas' : 'far', 'star']"
-        :spin="animated"
-      />
+      <FALayers class="fa-scale-110 fa-old-padding-layer">
+        <FAIcon
+          class="fa-scale-110"
+          :icon="[status.favorited ? 'fas' : 'far', 'star']"
+          :spin="animated"
+        />
+        <FAIcon
+          v-if="status.favorited"
+          class="active-marker"
+          transform="shrink-6 up-9 right-12"
+          icon="check"
+        />
+        <FAIcon
+          v-if="!status.favorited"
+          class="focus-marker"
+          transform="shrink-6 up-9 right-12"
+          icon="plus"
+        />
+        <FAIcon
+          v-else
+          class="focus-marker"
+          transform="shrink-6 up-9 right-12"
+          icon="minus"
+        />
+      </FALayers>
     </button>
     <span v-else>
       <FAIcon
@@ -33,6 +53,7 @@
 
 <style lang="scss">
 @import '../../_variables.scss';
+@import '../../_mixins.scss';
 
 .FavoriteButton {
   display: flex;
@@ -57,6 +78,26 @@
       color: $fallback--cOrange;
       color: var(--cOrange, $fallback--cOrange);
     }
+
+    @include unfocused-style {
+      .focus-marker {
+        visibility: hidden;
+      }
+
+      .active-marker {
+        visibility: visible;
+      }
+    }
+
+    @include focused-style {
+      .focus-marker {
+        visibility: visible;
+      }
+
+      .active-marker {
+        visibility: hidden;
+      }
+    }
   }
 }
 </style>

+ 3 - 1
src/components/follow_card/follow_card.js

@@ -1,6 +1,7 @@
 import BasicUserCard from '../basic_user_card/basic_user_card.vue'
 import RemoteFollow from '../remote_follow/remote_follow.vue'
 import FollowButton from '../follow_button/follow_button.vue'
+import RemoveFollowerButton from '../remove_follower_button/remove_follower_button.vue'
 
 const FollowCard = {
   props: [
@@ -10,7 +11,8 @@ const FollowCard = {
   components: {
     BasicUserCard,
     RemoteFollow,
-    FollowButton
+    FollowButton,
+    RemoveFollowerButton
   },
   computed: {
     isMe () {

+ 11 - 0
src/components/follow_card/follow_card.vue

@@ -22,6 +22,11 @@
           class="follow-card-follow-button"
           :user="user"
         />
+        <RemoveFollowerButton
+          v-if="noFollowsYou && relationship.followed_by"
+          :relationship="relationship"
+          class="follow-card-button"
+        />
       </template>
     </div>
   </basic-user-card>
@@ -40,6 +45,12 @@
     line-height: 1.5em;
   }
 
+  &-button {
+    margin-top: 0.5em;
+    padding: 0 1.5em;
+    margin-left: 1em;
+  }
+
   &-follow-button {
     margin-top: 0.5em;
     margin-left: auto;

+ 2 - 2
src/components/global_notice_list/global_notice_list.vue

@@ -29,10 +29,10 @@
 
 .global-notice-list {
   position: fixed;
-  top: 50px;
+  top: calc(var(--navbar-height) + 0.5em);
   width: 100%;
   pointer-events: none;
-  z-index: var(--ZI_popovers);
+  z-index: var(--ZI_navbar_popovers);
   display: flex;
   flex-direction: column;
   align-items: center;

+ 4 - 1
src/components/interactions/interactions.js

@@ -5,6 +5,8 @@ const tabModeDict = {
   mentions: ['mention'],
   'likes+repeats': ['repeat', 'like'],
   follows: ['follow'],
+  reactions: ['pleroma:emoji_reaction'],
+  reports: ['pleroma:report'],
   moves: ['move']
 }
 
@@ -12,7 +14,8 @@ const Interactions = {
   data () {
     return {
       allowFollowingMove: this.$store.state.users.currentUser.allow_following_move,
-      filterMode: tabModeDict.mentions
+      filterMode: tabModeDict.mentions,
+      canSeeReports: ['moderator', 'admin'].includes(this.$store.state.users.currentUser.role)
     }
   },
   methods: {

+ 9 - 0
src/components/interactions/interactions.vue

@@ -21,6 +21,15 @@
         key="follows"
         :label="$t('interactions.follows')"
       />
+      <span
+        key="reactions"
+        :label="$t('interactions.emoji_reactions')"
+      />
+      <span
+        v-if="canSeeReports"
+        key="reports"
+        :label="$t('interactions.reports')"
+      />
       <span
         v-if="!allowFollowingMove"
         key="moves"

+ 27 - 0
src/components/lists/lists.js

@@ -0,0 +1,27 @@
+import ListsCard from '../lists_card/lists_card.vue'
+
+const Lists = {
+  data () {
+    return {
+      isNew: false
+    }
+  },
+  components: {
+    ListsCard
+  },
+  computed: {
+    lists () {
+      return this.$store.state.lists.allLists
+    }
+  },
+  methods: {
+    cancelNewList () {
+      this.isNew = false
+    },
+    newList () {
+      this.isNew = true
+    }
+  }
+}
+
+export default Lists

+ 33 - 0
src/components/lists/lists.vue

@@ -0,0 +1,33 @@
+<template>
+  <div class="Lists panel panel-default">
+    <div class="panel-heading">
+      <div class="title">
+        {{ $t('lists.lists') }}
+      </div>
+      <router-link
+        :to="{ name: 'lists-new' }"
+        class="button-default btn new-list-button"
+      >
+        {{ $t("lists.new") }}
+      </router-link>
+    </div>
+    <div class="panel-body">
+      <ListsCard
+        v-for="list in lists.slice().reverse()"
+        :key="list"
+        :list="list"
+        class="list-item"
+      />
+    </div>
+  </div>
+</template>
+
+<script src="./lists.js"></script>
+
+<style lang="scss">
+.Lists {
+  .new-list-button {
+    padding: 0 0.5em;
+  }
+}
+</style>

+ 16 - 0
src/components/lists_card/lists_card.js

@@ -0,0 +1,16 @@
+import { library } from '@fortawesome/fontawesome-svg-core'
+import {
+  faEllipsisH
+} from '@fortawesome/free-solid-svg-icons'
+
+library.add(
+  faEllipsisH
+)
+
+const ListsCard = {
+  props: [
+    'list'
+  ]
+}
+
+export default ListsCard

+ 51 - 0
src/components/lists_card/lists_card.vue

@@ -0,0 +1,51 @@
+<template>
+  <div class="list-card">
+    <router-link
+      :to="{ name: 'lists-timeline', params: { id: list.id } }"
+      class="list-name"
+    >
+      {{ list.title }}
+    </router-link>
+    <router-link
+      :to="{ name: 'lists-edit', params: { id: list.id } }"
+      class="button-list-edit"
+    >
+      <FAIcon
+        class="fa-scale-110 fa-old-padding"
+        icon="ellipsis-h"
+      />
+    </router-link>
+  </div>
+</template>
+
+<script src="./lists_card.js"></script>
+
+<style lang="scss">
+@import '../../_variables.scss';
+
+.list-card {
+  display: flex;
+}
+
+.list-name,
+.button-list-edit {
+  margin: 0;
+  padding: 1em;
+  color: $fallback--link;
+  color: var(--link, $fallback--link);
+
+  &:hover {
+    background-color: $fallback--lightBg;
+    background-color: var(--selectedMenu, $fallback--lightBg);
+    color: $fallback--link;
+    color: var(--selectedMenuText, $fallback--link);
+    --faint: var(--selectedMenuFaintText, $fallback--faint);
+    --faintLink: var(--selectedMenuFaintLink, $fallback--faint);
+    --lightText: var(--selectedMenuLightText, $fallback--lightText);
+  }
+}
+
+.list-name {
+  flex-grow: 1;
+}
+</style>

+ 145 - 0
src/components/lists_edit/lists_edit.js

@@ -0,0 +1,145 @@
+import { mapState, mapGetters } from 'vuex'
+import BasicUserCard from '../basic_user_card/basic_user_card.vue'
+import ListsUserSearch from '../lists_user_search/lists_user_search.vue'
+import PanelLoading from 'src/components/panel_loading/panel_loading.vue'
+import UserAvatar from '../user_avatar/user_avatar.vue'
+import TabSwitcher from 'src/components/tab_switcher/tab_switcher.jsx'
+import { library } from '@fortawesome/fontawesome-svg-core'
+import {
+  faSearch,
+  faChevronLeft
+} from '@fortawesome/free-solid-svg-icons'
+
+library.add(
+  faSearch,
+  faChevronLeft
+)
+
+const ListsNew = {
+  components: {
+    BasicUserCard,
+    UserAvatar,
+    ListsUserSearch,
+    TabSwitcher,
+    PanelLoading
+  },
+  data () {
+    return {
+      title: '',
+      titleDraft: '',
+      membersUserIds: [],
+      removedUserIds: new Set([]), // users we added for members, to undo
+      searchUserIds: [],
+      addedUserIds: new Set([]), // users we added from search, to undo
+      searchLoading: false,
+      reallyDelete: false
+    }
+  },
+  created () {
+    if (!this.id) return
+    this.$store.dispatch('fetchList', { listId: this.id })
+      .then(() => {
+        this.title = this.findListTitle(this.id)
+        this.titleDraft = this.title
+      })
+    this.$store.dispatch('fetchListAccounts', { listId: this.id })
+      .then(() => {
+        this.membersUserIds = this.findListAccounts(this.id)
+        this.membersUserIds.forEach(userId => {
+          this.$store.dispatch('fetchUserIfMissing', userId)
+        })
+      })
+  },
+  computed: {
+    id () {
+      return this.$route.params.id
+    },
+    membersUsers () {
+      return [...this.membersUserIds, ...this.addedUserIds]
+        .map(userId => this.findUser(userId)).filter(user => user)
+    },
+    searchUsers () {
+      return this.searchUserIds.map(userId => this.findUser(userId)).filter(user => user)
+    },
+    ...mapState({
+      currentUser: state => state.users.currentUser
+    }),
+    ...mapGetters(['findUser', 'findListTitle', 'findListAccounts'])
+  },
+  methods: {
+    onInput () {
+      this.search(this.query)
+    },
+    toggleRemoveMember (user) {
+      if (this.removedUserIds.has(user.id)) {
+        this.id && this.addUser(user)
+        this.removedUserIds.delete(user.id)
+      } else {
+        this.id && this.removeUser(user.id)
+        this.removedUserIds.add(user.id)
+      }
+    },
+    toggleAddFromSearch (user) {
+      if (this.addedUserIds.has(user.id)) {
+        this.id && this.removeUser(user.id)
+        this.addedUserIds.delete(user.id)
+      } else {
+        this.id && this.addUser(user)
+        this.addedUserIds.add(user.id)
+      }
+    },
+    isRemoved (user) {
+      return this.removedUserIds.has(user.id)
+    },
+    isAdded (user) {
+      return this.addedUserIds.has(user.id)
+    },
+    addUser (user) {
+      this.$store.dispatch('addListAccount', { accountId: this.user.id, listId: this.id })
+    },
+    removeUser (userId) {
+      this.$store.dispatch('removeListAccount', { accountId: this.user.id, listId: this.id })
+    },
+    onSearchLoading (results) {
+      this.searchLoading = true
+    },
+    onSearchLoadingDone (results) {
+      this.searchLoading = false
+    },
+    onSearchResults (results) {
+      this.searchLoading = false
+      this.searchUserIds = results
+    },
+    updateListTitle () {
+      this.$store.dispatch('setList', { listId: this.id, title: this.titleDraft })
+        .then(() => {
+          this.title = this.findListTitle(this.id)
+        })
+    },
+    createList () {
+      this.$store.dispatch('createList', { title: this.titleDraft })
+        .then((list) => {
+          return this
+            .$store
+            .dispatch('setListAccounts', { listId: list.id, accountIds: [...this.addedUserIds] })
+            .then(() => list.id)
+        })
+        .then((listId) => {
+          this.$router.push({ name: 'lists-timeline', params: { id: listId } })
+        })
+        .catch((e) => {
+          this.$store.dispatch('pushGlobalNotice', {
+            messageKey: 'lists.error',
+            messageArgs: [e.message],
+            level: 'error'
+          })
+        })
+    },
+    deleteList () {
+      this.$store.dispatch('deleteList', { listId: this.id })
+      this.$router.push({ name: 'lists' })
+    }
+  }
+}
+
+export default ListsNew

+ 228 - 0
src/components/lists_edit/lists_edit.vue

@@ -0,0 +1,228 @@
+<template>
+  <div class="panel-default panel ListEdit">
+    <div
+      ref="header"
+      class="panel-heading list-edit-heading"
+    >
+      <button
+        class="button-unstyled go-back-button"
+        @click="$router.back"
+      >
+        <FAIcon
+          size="lg"
+          icon="chevron-left"
+        />
+      </button>
+      <div class="title">
+        <i18n-t
+          v-if="id"
+          keypath="lists.editing_list"
+        >
+          <template #listTitle>
+            {{ title }}
+          </template>
+        </i18n-t>
+        <i18n-t
+          v-else
+          keypath="lists.creating_list"
+        />
+      </div>
+    </div>
+    <div class="panel-body">
+      <div class="input-wrap">
+        <label for="list-edit-title">{{ $t('lists.title') }}</label>
+        {{ ' ' }}
+        <input
+          id="list-edit-title"
+          ref="title"
+          v-model="titleDraft"
+        >
+        <button
+          v-if="id"
+          class="btn button-default follow-button"
+          @click="updateListTitle"
+        >
+          {{ $t('lists.update_title') }}
+        </button>
+      </div>
+      <tab-switcher
+        class="list-member-management"
+        :scrollable-tabs="true"
+      >
+        <div
+          v-if="id || addedUserIds.size > 0"
+          :label="$t('lists.manage_members')"
+          class="members-list"
+        >
+          <div class="users-list">
+            <div
+              v-for="user in membersUsers"
+              :key="user.id"
+              class="member"
+            >
+              <BasicUserCard
+                :user="user"
+              >
+                <button
+                  class="btn button-default follow-button"
+                  @click="toggleRemoveMember(user)"
+                >
+                  {{ isRemoved(user) ? $t('general.undo') : $t('lists.remove_from_list') }}
+                </button>
+              </BasicUserCard>
+            </div>
+          </div>
+        </div>
+
+        <div
+          class="search-list"
+          :label="$t('lists.add_members')"
+        >
+          <ListsUserSearch
+            @results="onSearchResults"
+            @loading="onSearchLoading"
+            @loadingDone="onSearchLoadingDone"
+          />
+          <div
+            v-if="searchLoading"
+            class="loading"
+          >
+            <PanelLoading />
+          </div>
+          <div
+            v-else
+            class="users-list"
+          >
+            <div
+              v-for="user in searchUsers"
+              :key="user.id"
+              class="member"
+            >
+              <BasicUserCard
+                :user="user"
+              >
+                <span
+                  v-if="membersUserIds.includes(user.id)"
+                >
+                  {{ $t('lists.is_in_list') }}
+                </span>
+                <button
+                  v-if="!membersUserIds.includes(user.id)"
+                  class="btn button-default follow-button"
+                  @click="toggleAddFromSearch(user)"
+                >
+                  {{ isAdded(user) ? $t('general.undo') : $t('lists.add_to_list') }}
+                </button>
+                <button
+                  v-else
+                  class="btn button-default follow-button"
+                  @click="toggleRemoveMember(user)"
+                >
+                  {{ isRemoved(user) ? $t('general.undo') : $t('lists.remove_from_list') }}
+                </button>
+              </BasicUserCard>
+            </div>
+          </div>
+        </div>
+      </tab-switcher>
+    </div>
+    <div class="panel-footer">
+      <span class="spacer" />
+      <button
+        v-if="!id"
+        class="btn button-default footer-button"
+        @click="createList"
+      >
+        {{ $t('lists.create') }}
+      </button>
+      <button
+        v-else-if="!reallyDelete"
+        class="btn button-default footer-button"
+        @click="reallyDelete = true"
+      >
+        {{ $t('lists.delete') }}
+      </button>
+      <template v-else>
+        {{ $t('lists.really_delete') }}
+        <button
+          class="btn button-default footer-button"
+          @click="deleteList"
+        >
+          {{ $t('general.yes') }}
+        </button>
+        <button
+          class="btn button-default footer-button"
+          @click="reallyDelete = false"
+        >
+          {{ $t('general.no') }}
+        </button>
+      </template>
+    </div>
+  </div>
+</template>
+
+<script src="./lists_edit.js"></script>
+
+<style lang="scss">
+@import '../../_variables.scss';
+
+.ListEdit {
+  --panel-body-padding: 0.5em;
+
+  height: calc(100vh - var(--navbar-height));
+  overflow: hidden;
+  display: flex;
+  flex-direction: column;
+
+  .list-edit-heading {
+    grid-template-columns: auto minmax(50%, 1fr);
+  }
+
+  .panel-body {
+    display: flex;
+    flex: 1;
+    flex-direction: column;
+    overflow: hidden;
+  }
+
+  .list-member-management {
+    flex: 1 0 auto;
+  }
+
+  .search-icon {
+    margin-right: 0.3em;
+  }
+
+  .users-list {
+    padding-bottom: 0.7rem;
+    overflow-y: auto;
+  }
+
+  & .search-list,
+  & .members-list {
+    overflow: hidden;
+    flex-direction: column;
+    min-height: 0;
+  }
+
+  .go-back-button {
+    text-align: center;
+    line-height: 1;
+    height: 100%;
+    align-self: start;
+    width: var(--__panel-heading-height-inner);
+  }
+
+  .btn {
+    margin: 0 0.5em;
+  }
+
+  .panel-footer {
+    grid-template-columns: minmax(10%, 1fr);
+
+    .footer-button {
+      min-width: 9em;
+    }
+  }
+}
+</style>

+ 22 - 0
src/components/lists_menu/lists_menu_content.js

@@ -0,0 +1,22 @@
+import { mapState } from 'vuex'
+import NavigationEntry from 'src/components/navigation/navigation_entry.vue'
+import { getListEntries } from 'src/components/navigation/filter.js'
+
+export const ListsMenuContent = {
+  props: [
+    'showPin'
+  ],
+  components: {
+    NavigationEntry
+  },
+  computed: {
+    ...mapState({
+      lists: getListEntries,
+      currentUser: state => state.users.currentUser,
+      privateMode: state => state.instance.private,
+      federating: state => state.instance.federating
+    })
+  }
+}
+
+export default ListsMenuContent

+ 12 - 0
src/components/lists_menu/lists_menu_content.vue

@@ -0,0 +1,12 @@
+<template>
+  <ul>
+    <NavigationEntry
+      v-for="item in lists"
+      :key="item.name"
+      :show-pin="showPin"
+      :item="item"
+    />
+  </ul>
+</template>
+
+<script src="./lists_menu_content.js"></script>

+ 36 - 0
src/components/lists_timeline/lists_timeline.js

@@ -0,0 +1,36 @@
+import Timeline from '../timeline/timeline.vue'
+const ListsTimeline = {
+  data () {
+    return {
+      listId: null
+    }
+  },
+  components: {
+    Timeline
+  },
+  computed: {
+    timeline () { return this.$store.state.statuses.timelines.list }
+  },
+  watch: {
+    $route: function (route) {
+      if (route.name === 'lists-timeline' && route.params.id !== this.listId) {
+        this.listId = route.params.id
+        this.$store.dispatch('stopFetchingTimeline', 'list')
+        this.$store.commit('clearTimeline', { timeline: 'list' })
+        this.$store.dispatch('fetchList', { listId: this.listId })
+        this.$store.dispatch('startFetchingTimeline', { timeline: 'list', listId: this.listId })
+      }
+    }
+  },
+  created () {
+    this.listId = this.$route.params.id
+    this.$store.dispatch('fetchList', { listId: this.listId })
+    this.$store.dispatch('startFetchingTimeline', { timeline: 'list', listId: this.listId })
+  },
+  unmounted () {
+    this.$store.dispatch('stopFetchingTimeline', 'list')
+    this.$store.commit('clearTimeline', { timeline: 'list' })
+  }
+}
+
+export default ListsTimeline

+ 10 - 0
src/components/lists_timeline/lists_timeline.vue

@@ -0,0 +1,10 @@
+<template>
+  <Timeline
+    title="list.name"
+    :timeline="timeline"
+    :list-id="listId"
+    timeline-name="list"
+  />
+</template>
+
+<script src="./lists_timeline.js"></script>

+ 51 - 0
src/components/lists_user_search/lists_user_search.js

@@ -0,0 +1,51 @@
+import { library } from '@fortawesome/fontawesome-svg-core'
+import {
+  faSearch,
+  faChevronLeft
+} from '@fortawesome/free-solid-svg-icons'
+import { debounce } from 'lodash'
+import Checkbox from '../checkbox/checkbox.vue'
+
+library.add(
+  faSearch,
+  faChevronLeft
+)
+
+const ListsUserSearch = {
+  components: {
+    Checkbox
+  },
+  emits: ['loading', 'loadingDone', 'results'],
+  data () {
+    return {
+      loading: false,
+      query: '',
+      followingOnly: true
+    }
+  },
+  methods: {
+    onInput: debounce(function () {
+      this.search(this.query)
+    }, 2000),
+    search (query) {
+      if (!query) {
+        this.loading = false
+        return
+      }
+
+      this.loading = true
+      this.$emit('loading')
+      this.userIds = []
+      this.$store.dispatch('search', { q: query, resolve: true, type: 'accounts', following: this.followingOnly })
+        .then(data => {
+          this.$emit('results', data.accounts.map(a => a.id))
+        })
+        .finally(() => {
+          this.loading = false
+          this.$emit('loadingDone')
+        })
+    }
+  }
+}
+
+export default ListsUserSearch

+ 47 - 0
src/components/lists_user_search/lists_user_search.vue

@@ -0,0 +1,47 @@
+<template>
+  <div class="ListsUserSearch">
+    <div class="input-wrap">
+      <div class="input-search">
+        <FAIcon
+          class="search-icon fa-scale-110 fa-old-padding"
+          icon="search"
+        />
+      </div>
+      <input
+        ref="search"
+        v-model="query"
+        :placeholder="$t('lists.search')"
+        @input="onInput"
+      >
+    </div>
+    <div class="input-wrap">
+      <Checkbox
+        v-model="followingOnly"
+        @change="onInput"
+      >
+        {{ $t('lists.following_only') }}
+      </Checkbox>
+    </div>
+  </div>
+</template>
+
+<script src="./lists_user_search.js"></script>
+<style lang="scss">
+@import '../../_variables.scss';
+
+.ListsUserSearch {
+  .input-wrap {
+    display: flex;
+    margin: 0.7em 0.5em 0.7em 0.5em;
+
+    input {
+      width: 100%;
+    }
+  }
+
+  .search-icon {
+    margin-right: 0.3em;
+  }
+}
+
+</style>

+ 2 - 0
src/components/mention_link/mention_link.js

@@ -2,6 +2,7 @@ import generateProfileLink from 'src/services/user_profile_link_generator/user_p
 import { mapGetters, mapState } from 'vuex'
 import { highlightClass, highlightStyle } from '../../services/user_highlighter/user_highlighter.js'
 import UserAvatar from '../user_avatar/user_avatar.vue'
+import UnicodeDomainIndicator from '../unicode_domain_indicator/unicode_domain_indicator.vue'
 import { defineAsyncComponent } from 'vue'
 import { library } from '@fortawesome/fontawesome-svg-core'
 import {
@@ -16,6 +17,7 @@ const MentionLink = {
   name: 'MentionLink',
   components: {
     UserAvatar,
+    UnicodeDomainIndicator,
     UserPopover: defineAsyncComponent(() => import('../user_popover/user_popover.vue'))
   },
   props: {

+ 3 - 0
src/components/mention_link/mention_link.vue

@@ -47,6 +47,9 @@
             class="serverName"
             :class="{ '-faded': shouldFadeDomain }"
             v-html="'@' + serverName"
+          /><UnicodeDomainIndicator
+            v-if="shouldShowFullUserName"
+            :user="user"
           />
           </span>
           <span

+ 7 - 2
src/components/mobile_nav/mobile_nav.js

@@ -2,6 +2,7 @@ import SideDrawer from '../side_drawer/side_drawer.vue'
 import Notifications from '../notifications/notifications.vue'
 import { unseenNotificationsFromStore } from '../../services/notification_utils/notification_utils'
 import GestureService from '../../services/gesture_service/gesture_service'
+import NavigationPins from 'src/components/navigation/navigation_pins.vue'
 import { mapGetters } from 'vuex'
 import { library } from '@fortawesome/fontawesome-svg-core'
 import {
@@ -19,7 +20,8 @@ library.add(
 const MobileNav = {
   components: {
     SideDrawer,
-    Notifications
+    Notifications,
+    NavigationPins
   },
   data: () => ({
     notificationsCloseGesture: undefined,
@@ -47,7 +49,10 @@ const MobileNav = {
     isChat () {
       return this.$route.name === 'chat'
     },
-    ...mapGetters(['unreadChatCount'])
+    ...mapGetters(['unreadChatCount']),
+    chatsPinned () {
+      return new Set(this.$store.state.serverSideStorage.prefsStorage.collections.pinnedNavItems).has('chats')
+    }
   },
   methods: {
     toggleMobileSidebar () {

+ 16 - 13
src/components/mobile_nav/mobile_nav.vue

@@ -17,20 +17,12 @@
             icon="bars"
           />
           <div
-            v-if="unreadChatCount"
+            v-if="unreadChatCount && !chatsPinned"
             class="alert-dot"
           />
         </button>
-        <router-link
-          v-if="!hideSitename"
-          class="site-name"
-          :to="{ name: 'root' }"
-          active-class="home"
-        >
-          {{ sitename }}
-        </router-link>
-      </div>
-      <div class="item right">
+        <NavigationPins class="pins" />
+      </div> <div class="item right">
         <button
           v-if="currentUser"
           class="button-unstyled mobile-nav-button"
@@ -94,6 +86,7 @@
     grid-template-columns: 2fr auto;
     width: 100%;
     box-sizing: border-box;
+
     a {
       color: var(--topBarLink, $fallback--link);
     }
@@ -178,13 +171,20 @@
     }
   }
 
+  .pins {
+    flex: 1;
+
+    .pinned-item {
+      flex-grow: 1;
+    }
+  }
+
   .mobile-notifications {
     margin-top: 50px;
     width: 100vw;
     height: calc(100vh - var(--navbar-height));
     overflow-x: hidden;
     overflow-y: scroll;
-
     color: $fallback--text;
     color: var(--text, $fallback--text);
     background-color: $fallback--bg;
@@ -194,14 +194,17 @@
       padding: 0;
       border-radius: 0;
       box-shadow: none;
+
       .panel {
         border-radius: 0;
         margin: 0;
         box-shadow: none;
       }
-      .panel:after {
+
+      .panel::after {
         border-radius: 0;
       }
+
       .panel .panel-heading {
         border-radius: 0;
         box-shadow: none;

+ 2 - 1
src/components/mobile_post_status_button/mobile_post_status_button.js

@@ -10,7 +10,8 @@ library.add(
 
 const HIDDEN_FOR_PAGES = new Set([
   'chats',
-  'chat'
+  'chat',
+  'lists-edit'
 ])
 
 const MobilePostStatusButton = {

+ 70 - 10
src/components/nav_panel/nav_panel.js

@@ -1,5 +1,10 @@
-import TimelineMenuContent from '../timeline_menu/timeline_menu_content.vue'
+import ListsMenuContent from 'src/components/lists_menu/lists_menu_content.vue'
 import { mapState, mapGetters } from 'vuex'
+import { TIMELINES, ROOT_ITEMS } from 'src/components/navigation/navigation.js'
+import { filterNavigation } from 'src/components/navigation/filter.js'
+import NavigationEntry from 'src/components/navigation/navigation_entry.vue'
+import NavigationPins from 'src/components/navigation/navigation_pins.vue'
+import Checkbox from 'src/components/checkbox/checkbox.vue'
 
 import { library } from '@fortawesome/fontawesome-svg-core'
 import {
@@ -12,7 +17,8 @@ import {
   faComments,
   faBell,
   faInfoCircle,
-  faStream
+  faStream,
+  faList
 } from '@fortawesome/free-solid-svg-icons'
 
 library.add(
@@ -25,26 +31,52 @@ library.add(
   faComments,
   faBell,
   faInfoCircle,
-  faStream
+  faStream,
+  faList
 )
-
 const NavPanel = {
+  props: ['forceExpand', 'forceEditMode'],
   created () {
-    if (this.currentUser && this.currentUser.locked) {
-      this.$store.dispatch('startFetchingFollowRequests')
-    }
   },
   components: {
-    TimelineMenuContent
+    ListsMenuContent,
+    NavigationEntry,
+    NavigationPins,
+    Checkbox
   },
   data () {
     return {
-      showTimelines: false
+      editMode: false,
+      showTimelines: false,
+      showLists: false,
+      timelinesList: Object.entries(TIMELINES).map(([k, v]) => ({ ...v, name: k })),
+      rootList: Object.entries(ROOT_ITEMS).map(([k, v]) => ({ ...v, name: k }))
     }
   },
   methods: {
     toggleTimelines () {
       this.showTimelines = !this.showTimelines
+    },
+    toggleLists () {
+      this.showLists = !this.showLists
+    },
+    toggleEditMode () {
+      this.editMode = !this.editMode
+    },
+    toggleCollapse () {
+      this.$store.commit('setPreference', { path: 'simple.collapseNav', value: !this.collapsed })
+      this.$store.dispatch('pushServerSideStorage')
+    },
+    isPinned (item) {
+      return this.pinnedItems.has(item)
+    },
+    togglePin (item) {
+      if (this.isPinned(item)) {
+        this.$store.commit('removeCollectionPreference', { path: 'collections.pinnedNavItems', value: item })
+      } else {
+        this.$store.commit('addCollectionPreference', { path: 'collections.pinnedNavItems', value: item })
+      }
+      this.$store.dispatch('pushServerSideStorage')
     }
   },
   computed: {
@@ -53,8 +85,36 @@ const NavPanel = {
       followRequestCount: state => state.api.followRequests.length,
       privateMode: state => state.instance.private,
       federating: state => state.instance.federating,
-      pleromaChatMessagesAvailable: state => state.instance.pleromaChatMessagesAvailable
+      pleromaChatMessagesAvailable: state => state.instance.pleromaChatMessagesAvailable,
+      pinnedItems: state => new Set(state.serverSideStorage.prefsStorage.collections.pinnedNavItems),
+      collapsed: state => state.serverSideStorage.prefsStorage.simple.collapseNav
     }),
+    timelinesItems () {
+      return filterNavigation(
+        Object
+          .entries({ ...TIMELINES })
+          .map(([k, v]) => ({ ...v, name: k })),
+        {
+          hasChats: this.pleromaChatMessagesAvailable,
+          isFederating: this.federating,
+          isPrivate: this.privateMode,
+          currentUser: this.currentUser
+        }
+      )
+    },
+    rootItems () {
+      return filterNavigation(
+        Object
+          .entries({ ...ROOT_ITEMS })
+          .map(([k, v]) => ({ ...v, name: k })),
+        {
+          hasChats: this.pleromaChatMessagesAvailable,
+          isFederating: this.federating,
+          isPrivate: this.privateMode,
+          currentUser: this.currentUser
+        }
+      )
+    },
     ...mapGetters(['unreadChatCount'])
   }
 }

+ 91 - 125
src/components/nav_panel/nav_panel.vue

@@ -1,90 +1,99 @@
 <template>
   <div class="NavPanel">
     <div class="panel panel-default">
-      <ul>
-        <li v-if="currentUser || !privateMode">
-          <button
-            class="button-unstyled menu-item"
-            @click="toggleTimelines"
-          >
-            <FAIcon
-              fixed-width
-              class="fa-scale-110"
-              icon="stream"
-            />{{ $t("nav.timelines") }}
-            <FAIcon
-              class="timelines-chevron"
-              fixed-width
-              :icon="showTimelines ? 'chevron-up' : 'chevron-down'"
+      <div
+        v-if="!forceExpand"
+        class="panel-heading nav-panel-heading"
+      >
+        <NavigationPins :limit="6" />
+        <div class="spacer" />
+        <button
+          class="button-unstyled"
+          @click="toggleCollapse"
+        >
+          <FAIcon
+            class="timelines-chevron"
+            fixed-width
+            :icon="collapsed ? 'chevron-down' : 'chevron-up'"
+          />
+        </button>
+      </div>
+      <ul
+        v-if="!collapsed || forceExpand"
+        class="panel-body"
+      >
+        <NavigationEntry
+          v-if="currentUser || !privateMode"
+          :show-pin="false"
+          :item="{ icon: 'stream', label: 'nav.timelines' }"
+          :aria-expanded="showTimelines ? 'true' : 'false'"
+          @click="toggleTimelines"
+        >
+          <FAIcon
+            class="timelines-chevron"
+            fixed-width
+            :icon="showTimelines ? 'chevron-up' : 'chevron-down'"
+          />
+        </NavigationEntry>
+        <div
+          v-show="showTimelines"
+          class="timelines-background"
+        >
+          <div class="timelines">
+            <NavigationEntry
+              v-for="item in timelinesItems"
+              :key="item.name"
+              :show-pin="editMode || forceEditMode"
+              :item="item"
             />
-          </button>
-          <div
-            v-show="showTimelines"
-            class="timelines-background"
-          >
-            <TimelineMenuContent class="timelines" />
           </div>
-        </li>
-        <li v-if="currentUser">
-          <router-link
-            class="menu-item"
-            :to="{ name: 'interactions', params: { username: currentUser.screen_name } }"
-          >
-            <FAIcon
-              fixed-width
-              class="fa-scale-110"
-              icon="bell"
-            />{{ $t("nav.interactions") }}
-          </router-link>
-        </li>
-        <li v-if="currentUser && pleromaChatMessagesAvailable">
+        </div>
+        <NavigationEntry
+          v-if="currentUser"
+          :show-pin="false"
+          :item="{ icon: 'list', label: 'nav.lists' }"
+          :aria-expanded="showLists ? 'true' : 'false'"
+          @click="toggleLists"
+        >
           <router-link
-            class="menu-item"
-            :to="{ name: 'chats', params: { username: currentUser.screen_name } }"
+            :title="$t('lists.manage_lists')"
+            class="extra-button"
+            :to="{ name: 'lists' }"
+            @click.stop
           >
-            <div
-              v-if="unreadChatCount"
-              class="badge badge-notification"
-            >
-              {{ unreadChatCount }}
-            </div>
             <FAIcon
+              class="extra-button"
               fixed-width
-              class="fa-scale-110"
-              icon="comments"
-            />{{ $t("nav.chats") }}
-          </router-link>
-        </li>
-        <li v-if="currentUser && currentUser.locked">
-          <router-link
-            class="menu-item"
-            :to="{ name: 'friend-requests' }"
-          >
-            <FAIcon
-              fixed-width
-              class="fa-scale-110"
-              icon="user-plus"
-            />{{ $t("nav.friend_requests") }}
-            <span
-              v-if="followRequestCount > 0"
-              class="badge badge-notification"
-            >
-              {{ followRequestCount }}
-            </span>
-          </router-link>
-        </li>
-        <li>
-          <router-link
-            class="menu-item"
-            :to="{ name: 'about' }"
-          >
-            <FAIcon
-              fixed-width
-              class="fa-scale-110"
-              icon="info-circle"
-            />{{ $t("nav.about") }}
+              icon="wrench"
+            />
           </router-link>
-        </li>
+          <FAIcon
+            class="timelines-chevron"
+            fixed-width
+            :icon="showLists ? 'chevron-up' : 'chevron-down'"
+          />
+        </NavigationEntry>
+        <div
+          v-show="showLists"
+          class="timelines-background"
+        >
+          <ListsMenuContent
+            :show-pin="editMode || forceEditMode"
+            class="timelines"
+          />
+        </div>
+        <NavigationEntry
+          v-for="item in rootItems"
+          :key="item.name"
+          :show-pin="editMode || forceEditMode"
+          :item="item"
+        />
+        <NavigationEntry
+          v-if="!forceEditMode && currentUser"
+          :show-pin="false"
+          :item="{ label: editMode ? $t('nav.edit_finish') : $t('nav.edit_pinned'), icon: editMode ? 'check' : 'wrench' }"
+          @click="toggleEditMode"
+        />
       </ul>
     </div>
   </div>
@@ -112,7 +121,6 @@
     border-bottom: 1px solid;
     border-color: $fallback--border;
     border-color: var(--border, $fallback--border);
-    padding: 0;
   }
 
   > li {
@@ -135,46 +143,9 @@
     border: none;
   }
 
-  .menu-item {
-    display: block;
-    box-sizing: border-box;
-    height: 3.5em;
-    line-height: 3.5em;
-    padding: 0 1em;
-    width: 100%;
-    color: $fallback--link;
-    color: var(--link, $fallback--link);
-
-    &:hover {
-      background-color: $fallback--lightBg;
-      background-color: var(--selectedMenu, $fallback--lightBg);
-      color: $fallback--link;
-      color: var(--selectedMenuText, $fallback--link);
-      --faint: var(--selectedMenuFaintText, $fallback--faint);
-      --faintLink: var(--selectedMenuFaintLink, $fallback--faint);
-      --lightText: var(--selectedMenuLightText, $fallback--lightText);
-      --icon: var(--selectedMenuIcon, $fallback--icon);
-    }
-
-    &.router-link-active {
-      font-weight: bolder;
-      background-color: $fallback--lightBg;
-      background-color: var(--selectedMenu, $fallback--lightBg);
-      color: $fallback--text;
-      color: var(--selectedMenuText, $fallback--text);
-      --faint: var(--selectedMenuFaintText, $fallback--faint);
-      --faintLink: var(--selectedMenuFaintLink, $fallback--faint);
-      --lightText: var(--selectedMenuLightText, $fallback--lightText);
-      --icon: var(--selectedMenuIcon, $fallback--icon);
-
-      &:hover {
-        text-decoration: underline;
-      }
-    }
-  }
-
   .timelines-chevron {
     margin-left: 0.8em;
+    margin-right: 0.8em;
     font-size: 1.1em;
   }
 
@@ -182,7 +153,7 @@
     padding: 0 0 0 0.6em;
     background-color: $fallback--lightBg;
     background-color: var(--selectedMenu, $fallback--lightBg);
-    border-top: 1px solid;
+    border-bottom: 1px solid;
     border-color: $fallback--border;
     border-color: var(--border, $fallback--border);
   }
@@ -192,14 +163,9 @@
     background-color: var(--bg, $fallback--bg);
   }
 
-  .fa-scale-110 {
-    margin-right: 0.8em;
-  }
-
-  .badge {
-    position: absolute;
-    right: 0.6rem;
-    top: 1.25em;
+  .nav-panel-heading {
+   // breaks without a unit
+   --panel-heading-height-padding: 0em;
   }
 }
 </style>

+ 18 - 0
src/components/navigation/filter.js

@@ -0,0 +1,18 @@
+export const filterNavigation = (list = [], { hasChats, isFederating, isPrivate, currentUser }) => {
+  return list.filter(({ criteria, anon, anonRoute }) => {
+    const set = new Set(criteria || [])
+    if (!isFederating && set.has('federating')) return false
+    if (isPrivate && set.has('!private')) return false
+    if (!currentUser && !(anon || anonRoute)) return false
+    if ((!currentUser || !currentUser.locked) && set.has('lockedUser')) return false
+    if (!hasChats && set.has('chats')) return false
+    return true
+  })
+}
+
+export const getListEntries = state => state.lists.allLists.map(list => ({
+  name: 'list-' + list.id,
+  routeObject: { name: 'lists-timeline', params: { id: list.id } },
+  labelRaw: list.title,
+  iconLetter: list.title[0]
+}))

+ 75 - 0
src/components/navigation/navigation.js

@@ -0,0 +1,75 @@
+export const USERNAME_ROUTES = new Set([
+  'bookmarks',
+  'dms',
+  'interactions',
+  'notifications',
+  'chat',
+  'chats',
+  'user-profile'
+])
+
+export const TIMELINES = {
+  home: {
+    route: 'friends',
+    icon: 'home',
+    label: 'nav.home_timeline',
+    criteria: ['!private']
+  },
+  public: {
+    route: 'public-timeline',
+    anon: true,
+    icon: 'users',
+    label: 'nav.public_tl',
+    criteria: ['!private']
+  },
+  twkn: {
+    route: 'public-external-timeline',
+    anon: true,
+    icon: 'globe',
+    label: 'nav.twkn',
+    criteria: ['!private', 'federating']
+  },
+  bookmarks: {
+    route: 'bookmarks',
+    icon: 'bookmark',
+    label: 'nav.bookmarks'
+  },
+  favorites: {
+    routeObject: { name: 'user-profile', query: { tab: 'favorites' } },
+    icon: 'star',
+    label: 'user_card.favorites'
+  },
+  dms: {
+    route: 'dms',
+    icon: 'envelope',
+    label: 'nav.dms'
+  }
+}
+
+export const ROOT_ITEMS = {
+  interactions: {
+    route: 'interactions',
+    icon: 'bell',
+    label: 'nav.interactions'
+  },
+  chats: {
+    route: 'chats',
+    icon: 'comments',
+    label: 'nav.chats',
+    badgeGetter: 'unreadChatCount',
+    criteria: ['chats']
+  },
+  friendRequests: {
+    route: 'friend-requests',
+    icon: 'user-plus',
+    label: 'nav.friend_requests',
+    criteria: ['lockedUser'],
+    badgeGetter: 'followRequestCount'
+  },
+  about: {
+    route: 'about',
+    anon: true,
+    icon: 'info-circle',
+    label: 'nav.about'
+  }
+}

+ 51 - 0
src/components/navigation/navigation_entry.js

@@ -0,0 +1,51 @@
+import { mapState } from 'vuex'
+import { USERNAME_ROUTES } from 'src/components/navigation/navigation.js'
+import OptionalRouterLink from 'src/components/optional_router_link/optional_router_link.vue'
+import { library } from '@fortawesome/fontawesome-svg-core'
+import { faThumbtack } from '@fortawesome/free-solid-svg-icons'
+
+library.add(faThumbtack)
+
+const NavigationEntry = {
+  props: ['item', 'showPin'],
+  components: {
+    OptionalRouterLink
+  },
+  methods: {
+    isPinned (value) {
+      return this.pinnedItems.has(value)
+    },
+    togglePin (value) {
+      if (this.isPinned(value)) {
+        this.$store.commit('removeCollectionPreference', { path: 'collections.pinnedNavItems', value })
+      } else {
+        this.$store.commit('addCollectionPreference', { path: 'collections.pinnedNavItems', value })
+      }
+      this.$store.dispatch('pushServerSideStorage')
+    }
+  },
+  computed: {
+    routeTo () {
+      if (!this.item.route && !this.item.routeObject) return null
+      let route
+      if (this.item.routeObject) {
+        route = this.item.routeObject
+      } else {
+        route = { name: (this.item.anon || this.currentUser) ? this.item.route : this.item.anonRoute }
+      }
+      if (USERNAME_ROUTES.has(route.name)) {
+        route.params = { username: this.currentUser.screen_name, name: this.currentUser.screen_name }
+      }
+      return route
+    },
+    getters () {
+      return this.$store.getters
+    },
+    ...mapState({
+      currentUser: state => state.users.currentUser,
+      pinnedItems: state => new Set(state.serverSideStorage.prefsStorage.collections.pinnedNavItems)
+    })
+  }
+}
+
+export default NavigationEntry

+ 133 - 0
src/components/navigation/navigation_entry.vue

@@ -0,0 +1,133 @@
+<template>
+  <OptionalRouterLink
+    v-slot="{ isActive, href, navigate } = {}"
+    ass="ass"
+    :to="routeTo"
+  >
+    <li
+      class="NavigationEntry menu-item"
+      :class="{ '-active': isActive }"
+      v-bind="$attrs"
+    >
+      <component
+        :is="routeTo ? 'a' : 'button'"
+        class="main-link button-unstyled"
+        :href="href"
+        @click="navigate"
+      >
+        <span>
+          <FAIcon
+            v-if="item.icon"
+            fixed-width
+            class="fa-scale-110 menu-icon"
+            :icon="item.icon"
+          />
+        </span>
+        <span
+          v-if="item.iconLetter"
+          class="icon iconLetter fa-scale-110 menu-icon"
+        >{{ item.iconLetter }}
+        </span>
+        <span class="label">
+          {{ item.labelRaw || $t(item.label) }}
+        </span>
+      </component>
+      <slot />
+      <div
+        v-if="item.badgeGetter && getters[item.badgeGetter]"
+        class="badge badge-notification"
+      >
+        {{ getters[item.badgeGetter] }}
+      </div>
+      <button
+        v-if="showPin && currentUser"
+        type="button"
+        class="button-unstyled extra-button"
+        :title="$t(isPinned ? 'general.unpin' : 'general.pin' )"
+        :aria-pressed="!!isPinned"
+        @click.stop.prevent="togglePin(item.name)"
+      >
+        <FAIcon
+          v-if="showPin && currentUser"
+          fixed-width
+          class="fa-scale-110"
+          :class="{ 'veryfaint': !isPinned(item.name) }"
+          :transform="!isPinned(item.name) ? 'rotate-45' : ''"
+          icon="thumbtack"
+        />
+      </button>
+    </li>
+  </OptionalRouterLink>
+</template>
+
+<script src="./navigation_entry.js"></script>
+
+<style lang="scss">
+@import '../../_variables.scss';
+
+.NavigationEntry {
+  display: flex;
+  box-sizing: border-box;
+  align-items: baseline;
+  height: 3.5em;
+  line-height: 3.5em;
+  padding: 0 1em;
+  width: 100%;
+  color: $fallback--link;
+  color: var(--link, $fallback--link);
+
+  .timelines-chevron {
+    margin-right: 0;
+  }
+
+  .main-link {
+    flex: 1;
+  }
+
+  .menu-icon {
+    margin-right: 0.8em;
+  }
+
+  .extra-button {
+    width: 3em;
+    text-align: center;
+
+    &:last-child {
+      margin-right: -0.8em;
+    }
+  }
+
+  &:hover {
+    background-color: $fallback--lightBg;
+    background-color: var(--selectedMenu, $fallback--lightBg);
+    color: $fallback--link;
+    color: var(--selectedMenuText, $fallback--link);
+    --faint: var(--selectedMenuFaintText, $fallback--faint);
+    --faintLink: var(--selectedMenuFaintLink, $fallback--faint);
+    --lightText: var(--selectedMenuLightText, $fallback--lightText);
+
+    .menu-icon {
+      --icon: var(--text, $fallback--icon);
+    }
+  }
+
+  &.-active {
+    font-weight: bolder;
+    background-color: $fallback--lightBg;
+    background-color: var(--selectedMenu, $fallback--lightBg);
+    color: $fallback--text;
+    color: var(--selectedMenuText, $fallback--text);
+    --faint: var(--selectedMenuFaintText, $fallback--faint);
+    --faintLink: var(--selectedMenuFaintLink, $fallback--faint);
+    --lightText: var(--selectedMenuLightText, $fallback--lightText);
+
+    .menu-icon {
+      --icon: var(--text, $fallback--icon);
+    }
+
+    &:hover {
+      text-decoration: underline;
+    }
+  }
+}
+</style>

+ 88 - 0
src/components/navigation/navigation_pins.js

@@ -0,0 +1,88 @@
+import { mapState } from 'vuex'
+import { TIMELINES, ROOT_ITEMS, USERNAME_ROUTES } from 'src/components/navigation/navigation.js'
+import { getListEntries, filterNavigation } from 'src/components/navigation/filter.js'
+
+import { library } from '@fortawesome/fontawesome-svg-core'
+import {
+  faUsers,
+  faGlobe,
+  faBookmark,
+  faEnvelope,
+  faComments,
+  faBell,
+  faInfoCircle,
+  faStream,
+  faList
+} from '@fortawesome/free-solid-svg-icons'
+
+library.add(
+  faUsers,
+  faGlobe,
+  faBookmark,
+  faEnvelope,
+  faComments,
+  faBell,
+  faInfoCircle,
+  faStream,
+  faList
+)
+
+const NavPanel = {
+  props: ['limit'],
+  methods: {
+    getRouteTo (item) {
+      if (item.routeObject) {
+        return item.routeObject
+      }
+      const route = { name: (item.anon || this.currentUser) ? item.route : item.anonRoute }
+      if (USERNAME_ROUTES.has(route.name)) {
+        route.params = { username: this.currentUser.screen_name }
+      }
+      return route
+    }
+  },
+  computed: {
+    getters () {
+      return this.$store.getters
+    },
+    ...mapState({
+      lists: getListEntries,
+      currentUser: state => state.users.currentUser,
+      followRequestCount: state => state.api.followRequests.length,
+      privateMode: state => state.instance.private,
+      federating: state => state.instance.federating,
+      pleromaChatMessagesAvailable: state => state.instance.pleromaChatMessagesAvailable,
+      pinnedItems: state => new Set(state.serverSideStorage.prefsStorage.collections.pinnedNavItems)
+    }),
+    pinnedList () {
+      if (!this.currentUser) {
+        return [
+          { ...TIMELINES.public, name: 'public' },
+          { ...TIMELINES.twkn, name: 'twkn' },
+          { ...ROOT_ITEMS.about, name: 'about' }
+        ]
+      }
+      return filterNavigation(
+        [
+          ...Object
+            .entries({ ...TIMELINES })
+            .filter(([k]) => this.pinnedItems.has(k))
+            .map(([k, v]) => ({ ...v, name: k })),
+          ...this.lists.filter((k) => this.pinnedItems.has(k.name)),
+          ...Object
+            .entries({ ...ROOT_ITEMS })
+            .filter(([k]) => this.pinnedItems.has(k))
+            .map(([k, v]) => ({ ...v, name: k }))
+        ],
+        {
+          hasChats: this.pleromaChatMessagesAvailable,
+          isFederating: this.federating,
+          isPrivate: this.privateMode,
+          currentUser: this.currentUser
+        }
+      ).slice(0, this.limit)
+    }
+  }
+}
+
+export default NavPanel

+ 76 - 0
src/components/navigation/navigation_pins.vue

@@ -0,0 +1,76 @@
+<template>
+  <span class="NavigationPins">
+    <router-link
+      v-for="item in pinnedList"
+      :key="item.name"
+      class="pinned-item"
+      :to="getRouteTo(item)"
+      :title="item.labelRaw || $t(item.label)"
+    >
+      <FAIcon
+        v-if="item.icon"
+        fixed-width
+        :icon="item.icon"
+      />
+      <span
+        v-if="item.iconLetter"
+        class="iconLetter fa-scale-110 fa-old-padding"
+      >{{ item.iconLetter }}</span>
+      <div
+        v-if="item.badgeGetter && getters[item.badgeGetter]"
+        class="alert-dot"
+      />
+    </router-link>
+  </span>
+</template>
+
+<script src="./navigation_pins.js"></script>
+
+<style lang="scss">
+@import '../../_variables.scss';
+.NavigationPins {
+  display: flex;
+  flex-wrap: wrap;
+  overflow: hidden;
+  height: 100%;
+
+  .alert-dot {
+    border-radius: 100%;
+    height: 0.5em;
+    width: 0.5em;
+    position: absolute;
+    right: calc(50% - 0.25em);
+    top: calc(50% - 0.25em);
+    margin-left: 6px;
+    margin-top: -6px;
+    background-color: $fallback--cRed;
+    background-color: var(--badgeNotification, $fallback--cRed);
+  }
+
+  .pinned-item {
+    position: relative;
+    flex: 1 0 3em;
+    min-width: 2em;
+    text-align: center;
+    overflow: visible;
+    box-sizing: border-box;
+    height: 100%;
+
+    & .svg-inline--fa,
+    & .iconLetter {
+      margin: 0;
+    }
+
+    &.router-link-active {
+      color: $fallback--text;
+      color: var(--selectedMenuText, $fallback--text);
+      border-bottom: 4px solid;
+
+      & .svg-inline--fa,
+      & .iconLetter {
+        color: inherit;
+      }
+    }
+  }
+}
+</style>

+ 5 - 1
src/components/notification/notification.js

@@ -4,6 +4,8 @@ import Status from '../status/status.vue'
 import UserAvatar from '../user_avatar/user_avatar.vue'
 import UserCard from '../user_card/user_card.vue'
 import Timeago from '../timeago/timeago.vue'
+import Report from '../report/report.vue'
+import UserLink from '../user_link/user_link.vue'
 import RichContent from 'src/components/rich_content/rich_content.jsx'
 import UserPopover from '../user_popover/user_popover.vue'
 import { isStatusNotification } from '../../services/notification_utils/notification_utils.js'
@@ -47,8 +49,10 @@ const Notification = {
     UserCard,
     Timeago,
     Status,
+    Report,
     RichContent,
-    UserPopover
+    UserPopover,
+    UserLink
   },
   methods: {
     toggleUserExpanded () {

+ 17 - 11
src/components/notification/notification.vue

@@ -11,9 +11,10 @@
       class="Notification container -muted"
     >
       <small>
-        <router-link :to="userProfileLink">
-          {{ notification.from_profile.screen_name_ui }}
-        </router-link>
+        <user-link
+          :user="notification.from_profile"
+          :at="false"
+        />
       </small>
       <button
         class="button-unstyled unmute"
@@ -121,6 +122,9 @@
                 </i18n-t>
               </small>
             </span>
+            <span v-if="notification.type === 'pleroma:report'">
+              <small>{{ $t('notifications.submitted_report') }}</small>
+            </span>
             <span v-if="notification.type === 'poll'">
               <FAIcon
                 class="type-icon"
@@ -171,12 +175,10 @@
           v-if="notification.type === 'follow' || notification.type === 'follow_request'"
           class="follow-text"
         >
-          <router-link
-            :to="userProfileLink"
+          <user-link
             class="follow-name"
-          >
-            @{{ notification.from_profile.screen_name_ui }}
-          </router-link>
+            :user="notification.from_profile"
+          />
           <div
             v-if="notification.type === 'follow_request'"
             style="white-space: nowrap;"
@@ -207,10 +209,14 @@
           v-else-if="notification.type === 'move'"
           class="move-text"
         >
-          <router-link :to="targetUserProfileLink">
-            @{{ notification.target.screen_name_ui }}
-          </router-link>
+          <user-link
+            :user="notification.target"
+          />
         </div>
+        <Report
+          v-else-if="notification.type === 'pleroma:report'"
+          :report-id="notification.report.id"
+        />
         <template v-else>
           <StatusContent
             class="faint"

+ 4 - 2
src/components/notifications/notifications.scss

@@ -59,8 +59,10 @@
       height: 32px;
     }
 
-    --link: var(--faintLink);
-    --text: var(--faint);
+    .faint {
+      --link: var(--faintLink);
+      --text: var(--faint);
+    }
   }
 
   .follow-request-accept {

+ 23 - 0
src/components/optional_router_link/optional_router_link.vue

@@ -0,0 +1,23 @@
+<template>
+  <!-- eslint-disable vue/no-multiple-template-root -->
+  <router-link
+    v-if="to"
+    v-slot="props"
+    :to="to"
+    custom
+  >
+    <slot
+      v-bind="props"
+    />
+  </router-link>
+  <slot
+    v-else
+    v-bind="{}"
+  />
+</template>
+
+<script>
+export default {
+  props: ['to']
+}
+</script>

+ 23 - 2
src/components/popover/popover.js

@@ -4,7 +4,7 @@ const Popover = {
     // Action to trigger popover: either 'hover' or 'click'
     trigger: String,
 
-    // Either 'top' or 'bottom'
+    // 'top', 'bottom', 'left', 'right'
     placement: String,
 
     // Takes object with properties 'x' and 'y', values of these can be
@@ -84,6 +84,8 @@ const Popover = {
       const anchorStyle = getComputedStyle(anchorEl)
       const topPadding = parseFloat(anchorStyle.paddingTop)
       const bottomPadding = parseFloat(anchorStyle.paddingBottom)
+      const rightPadding = parseFloat(anchorStyle.paddingRight)
+      const leftPadding = parseFloat(anchorStyle.paddingLeft)
 
       // Screen position of the origin point for popover = center of the anchor
       const origin = {
@@ -170,7 +172,7 @@ const Popover = {
       if (overlayCenter) {
         translateX = origin.x + horizOffset
         translateY = origin.y + vertOffset
-      } else {
+      } else if (this.placement !== 'right' && this.placement !== 'left') {
         // Default to whatever user wished with placement prop
         let usingTop = this.placement !== 'bottom'
 
@@ -189,6 +191,25 @@ const Popover = {
 
         const xOffset = (this.offset && this.offset.x) || 0
         translateX = origin.x + horizOffset + xOffset
+      } else {
+        // Default to whatever user wished with placement prop
+        let usingRight = this.placement !== 'left'
+
+        // Handle special cases, first force to displaying on top if there's not space on bottom,
+        // regardless of what placement value was. Then check if there's not space on top, and
+        // force to bottom, again regardless of what placement value was.
+        const rightBoundary = origin.x - anchorWidth * 0.5 + (this.removePadding ? rightPadding : 0)
+        const leftBoundary = origin.x + anchorWidth * 0.5 - (this.removePadding ? leftPadding : 0)
+        if (leftBoundary + content.offsetWidth > xBounds.max) usingRight = true
+        if (rightBoundary - content.offsetWidth < xBounds.min) usingRight = false
+
+        const xOffset = (this.offset && this.offset.x) || 0
+        translateX = usingRight
+          ? rightBoundary - xOffset - content.offsetWidth
+          : leftBoundary + xOffset
+
+        const yOffset = (this.offset && this.offset.y) || 0
+        translateY = origin.y + vertOffset + yOffset
       }
 
       this.styles = {

+ 7 - 0
src/components/popover/popover.vue

@@ -126,6 +126,13 @@
       }
     }
 
+    &.-has-submenu {
+      .chevron-icon {
+        margin-right: 0.25rem;
+        margin-left: 2rem;
+      }
+    }
+
     &:active, &:hover {
       background-color: $fallback--lightBg;
       background-color: var(--selectedMenuPopover, $fallback--lightBg);

+ 41 - 13
src/components/post_status_form/post_status_form.js

@@ -55,6 +55,14 @@ const pxStringToNumber = (str) => {
 
 const PostStatusForm = {
   props: [
+    'statusId',
+    'statusText',
+    'statusIsSensitive',
+    'statusPoll',
+    'statusFiles',
+    'statusMediaDescriptions',
+    'statusScope',
+    'statusContentType',
     'replyTo',
     'repliedUser',
     'attentions',
@@ -62,6 +70,7 @@ const PostStatusForm = {
     'subject',
     'disableSubject',
     'disableScopeSelector',
+    'disableVisibilitySelector',
     'disableNotice',
     'disableLockWarning',
     'disablePolls',
@@ -125,22 +134,38 @@ const PostStatusForm = {
 
     const { postContentType: contentType, sensitiveByDefault } = this.$store.getters.mergedConfig
 
+    let statusParams = {
+      spoilerText: this.subject || '',
+      status: statusText,
+      nsfw: !!sensitiveByDefault,
+      files: [],
+      poll: {},
+      mediaDescriptions: {},
+      visibility: scope,
+      contentType
+    }
+
+    if (this.statusId) {
+      const statusContentType = this.statusContentType || contentType
+      statusParams = {
+        spoilerText: this.subject || '',
+        status: this.statusText || '',
+        nsfw: this.statusIsSensitive || !!sensitiveByDefault,
+        files: this.statusFiles || [],
+        poll: this.statusPoll || {},
+        mediaDescriptions: this.statusMediaDescriptions || {},
+        visibility: this.statusScope || scope,
+        contentType: statusContentType
+      }
+    }
+
     return {
       dropFiles: [],
       uploadingFiles: false,
       error: null,
       posting: false,
       highlighted: 0,
-      newStatus: {
-        spoilerText: this.subject || '',
-        status: statusText,
-        nsfw: !!sensitiveByDefault,
-        files: [],
-        poll: {},
-        mediaDescriptions: {},
-        visibility: scope,
-        contentType
-      },
+      newStatus: statusParams,
       caret: 0,
       pollFormVisible: false,
       showDropIcon: 'hide',
@@ -164,7 +189,7 @@ const PostStatusForm = {
     emojiUserSuggestor () {
       return suggestor({
         emoji: [
-          ...this.$store.state.instance.emoji,
+          ...this.$store.getters.standardEmojiList,
           ...this.$store.state.instance.customEmoji
         ],
         store: this.$store
@@ -173,13 +198,13 @@ const PostStatusForm = {
     emojiSuggestor () {
       return suggestor({
         emoji: [
-          ...this.$store.state.instance.emoji,
+          ...this.$store.getters.standardEmojiList,
           ...this.$store.state.instance.customEmoji
         ]
       })
     },
     emoji () {
-      return this.$store.state.instance.emoji || []
+      return this.$store.getters.standardEmojiList || []
     },
     customEmoji () {
       return this.$store.state.instance.customEmoji || []
@@ -236,6 +261,9 @@ const PostStatusForm = {
     uploadFileLimitReached () {
       return this.newStatus.files.length >= this.fileLimit
     },
+    isEdit () {
+      return typeof this.statusId !== 'undefined' && this.statusId.trim() !== ''
+    },
     ...mapGetters(['mergedConfig']),
     ...mapState({
       mobileLayout: state => state.interface.mobileLayout

+ 18 - 0
src/components/post_status_form/post_status_form.vue

@@ -66,6 +66,13 @@
           <span v-if="safeDMEnabled">{{ $t('post_status.direct_warning_to_first_only') }}</span>
           <span v-else>{{ $t('post_status.direct_warning_to_all') }}</span>
         </p>
+        <div
+          v-if="isEdit"
+          class="visibility-notice edit-warning"
+        >
+          <p>{{ $t('post_status.edit_remote_warning') }}</p>
+          <p>{{ $t('post_status.edit_unsupported_warning') }}</p>
+        </div>
         <div
           v-if="!disablePreview"
           class="preview-heading faint"
@@ -170,6 +177,7 @@
           class="visibility-tray"
         >
           <scope-selector
+            v-if="!disableVisibilitySelector"
             :show-all="showAllScopes"
             :user-default="userDefaultScope"
             :original-scope="copyMessageScope"
@@ -410,6 +418,16 @@
     align-items: baseline;
   }
 
+  .visibility-notice.edit-warning {
+    > :first-child {
+      margin-top: 0;
+    }
+
+    > :last-child {
+      margin-bottom: 0;
+    }
+  }
+
   .media-upload-icon, .poll-icon, .emoji-icon {
     font-size: 1.85em;
     line-height: 1.1;

+ 5 - 2
src/components/timeline/timeline_quick_settings.js → src/components/quick_filter_settings/quick_filter_settings.js

@@ -9,7 +9,10 @@ library.add(
   faWrench
 )
 
-const TimelineQuickSettings = {
+const QuickFilterSettings = {
+  props: {
+    conversation: Boolean
+  },
   components: {
     Popover
   },
@@ -64,4 +67,4 @@ const TimelineQuickSettings = {
   }
 }
 
-export default TimelineQuickSettings
+export default QuickFilterSettings

+ 8 - 10
src/components/timeline/timeline_quick_settings.vue → src/components/quick_filter_settings/quick_filter_settings.vue

@@ -1,13 +1,14 @@
 <template>
   <Popover
     trigger="click"
-    class="TimelineQuickSettings"
+    class="QuickFilterSettings"
     :bound-to="{ x: 'container' }"
   >
     <template #content>
       <div class="dropdown-menu">
         <div v-if="loggedIn">
           <button
+            v-if="!conversation"
             class="button-default dropdown-item"
             @click="replyVisibilityAll = true"
           >
@@ -17,6 +18,7 @@
             />{{ $t('settings.reply_visibility_all') }}
           </button>
           <button
+            v-if="!conversation"
             class="button-default dropdown-item"
             @click="replyVisibilityFollowing = true"
           >
@@ -26,6 +28,7 @@
             />{{ $t('settings.reply_visibility_following_short') }}
           </button>
           <button
+            v-if="!conversation"
             class="button-default dropdown-item"
             @click="replyVisibilitySelf = true"
           >
@@ -35,6 +38,7 @@
             />{{ $t('settings.reply_visibility_self_short') }}
           </button>
           <div
+            v-if="!conversation"
             role="separator"
             class="dropdown-divider"
           />
@@ -70,13 +74,7 @@
           class="button-default dropdown-item dropdown-item-icon"
           @click="openTab('filtering')"
         >
-          <FAIcon icon="font" />{{ $t('settings.word_filter') }}
-        </button>
-        <button
-          class="button-default dropdown-item dropdown-item-icon"
-          @click="openTab('general')"
-        >
-          <FAIcon icon="wrench" />{{ $t('settings.more_settings') }}
+          <FAIcon icon="font" />{{ $t('settings.word_filter_and_more') }}
         </button>
       </div>
     </template>
@@ -88,11 +86,11 @@
   </Popover>
 </template>
 
-<script src="./timeline_quick_settings.js"></script>
+<script src="./quick_filter_settings.js"></script>
 
 <style lang="scss">
 
-.TimelineQuickSettings {
+.QuickFilterSettings {
 
   > button {
     line-height: 100%;

+ 69 - 0
src/components/quick_view_settings/quick_view_settings.js

@@ -0,0 +1,69 @@
+import Popover from '../popover/popover.vue'
+import { mapGetters } from 'vuex'
+import { library } from '@fortawesome/fontawesome-svg-core'
+import { faList, faFolderTree, faBars, faWrench } from '@fortawesome/free-solid-svg-icons'
+
+library.add(
+  faList,
+  faFolderTree,
+  faBars,
+  faWrench
+)
+
+const QuickViewSettings = {
+  props: {
+    conversation: Boolean
+  },
+  components: {
+    Popover
+  },
+  methods: {
+    setConversationDisplay (visibility) {
+      this.$store.dispatch('setOption', { name: 'conversationDisplay', value: visibility })
+    },
+    openTab (tab) {
+      this.$store.dispatch('openSettingsModalTab', tab)
+    }
+  },
+  computed: {
+    ...mapGetters(['mergedConfig']),
+    loggedIn () {
+      return !!this.$store.state.users.currentUser
+    },
+    conversationDisplay: {
+      get () { return this.mergedConfig.conversationDisplay },
+      set (newVal) { this.setConversationDisplay(newVal) }
+    },
+    autoUpdate: {
+      get () { return this.mergedConfig.streaming },
+      set () {
+        const value = !this.autoUpdate
+        this.$store.dispatch('setOption', { name: 'streaming', value })
+      }
+    },
+    collapseWithSubjects: {
+      get () { return this.mergedConfig.collapseMessageWithSubject },
+      set () {
+        const value = !this.collapseWithSubjects
+        this.$store.dispatch('setOption', { name: 'collapseMessageWithSubject', value })
+      }
+    },
+    showUserAvatars: {
+      get () { return this.mergedConfig.mentionLinkShowAvatar },
+      set () {
+        const value = !this.showUserAvatars
+        console.log(value)
+        this.$store.dispatch('setOption', { name: 'mentionLinkShowAvatar', value })
+      }
+    },
+    muteBotStatuses: {
+      get () { return this.mergedConfig.muteBotStatuses },
+      set () {
+        const value = !this.muteBotStatuses
+        this.$store.dispatch('setOption', { name: 'muteBotStatuses', value })
+      }
+    }
+  }
+}
+
+export default QuickViewSettings

+ 94 - 0
src/components/quick_view_settings/quick_view_settings.vue

@@ -0,0 +1,94 @@
+<template>
+  <Popover
+    trigger="click"
+    class="QuickViewSettings"
+    :bound-to="{ x: 'container' }"
+  >
+    <template #content>
+      <div class="dropdown-menu">
+        <button
+          class="button-default dropdown-item"
+          @click="conversationDisplay = 'tree'"
+        >
+          <span
+            class="menu-checkbox -radio"
+            :class="{ 'menu-checkbox-checked': conversationDisplay === 'tree' }"
+          /><FAIcon icon="folder-tree" /> {{ $t('settings.conversation_display_tree_quick') }}
+        </button>
+        <button
+          class="button-default dropdown-item"
+          @click="conversationDisplay = 'linear'"
+        >
+          <span
+            class="menu-checkbox -radio"
+            :class="{ 'menu-checkbox-checked': conversationDisplay === 'linear' }"
+          /><FAIcon icon="list" /> {{ $t('settings.conversation_display_linear_quick') }}
+        </button>
+        <div
+          role="separator"
+          class="dropdown-divider"
+        />
+        <button
+          class="button-default dropdown-item"
+          @click="showUserAvatars = !showUserAvatars"
+        >
+          <span
+            class="menu-checkbox"
+            :class="{ 'menu-checkbox-checked': showUserAvatars }"
+          />{{ $t('settings.mention_link_show_avatar_quick') }}
+        </button>
+        <button
+          v-if="!conversation"
+          class="button-default dropdown-item"
+          @click="autoUpdate = !autoUpdate"
+        >
+          <span
+            class="menu-checkbox"
+            :class="{ 'menu-checkbox-checked': autoUpdate }"
+          />{{ $t('settings.auto_update') }}
+        </button>
+        <button
+          v-if="!conversation"
+          class="button-default dropdown-item"
+          @click="collapseWithSubjects = !collapseWithSubjects"
+        >
+          <span
+            class="menu-checkbox"
+            :class="{ 'menu-checkbox-checked': collapseWithSubjects }"
+          />{{ $t('settings.collapse_subject') }}
+        </button>
+        <button
+          class="button-default dropdown-item dropdown-item-icon"
+          @click="openTab('general')"
+        >
+          <FAIcon icon="wrench" />{{ $t('settings.more_settings') }}
+        </button>
+      </div>
+    </template>
+    <template #trigger>
+      <button class="button-unstyled">
+        <FAIcon icon="bars" />
+      </button>
+    </template>
+  </Popover>
+</template>
+
+<script src="./quick_view_settings.js"></script>
+
+<style lang="scss">
+
+.QuickViewSettings {
+
+  > button {
+    line-height: 100%;
+    height: 100%;
+    width: var(--__panel-heading-height-inner);
+    text-align: center;
+
+    svg {
+      font-size: 1.2em;
+    }
+  }
+}
+
+</style>

+ 17 - 4
src/components/react_button/react_button.js

@@ -1,15 +1,21 @@
 import Popover from '../popover/popover.vue'
 import { library } from '@fortawesome/fontawesome-svg-core'
+import { faPlus, faTimes } from '@fortawesome/free-solid-svg-icons'
 import { faSmileBeam } from '@fortawesome/free-regular-svg-icons'
 import { trim } from 'lodash'
 
-library.add(faSmileBeam)
+library.add(
+  faPlus,
+  faTimes,
+  faSmileBeam
+)
 
 const ReactButton = {
   props: ['status'],
   data () {
     return {
-      filterWord: ''
+      filterWord: '',
+      expanded: false
     }
   },
   components: {
@@ -25,6 +31,13 @@ const ReactButton = {
       }
       close()
     },
+    onShow () {
+      this.expanded = true
+      this.focusInput()
+    },
+    onClose () {
+      this.expanded = false
+    },
     focusInput () {
       this.$nextTick(() => {
         const input = this.$el.querySelector('input')
@@ -46,7 +59,7 @@ const ReactButton = {
       if (this.filterWord !== '') {
         const filterWordLowercase = trim(this.filterWord.toLowerCase())
         const orderedEmojiList = []
-        for (const emoji of this.$store.state.instance.emoji) {
+        for (const emoji of this.$store.getters.standardEmojiList) {
           if (emoji.replacement === this.filterWord) return [emoji]
 
           const indexOfFilterWord = emoji.displayText.toLowerCase().indexOf(filterWordLowercase)
@@ -59,7 +72,7 @@ const ReactButton = {
         }
         return orderedEmojiList.flat()
       }
-      return this.$store.state.instance.emoji || []
+      return this.$store.getters.standardEmojiList || []
     },
     mergedConfig () {
       return this.$store.getters.mergedConfig

+ 36 - 5
src/components/react_button/react_button.vue

@@ -7,7 +7,8 @@
     :bound-to="{ x: 'container' }"
     remove-padding
     popover-class="ReactButton popover-default"
-    @show="focusInput"
+    @show="onShow"
+    @close="onClose"
   >
     <template #content="{close}">
       <div class="reaction-picker-filter">
@@ -46,10 +47,24 @@
         class="button-unstyled popover-trigger"
         :title="$t('tool_tip.add_reaction')"
       >
-        <FAIcon
-          class="fa-scale-110 fa-old-padding"
-          :icon="['far', 'smile-beam']"
-        />
+        <FALayers>
+          <FAIcon
+            class="fa-scale-110 fa-old-padding"
+            :icon="['far', 'smile-beam']"
+          />
+          <FAIcon
+            v-show="!expanded"
+            class="focus-marker"
+            transform="shrink-6 up-9 right-17"
+            icon="plus"
+          />
+          <FAIcon
+            v-show="expanded"
+            class="focus-marker"
+            transform="shrink-6 up-9 right-17"
+            icon="times"
+          />
+        </FALayers>
       </span>
     </template>
   </Popover>
@@ -59,6 +74,7 @@
 
 <style lang="scss">
 @import '../../_variables.scss';
+@import '../../_mixins.scss';
 
 .ReactButton {
   .reaction-picker-filter {
@@ -125,6 +141,21 @@
       color: $fallback--text;
       color: var(--text, $fallback--text);
     }
+
+  }
+
+  .popover-trigger-button {
+    @include unfocused-style {
+      .focus-marker {
+        visibility: hidden;
+      }
+    }
+
+    @include focused-style {
+      .focus-marker {
+        visibility: visible;
+      }
+    }
   }
 }
 

+ 25 - 0
src/components/remove_follower_button/remove_follower_button.js

@@ -0,0 +1,25 @@
+export default {
+  props: ['relationship'],
+  data () {
+    return {
+      inProgress: false
+    }
+  },
+  computed: {
+    label () {
+      if (this.inProgress) {
+        return this.$t('user_card.follow_progress')
+      } else {
+        return this.$t('user_card.remove_follower')
+      }
+    }
+  },
+  methods: {
+    onClick () {
+      this.inProgress = true
+      this.$store.dispatch('removeUserFromFollowers', this.relationship.id).then(() => {
+        this.inProgress = false
+      })
+    }
+  }
+}

+ 13 - 0
src/components/remove_follower_button/remove_follower_button.vue

@@ -0,0 +1,13 @@
+<template>
+  <button
+    class="btn button-default follow-button"
+    :class="{ toggled: inProgress }"
+    :disabled="inProgress"
+    :title="$t('user_card.remove_follower')"
+    @click="onClick"
+  >
+    {{ label }}
+  </button>
+</template>
+
+<script src="./remove_follower_button.js"></script>

+ 10 - 2
src/components/reply_button/reply_button.js

@@ -1,7 +1,15 @@
 import { library } from '@fortawesome/fontawesome-svg-core'
-import { faReply } from '@fortawesome/free-solid-svg-icons'
+import {
+  faReply,
+  faPlus,
+  faTimes
+} from '@fortawesome/free-solid-svg-icons'
 
-library.add(faReply)
+library.add(
+  faReply,
+  faPlus,
+  faTimes
+)
 
 const ReplyButton = {
   name: 'ReplyButton',

+ 31 - 4
src/components/reply_button/reply_button.vue

@@ -7,10 +7,24 @@
       :title="$t('tool_tip.reply')"
       @click.prevent="$emit('toggle')"
     >
-      <FAIcon
-        class="fa-scale-110 fa-old-padding"
-        icon="reply"
-      />
+      <FALayers class="fa-old-padding-layer">
+        <FAIcon
+          class="fa-scale-110"
+          icon="reply"
+        />
+        <FAIcon
+          v-if="!replying"
+          class="focus-marker"
+          transform="shrink-6 up-8 right-11"
+          icon="plus"
+        />
+        <FAIcon
+          v-else
+          class="focus-marker"
+          transform="shrink-6 up-8 right-11"
+          icon="times"
+        />
+      </FALayers>
     </button>
     <span v-else>
       <FAIcon
@@ -32,6 +46,7 @@
 
 <style lang="scss">
 @import '../../_variables.scss';
+@import '../../_mixins.scss';
 
 .ReplyButton {
   display: flex;
@@ -52,6 +67,18 @@
       color: $fallback--cBlue;
       color: var(--cBlue, $fallback--cBlue);
     }
+
+    @include unfocused-style {
+      .focus-marker {
+        visibility: hidden;
+      }
+    }
+
+    @include focused-style {
+      .focus-marker {
+        visibility: visible;
+      }
+    }
   }
 
 }

+ 34 - 0
src/components/report/report.js

@@ -0,0 +1,34 @@
+import Select from '../select/select.vue'
+import StatusContent from '../status_content/status_content.vue'
+import Timeago from '../timeago/timeago.vue'
+import generateProfileLink from 'src/services/user_profile_link_generator/user_profile_link_generator'
+
+const Report = {
+  props: [
+    'reportId'
+  ],
+  components: {
+    Select,
+    StatusContent,
+    Timeago
+  },
+  computed: {
+    report () {
+      return this.$store.state.reports.reports[this.reportId] || {}
+    },
+    state: {
+      get: function () { return this.report.state },
+      set: function (val) { this.setReportState(val) }
+    }
+  },
+  methods: {
+    generateUserProfileLink (user) {
+      return generateProfileLink(user.id, user.screen_name, this.$store.state.instance.restrictedNicknames)
+    },
+    setReportState (state) {
+      return this.$store.dispatch('setReportState', { id: this.report.id, state })
+    }
+  }
+}
+
+export default Report

+ 43 - 0
src/components/report/report.scss

@@ -0,0 +1,43 @@
+@import '../../_variables.scss';
+
+.Report {
+  .report-content {
+    margin: 0.5em 0 1em;
+  }
+
+  .report-state {
+    margin: 0.5em 0 1em;
+  }
+
+  .reported-status {
+    border: 1px solid $fallback--faint;
+    border-color: var(--faint, $fallback--faint);
+    border-radius: $fallback--inputRadius;
+    border-radius: var(--inputRadius, $fallback--inputRadius);
+    color: $fallback--text;
+    color: var(--text, $fallback--text);
+    display: block;
+    padding: 0.5em;
+    margin: 0.5em 0;
+
+    .status-content {
+      pointer-events: none;
+    }
+
+    .reported-status-heading {
+      display: flex;
+      width: 100%;
+      justify-content: space-between;
+      margin-bottom: 0.2em;
+    }
+
+    .reported-status-name {
+      font-weight: bold;
+    }
+  }
+
+  .note {
+    width: 100%;
+    margin-bottom: 0.5em;
+  }
+}

+ 74 - 0
src/components/report/report.vue

@@ -0,0 +1,74 @@
+<template>
+  <div class="Report">
+    <div class="reported-user">
+      <span>{{ $t('report.reported_user') }}</span>
+      <router-link :to="generateUserProfileLink(report.acct)">
+        @{{ report.acct.screen_name }}
+      </router-link>
+    </div>
+    <div class="reporter">
+      <span>{{ $t('report.reporter') }}</span>
+      <router-link :to="generateUserProfileLink(report.actor)">
+        @{{ report.actor.screen_name }}
+      </router-link>
+    </div>
+    <div class="report-state">
+      <span>{{ $t('report.state') }}</span>
+      <Select
+        :id="report-state"
+        v-model="state"
+        class="form-control"
+      >
+        <option
+          v-for="state in ['open', 'closed', 'resolved']"
+          :key="state"
+          :value="state"
+        >
+          {{ $t('report.state_' + state) }}
+        </option>
+      </Select>
+    </div>
+    <RichContent
+      class="report-content"
+      :html="report.content"
+      :emoji="[]"
+    />
+    <div v-if="report.statuses.length">
+      <small>{{ $t('report.reported_statuses') }}</small>
+      <router-link
+        v-for="status in report.statuses"
+        :key="status.id"
+        :to="{ name: 'conversation', params: { id: status.id } }"
+        class="reported-status"
+      >
+        <div class="reported-status-heading">
+          <span class="reported-status-name">{{ status.user.name }}</span>
+          <Timeago
+            :time="status.created_at"
+            :auto-update="240"
+            class="faint"
+          />
+        </div>
+        <status-content :status="status" />
+      </router-link>
+    </div>
+    <div v-if="report.notes.length">
+      <small>{{ $t('report.notes') }}</small>
+      <div
+        v-for="note in report.notes"
+        :key="note.id"
+        class="note"
+      >
+        <span>{{ note.content }}</span>
+        <Timeago
+          :time="note.created_at"
+          :auto-update="240"
+          class="faint"
+        />
+      </div>
+    </div>
+  </div>
+</template>
+
+<script src="./report.js"></script>
+<style src="./report.scss" lang="scss"></style>

+ 12 - 2
src/components/retweet_button/retweet_button.js

@@ -1,7 +1,17 @@
 import { library } from '@fortawesome/fontawesome-svg-core'
-import { faRetweet } from '@fortawesome/free-solid-svg-icons'
+import {
+  faRetweet,
+  faPlus,
+  faMinus,
+  faCheck
+} from '@fortawesome/free-solid-svg-icons'
 
-library.add(faRetweet)
+library.add(
+  faRetweet,
+  faPlus,
+  faMinus,
+  faCheck
+)
 
 const RetweetButton = {
   props: ['status', 'loggedIn', 'visibility'],

+ 46 - 5
src/components/retweet_button/retweet_button.vue

@@ -7,11 +7,31 @@
       :title="$t('tool_tip.repeat')"
       @click.prevent="retweet()"
     >
-      <FAIcon
-        class="fa-scale-110 fa-old-padding"
-        icon="retweet"
-        :spin="animated"
-      />
+      <FALayers class="fa-old-padding-layer">
+        <FAIcon
+          class="fa-scale-110"
+          icon="retweet"
+          :spin="animated"
+        />
+        <FAIcon
+          v-if="status.repeated"
+          class="active-marker"
+          transform="shrink-6 up-9 right-12"
+          icon="check"
+        />
+        <FAIcon
+          v-if="!status.repeated"
+          class="focus-marker"
+          transform="shrink-6 up-9 right-12"
+          icon="plus"
+        />
+        <FAIcon
+          v-else
+          class="focus-marker"
+          transform="shrink-6 up-9 right-12"
+          icon="minus"
+        />
+      </FALayers>
     </button>
     <span v-else-if="loggedIn">
       <FAIcon
@@ -40,6 +60,7 @@
 
 <style lang="scss">
 @import '../../_variables.scss';
+@import '../../_mixins.scss';
 
 .RetweetButton {
   display: flex;
@@ -64,6 +85,26 @@
       color: $fallback--cGreen;
       color: var(--cGreen, $fallback--cGreen);
     }
+
+    @include unfocused-style {
+      .focus-marker {
+        visibility: hidden;
+      }
+
+      .active-marker {
+        visibility: visible;
+      }
+    }
+
+    @include focused-style {
+      .focus-marker {
+        visibility: visible;
+      }
+
+      .active-marker {
+        visibility: hidden;
+      }
+    }
   }
 }
 </style>

+ 2 - 0
src/components/search_bar/search_bar.vue

@@ -47,6 +47,8 @@
           class="cancel-icon fa-scale-110 fa-old-padding"
         />
       </button>
+      <span class="spacer" />
+      <span class="spacer" />
     </template>
   </div>
 </template>

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

@@ -42,6 +42,9 @@ export default {
   methods: {
     update (e) {
       set(this.$parent, this.path, e)
+    },
+    reset () {
+      set(this.$parent, this.path, this.defaultState)
     }
   }
 }

+ 6 - 1
src/components/settings_modal/helpers/boolean_setting.vue

@@ -15,7 +15,12 @@
         <slot />
       </span>
       {{ ' ' }}
-      <ModifiedIndicator :changed="isChanged" /><ServerSideIndicator :server-side="isServerSide" /> </Checkbox>
+      <ModifiedIndicator
+        :changed="isChanged"
+        :onclick="reset"
+      />
+      <ServerSideIndicator :server-side="isServerSide" />
+    </Checkbox>
   </label>
 </template>
 

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

@@ -43,6 +43,9 @@ export default {
   methods: {
     update (e) {
       set(this.$parent, this.path, e)
+    },
+    reset () {
+      set(this.$parent, this.path, this.defaultState)
     }
   }
 }

+ 4 - 1
src/components/settings_modal/helpers/choice_setting.vue

@@ -19,7 +19,10 @@
         {{ option.value === defaultState ? $t('settings.instance_default_simple') : '' }}
       </option>
     </Select>
-    <ModifiedIndicator :changed="isChanged" />
+    <ModifiedIndicator
+      :changed="isChanged"
+      :onclick="reset"
+    />
     <ServerSideIndicator :server-side="isServerSide" />
   </label>
 </template>

Some files were not shown because too many files changed in this diff