소스 검색

Merge branch 'vue3-again' into 'develop'

Migration to Vue 3 (again)

See merge request pleroma/pleroma-fe!1385
HJ 2 년 전
부모
커밋
f71f101fce
100개의 변경된 파일682개의 추가작업 그리고 550개의 파일을 삭제
  1. 2 2
      .babelrc
  2. 1 1
      .gitlab-ci.yml
  3. 14 3
      build/webpack.base.conf.js
  4. 3 1
      build/webpack.dev.conf.js
  5. 3 1
      build/webpack.prod.conf.js
  6. 16 14
      package.json
  7. 1 1
      src/App.js
  8. 1 1
      src/App.scss
  9. 2 2
      src/App.vue
  10. 27 14
      src/boot/after_store.js
  11. 2 2
      src/boot/routes.js
  12. 1 0
      src/components/async_component_error/async_component_error.vue
  13. 3 2
      src/components/auth_form/auth_form.js
  14. 1 1
      src/components/basic_user_card/basic_user_card.vue
  15. 1 1
      src/components/bookmark_timeline/bookmark_timeline.js
  16. 1 1
      src/components/chat/chat.js
  17. 58 60
      src/components/chat/chat.vue
  18. 1 0
      src/components/chat_message/chat_message.js
  19. 5 4
      src/components/chat_title/chat_title.js
  20. 5 8
      src/components/checkbox/checkbox.vue
  21. 12 11
      src/components/color_input/color_input.vue
  22. 28 20
      src/components/conversation/conversation.vue
  23. 1 1
      src/components/desktop_nav/desktop_nav.vue
  24. 17 14
      src/components/emoji_input/emoji_input.js
  25. 5 4
      src/components/emoji_picker/emoji_picker.js
  26. 2 12
      src/components/exporter/exporter.js
  27. 2 2
      src/components/exporter/exporter.vue
  28. 6 5
      src/components/font_control/font_control.js
  29. 2 1
      src/components/font_control/font_control.vue
  30. 2 2
      src/components/gallery/gallery.js
  31. 0 1
      src/components/gallery/gallery.vue
  32. 2 2
      src/components/image_cropper/image_cropper.js
  33. 3 18
      src/components/importer/importer.js
  34. 19 9
      src/components/importer/importer.vue
  35. 3 1
      src/components/interactions/interactions.js
  36. 1 0
      src/components/interface_language_switcher/interface_language_switcher.vue
  37. 8 4
      src/components/login_form/login_form.vue
  38. 1 1
      src/components/media_modal/media_modal.js
  39. 4 2
      src/components/mention_link/mention_link.vue
  40. 0 2
      src/components/mentions_line/mentions_line.vue
  41. 8 4
      src/components/mfa_form/recovery_form.vue
  42. 9 5
      src/components/mfa_form/totp_form.vue
  43. 1 1
      src/components/mobile_post_status_button/mobile_post_status_button.js
  44. 29 28
      src/components/mobile_post_status_button/mobile_post_status_button.vue
  45. 2 2
      src/components/moderation_tools/moderation_tools.vue
  46. 31 12
      src/components/notification/notification.vue
  47. 0 4
      src/components/notifications/notifications.scss
  48. 7 6
      src/components/opacity_input/opacity_input.vue
  49. 1 1
      src/components/poll/poll.js
  50. 12 7
      src/components/poll/poll.vue
  51. 1 0
      src/components/poll/poll_form.vue
  52. 1 1
      src/components/popover/popover.js
  53. 6 0
      src/components/post_status_form/post_status_form.js
  54. 12 7
      src/components/post_status_form/post_status_form.vue
  55. 1 1
      src/components/public_and_external_timeline/public_and_external_timeline.js
  56. 1 1
      src/components/public_timeline/public_timeline.js
  57. 8 7
      src/components/range_input/range_input.vue
  58. 6 6
      src/components/registration/registration.js
  59. 19 19
      src/components/registration/registration.vue
  60. 8 9
      src/components/rich_content/rich_content.jsx
  61. 3 0
      src/components/scope_selector/scope_selector.vue
  62. 3 1
      src/components/search/search.js
  63. 2 5
      src/components/select/select.js
  64. 5 4
      src/components/select/select.vue
  65. 4 4
      src/components/selectable_list/selectable_list.vue
  66. 3 2
      src/components/settings_modal/helpers/boolean_setting.vue
  67. 3 2
      src/components/settings_modal/helpers/choice_setting.vue
  68. 1 1
      src/components/settings_modal/helpers/integer_setting.js
  69. 1 0
      src/components/settings_modal/helpers/integer_setting.vue
  70. 2 2
      src/components/settings_modal/settings_modal.js
  71. 9 5
      src/components/settings_modal/settings_modal.vue
  72. 6 3
      src/components/settings_modal/settings_modal_content.js
  73. 1 0
      src/components/settings_modal/settings_modal_content.vue
  74. 1 13
      src/components/settings_modal/tabs/filtering_tab.vue
  75. 1 1
      src/components/settings_modal/tabs/mutes_and_blocks_tab.js
  76. 5 1
      src/components/settings_modal/tabs/profile_tab.scss
  77. 26 16
      src/components/settings_modal/tabs/profile_tab.vue
  78. 6 5
      src/components/settings_modal/tabs/theme_tab/preview.vue
  79. 7 8
      src/components/settings_modal/tabs/theme_tab/theme_tab.js
  80. 17 12
      src/components/settings_modal/tabs/theme_tab/theme_tab.vue
  81. 1 1
      src/components/settings_modal/tabs/version_tab.vue
  82. 6 5
      src/components/shadow_control/shadow_control.js
  83. 4 3
      src/components/shadow_control/shadow_control.vue
  84. 5 7
      src/components/status/status.js
  85. 8 6
      src/components/status/status.vue
  86. 4 2
      src/components/status_content/status_content.js
  87. 3 2
      src/components/status_popover/status_popover.js
  88. 1 1
      src/components/sticker_picker/sticker_picker.js
  89. 47 26
      src/components/tab_switcher/tab_switcher.jsx
  90. 0 7
      src/components/tab_switcher/tab_switcher.scss
  91. 1 1
      src/components/tag_timeline/tag_timeline.js
  92. 28 20
      src/components/thread_tree/thread_tree.vue
  93. 1 1
      src/components/timeago/timeago.vue
  94. 7 1
      src/components/timeline/timeline.js
  95. 20 24
      src/components/timeline/timeline.vue
  96. 1 1
      src/components/user_avatar/user_avatar.vue
  97. 1 0
      src/components/user_card/user_card.vue
  98. 6 2
      src/components/user_list_popover/user_list_popover.js
  99. 1 1
      src/components/user_panel/user_panel.vue
  100. 2 2
      src/components/user_profile/user_profile.js

+ 2 - 2
.babelrc

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

+ 1 - 1
.gitlab-ci.yml

@@ -1,7 +1,7 @@
 # This file is a template, and might need editing before it works on your project.
 # Official framework image. Look for the different tagged releases at:
 # https://hub.docker.com/r/library/node/tags/
-image: node:10
+image: node:12
 
 stages:
   - lint

+ 14 - 3
build/webpack.base.conf.js

@@ -4,6 +4,7 @@ var utils = require('./utils')
 var projectRoot = path.resolve(__dirname, '../')
 var ServiceWorkerWebpackPlugin = require('serviceworker-webpack-plugin')
 var CopyPlugin = require('copy-webpack-plugin');
+var { VueLoaderPlugin } = require('vue-loader')
 
 var env = process.env.NODE_ENV
 // check env & config/index.js to decide weither to enable CSS Sourcemaps for the
@@ -29,12 +30,11 @@ module.exports = {
     }
   },
   resolve: {
-    extensions: ['.js', '.vue'],
+    extensions: ['.js', '.jsx', '.vue'],
     modules: [
       path.join(__dirname, '../node_modules')
     ],
     alias: {
-      'vue$': 'vue/dist/vue.runtime.common',
       'static': path.resolve(__dirname, '../static'),
       'src': path.resolve(__dirname, '../src'),
       'assets': path.resolve(__dirname, '../src/assets'),
@@ -60,7 +60,17 @@ module.exports = {
       },
       {
         test: /\.vue$/,
-        use: 'vue-loader'
+        loader: 'vue-loader',
+        options: {
+          compilerOptions: {
+            isCustomElement(tag) {
+              if (tag === 'pinch-zoom') {
+                return true
+              }
+              return false
+            }
+          }
+        }
       },
       {
         test: /\.jsx?$/,
@@ -95,6 +105,7 @@ module.exports = {
       entry: path.join(__dirname, '..', 'src/sw.js'),
       filename: 'sw-pleroma.js'
     }),
+    new VueLoaderPlugin(),
     // This copies Ruffle's WASM to a directory so that JS side can access it
     new CopyPlugin({
       patterns: [

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

@@ -21,7 +21,9 @@ module.exports = merge(baseWebpackConfig, {
     new webpack.DefinePlugin({
       'process.env': config.dev.env,
       'COMMIT_HASH': JSON.stringify('DEV'),
-      'DEV_OVERRIDES': JSON.stringify(config.dev.settings)
+      'DEV_OVERRIDES': JSON.stringify(config.dev.settings),
+      '__VUE_OPTIONS_API__': true,
+      '__VUE_PROD_DEVTOOLS__': false
     }),
     // https://github.com/glenjamin/webpack-hot-middleware#installation--usage
     new webpack.HotModuleReplacementPlugin(),

+ 3 - 1
build/webpack.prod.conf.js

@@ -36,7 +36,9 @@ var webpackConfig = merge(baseWebpackConfig, {
     new webpack.DefinePlugin({
       'process.env': env,
       'COMMIT_HASH': JSON.stringify(commitHash),
-      'DEV_OVERRIDES': JSON.stringify(undefined)
+      'DEV_OVERRIDES': JSON.stringify(undefined),
+      '__VUE_OPTIONS_API__': true,
+      '__VUE_PROD_DEVTOOLS__': false
     }),
     // extract css into its own file
     new MiniCssExtractPlugin({

+ 16 - 14
package.json

@@ -17,30 +17,31 @@
   },
   "dependencies": {
     "@babel/runtime": "7.17.8",
-    "@chenfengyuan/vue-qrcode": "1.0.2",
+    "@chenfengyuan/vue-qrcode": "2.0.0",
     "@fortawesome/fontawesome-svg-core": "1.3.0",
     "@fortawesome/free-regular-svg-icons": "5.15.4",
     "@fortawesome/free-solid-svg-icons": "5.15.4",
-    "@fortawesome/vue-fontawesome": "2.0.6",
+    "@fortawesome/vue-fontawesome": "3.0.0-5",
     "@kazvmoe-infra/pinch-zoom-element": "1.2.0",
+    "@vuelidate/core": "2.0.0-alpha.35",
+    "@vuelidate/validators": "2.0.0-alpha.27",
     "body-scroll-lock": "2.7.1",
     "chromatism": "3.0.0",
+    "click-outside-vue3": "4.0.1",
     "cropperjs": "1.5.12",
     "diff": "3.5.0",
     "escape-html": "1.0.3",
     "localforage": "1.10.0",
     "parse-link-header": "1.0.1",
     "phoenix": "1.4.0",
-    "portal-vue": "2.1.7",
     "punycode.js": "2.1.0",
+    "qrcode": "1",
     "ruffle-mirror": "2021.12.31",
-    "v-click-outside": "2.1.5",
-    "vue": "2.6.11",
-    "vue-i18n": "7.8.1",
-    "vue-router": "3.0.2",
+    "vue": "^3.2.31",
+    "vue-i18n": "9.1.9",
+    "vue-router": "4.0.14",
     "vue-template-compiler": "2.6.11",
-    "vuelidate": "0.7.7",
-    "vuex": "3.0.1"
+    "vuex": "4.0.2"
   },
   "devDependencies": {
     "@babel/core": "7.17.8",
@@ -49,8 +50,9 @@
     "@babel/register": "7.17.7",
     "@ungap/event-target": "0.2.3",
     "@vue/babel-helper-vue-jsx-merge-props": "1.2.1",
-    "@vue/babel-preset-jsx": "1.2.4",
-    "@vue/test-utils": "1.0.0-beta.28",
+    "@vue/babel-plugin-jsx": "1.1.1",
+    "@vue/compiler-sfc": "^3.1.0",
+    "@vue/test-utils": "2.0.0-rc.17",
     "autoprefixer": "6.7.7",
     "babel-eslint": "7.2.3",
     "babel-loader": "8.2.4",
@@ -82,10 +84,10 @@
     "iso-639-1": "2.1.13",
     "isparta-loader": "2.0.0",
     "json-loader": "0.5.7",
-    "karma": "3.1.4",
+    "karma": "6.3.17",
     "karma-coverage": "1.1.2",
     "karma-firefox-launcher": "1.3.0",
-    "karma-mocha": "1.3.0",
+    "karma-mocha": "2.0.1",
     "karma-mocha-reporter": "2.2.5",
     "karma-sinon-chai": "2.0.2",
     "karma-sourcemap-loader": "0.3.8",
@@ -112,7 +114,7 @@
     "stylelint-config-standard": "20.0.0",
     "stylelint-rscss": "0.4.0",
     "url-loader": "1.1.2",
-    "vue-loader": "14.2.4",
+    "vue-loader": "^16.0.0",
     "vue-style-loader": "4.1.2",
     "webpack": "4.46.0",
     "webpack-dev-middleware": "3.7.3",

+ 1 - 1
src/App.js

@@ -46,7 +46,7 @@ export default {
     this.$store.dispatch('setOption', { name: 'interfaceLanguage', value: val })
     window.addEventListener('resize', this.updateMobileState)
   },
-  destroyed () {
+  unmounted () {
     window.removeEventListener('resize', this.updateMobileState)
   },
   computed: {

+ 1 - 1
src/App.scss

@@ -572,7 +572,7 @@ nav {
 .fade-enter-active, .fade-leave-active {
   transition: opacity .2s
 }
-.fade-enter, .fade-leave-active {
+.fade-enter-from, .fade-leave-active {
   opacity: 0
 }
 

+ 2 - 2
src/App.vue

@@ -1,6 +1,6 @@
 <template>
   <div
-    id="app"
+    id="app-loaded"
     :style="bgStyle"
   >
     <div
@@ -59,7 +59,7 @@
     <UserReportingModal />
     <PostStatusModal />
     <SettingsModal />
-    <portal-target name="modal" />
+    <div id="modal" />
     <GlobalNoticeList />
   </div>
 </template>

+ 27 - 14
src/boot/after_store.js

@@ -1,7 +1,13 @@
-import Vue from 'vue'
-import VueRouter from 'vue-router'
-import routes from './routes'
+import { createApp } from 'vue'
+import { createRouter, createWebHistory } from 'vue-router'
+import vClickOutside from 'click-outside-vue3'
+
+import { FontAwesomeIcon, FontAwesomeLayers } from '@fortawesome/vue-fontawesome'
+
 import App from '../App.vue'
+import routes from './routes'
+import VBodyScrollLock from 'src/directives/body_scroll_lock'
+
 import { windowWidth } 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'
@@ -367,25 +373,32 @@ const afterStoreSetup = async ({ store, i18n }) => {
   getTOS({ store })
   getStickers({ store })
 
-  const router = new VueRouter({
-    mode: 'history',
+  const router = createRouter({
+    history: createWebHistory(),
     routes: routes(store),
     scrollBehavior: (to, _from, savedPosition) => {
       if (to.matched.some(m => m.meta.dontScroll)) {
         return false
       }
-      return savedPosition || { x: 0, y: 0 }
+      return savedPosition || { left: 0, top: 0 }
     }
   })
 
-  /* eslint-disable no-new */
-  return new Vue({
-    router,
-    store,
-    i18n,
-    el: '#app',
-    render: h => h(App)
-  })
+  const app = createApp(App)
+
+  app.use(router)
+  app.use(store)
+  app.use(i18n)
+
+  app.use(vClickOutside)
+  app.use(VBodyScrollLock)
+
+  app.component('FAIcon', FontAwesomeIcon)
+  app.component('FALayers', FontAwesomeLayers)
+
+  app.mount('#app')
+
+  return app
 }
 
 export default afterStoreSetup

+ 2 - 2
src/boot/routes.js

@@ -46,7 +46,7 @@ export default (store) => {
     { name: 'bookmarks', path: '/bookmarks', component: BookmarkTimeline },
     { name: 'conversation', path: '/notice/:id', component: ConversationPage, meta: { dontScroll: true } },
     { name: 'remote-user-profile-acct',
-      path: '/remote-users/(@?):username([^/@]+)@:hostname([^/@]+)',
+      path: '/remote-users/:_(@)?:username([^/@]+)@:hostname([^/@]+)',
       component: RemoteUserResolver,
       beforeEnter: validateAuthenticatedRoute
     },
@@ -69,7 +69,7 @@ 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 }
   ]
 
   if (store.state.instance.pleromaChatMessagesAvailable) {

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

@@ -19,6 +19,7 @@
 
 <script>
 export default {
+  emits: ['resetAsyncComponent'],
   methods: {
     retry () {
       this.$emit('resetAsyncComponent')

+ 3 - 2
src/components/auth_form/auth_form.js

@@ -1,3 +1,4 @@
+import { h, resolveComponent } from 'vue'
 import LoginForm from '../login_form/login_form.vue'
 import MFARecoveryForm from '../mfa_form/recovery_form.vue'
 import MFATOTPForm from '../mfa_form/totp_form.vue'
@@ -5,8 +6,8 @@ import { mapGetters } from 'vuex'
 
 const AuthForm = {
   name: 'AuthForm',
-  render (createElement) {
-    return createElement('component', { is: this.authForm })
+  render () {
+    return h(resolveComponent(this.authForm))
   },
   computed: {
     authForm () {

+ 1 - 1
src/components/basic_user_card/basic_user_card.vue

@@ -4,7 +4,7 @@
       <UserAvatar
         class="avatar"
         :user="user"
-        @click.prevent.native="toggleUserExpanded"
+        @click.prevent="toggleUserExpanded"
       />
     </router-link>
     <div

+ 1 - 1
src/components/bookmark_timeline/bookmark_timeline.js

@@ -9,7 +9,7 @@ const Bookmarks = {
   components: {
     Timeline
   },
-  destroyed () {
+  unmounted () {
     this.$store.commit('clearTimeline', { timeline: 'bookmarks' })
   }
 }

+ 1 - 1
src/components/chat/chat.js

@@ -57,7 +57,7 @@ const Chat = {
     })
     this.setChatLayout()
   },
-  destroyed () {
+  unmounted () {
     window.removeEventListener('scroll', this.handleScroll)
     window.removeEventListener('resize', this.handleLayoutChange)
     this.unsetChatLayout()

+ 58 - 60
src/components/chat/chat.vue

@@ -26,73 +26,71 @@
             />
           </div>
         </div>
-        <template>
+        <div
+          ref="scrollable"
+          class="scrollable-message-list"
+          :style="{ height: scrollableContainerHeight }"
+          @scroll="handleScroll"
+        >
+          <template v-if="!errorLoadingChat">
+            <ChatMessage
+              v-for="chatViewItem in chatViewItems"
+              :key="chatViewItem.id"
+              :author="recipient"
+              :chat-view-item="chatViewItem"
+              :hovered-message-chain="chatViewItem.messageChainId === hoveredMessageChainId"
+              @hover="onMessageHover"
+            />
+          </template>
           <div
-            ref="scrollable"
-            class="scrollable-message-list"
-            :style="{ height: scrollableContainerHeight }"
-            @scroll="handleScroll"
+            v-else
+            class="chat-loading-error"
           >
-            <template v-if="!errorLoadingChat">
-              <ChatMessage
-                v-for="chatViewItem in chatViewItems"
-                :key="chatViewItem.id"
-                :author="recipient"
-                :chat-view-item="chatViewItem"
-                :hovered-message-chain="chatViewItem.messageChainId === hoveredMessageChainId"
-                @hover="onMessageHover"
-              />
-            </template>
-            <div
-              v-else
-              class="chat-loading-error"
-            >
-              <div class="alert error">
-                {{ $t('chats.error_loading_chat') }}
-              </div>
+            <div class="alert error">
+              {{ $t('chats.error_loading_chat') }}
             </div>
           </div>
+        </div>
+        <div
+          ref="footer"
+          class="panel-body footer"
+        >
           <div
-            ref="footer"
-            class="panel-body footer"
+            class="jump-to-bottom-button"
+            :class="{ 'visible': jumpToBottomButtonVisible }"
+            @click="scrollDown({ behavior: 'smooth' })"
           >
-            <div
-              class="jump-to-bottom-button"
-              :class="{ 'visible': jumpToBottomButtonVisible }"
-              @click="scrollDown({ behavior: 'smooth' })"
-            >
-              <span>
-                <FAIcon icon="chevron-down" />
-                <div
-                  v-if="newMessageCount"
-                  class="badge badge-notification unread-chat-count unread-message-count"
-                >
-                  {{ newMessageCount }}
-                </div>
-              </span>
-            </div>
-            <PostStatusForm
-              :disable-subject="true"
-              :disable-scope-selector="true"
-              :disable-notice="true"
-              :disable-lock-warning="true"
-              :disable-polls="true"
-              :disable-sensitivity-checkbox="true"
-              :disable-submit="errorLoadingChat || !currentChat"
-              :disable-preview="true"
-              :optimistic-posting="true"
-              :post-handler="sendMessage"
-              :submit-on-enter="!mobileLayout"
-              :preserve-focus="!mobileLayout"
-              :auto-focus="!mobileLayout"
-              :placeholder="formPlaceholder"
-              :file-limit="1"
-              max-height="160"
-              emoji-picker-placement="top"
-              @resize="handleResize"
-            />
+            <span>
+              <FAIcon icon="chevron-down" />
+              <div
+                v-if="newMessageCount"
+                class="badge badge-notification unread-chat-count unread-message-count"
+              >
+                {{ newMessageCount }}
+              </div>
+            </span>
           </div>
-        </template>
+          <PostStatusForm
+            :disable-subject="true"
+            :disable-scope-selector="true"
+            :disable-notice="true"
+            :disable-lock-warning="true"
+            :disable-polls="true"
+            :disable-sensitivity-checkbox="true"
+            :disable-submit="errorLoadingChat || !currentChat"
+            :disable-preview="true"
+            :optimistic-posting="true"
+            :post-handler="sendMessage"
+            :submit-on-enter="!mobileLayout"
+            :preserve-focus="!mobileLayout"
+            :auto-focus="!mobileLayout"
+            :placeholder="formPlaceholder"
+            :file-limit="1"
+            max-height="160"
+            emoji-picker-placement="top"
+            @resize="handleResize"
+          />
+        </div>
       </div>
     </div>
   </div>

+ 1 - 0
src/components/chat_message/chat_message.js

@@ -27,6 +27,7 @@ const ChatMessage = {
     'chatViewItem',
     'hoveredMessageChain'
   ],
+  emits: ['hover'],
   components: {
     Popover,
     Attachment,

+ 5 - 4
src/components/chat_title/chat_title.js

@@ -1,11 +1,12 @@
-import Vue from 'vue'
 import generateProfileLink from 'src/services/user_profile_link_generator/user_profile_link_generator'
 import UserAvatar from '../user_avatar/user_avatar.vue'
+import RichContent from 'src/components/rich_content/rich_content.jsx'
 
-export default Vue.component('chat-title', {
+export default {
   name: 'ChatTitle',
   components: {
-    UserAvatar
+    UserAvatar,
+    RichContent
   },
   props: [
     'user', 'withAvatar'
@@ -23,4 +24,4 @@ export default Vue.component('chat-title', {
       return generateProfileLink(user.id, user.screen_name)
     }
   }
-})
+}

+ 5 - 8
src/components/checkbox/checkbox.vue

@@ -6,9 +6,9 @@
     <input
       type="checkbox"
       :disabled="disabled"
-      :checked="checked"
-      :indeterminate.prop="indeterminate"
-      @change="$emit('change', $event.target.checked)"
+      :checked="modelValue"
+      :indeterminate="indeterminate"
+      @change="$emit('update:modelValue', $event.target.checked)"
     >
     <i class="checkbox-indicator" />
     <span
@@ -22,12 +22,9 @@
 
 <script>
 export default {
-  model: {
-    prop: 'checked',
-    event: 'change'
-  },
+  emits: ['update:modelValue'],
   props: [
-    'checked',
+    'modelValue',
     'indeterminate',
     'disabled'
   ]

+ 12 - 11
src/components/color_input/color_input.vue

@@ -11,28 +11,28 @@
     </label>
     <Checkbox
       v-if="typeof fallback !== 'undefined' && showOptionalTickbox"
-      :checked="present"
+      :model-value="present"
       :disabled="disabled"
       class="opt"
-      @change="$emit('input', typeof value === 'undefined' ? fallback : undefined)"
+      @update:modelValue="$emit('update:modelValue', typeof modelValue === 'undefined' ? fallback : undefined)"
     />
     <div class="input color-input-field">
       <input
         :id="name + '-t'"
         class="textColor unstyled"
         type="text"
-        :value="value || fallback"
+        :value="modelValue || fallback"
         :disabled="!present || disabled"
-        @input="$emit('input', $event.target.value)"
+        @input="$emit('update:modelValue', $event.target.value)"
       >
       <input
         v-if="validColor"
         :id="name"
         class="nativeColor unstyled"
         type="color"
-        :value="value || fallback"
+        :value="modelValue || fallback"
         :disabled="!present || disabled"
-        @input="$emit('input', $event.target.value)"
+        @input="$emit('update:modelValue', $event.target.value)"
       >
       <div
         v-if="transparentColor"
@@ -67,7 +67,7 @@ export default {
     },
     // Color value, should be required but vue cannot tell the difference
     // between "property missing" and "property set to undefined"
-    value: {
+    modelValue: {
       required: false,
       type: String,
       default: undefined
@@ -91,18 +91,19 @@ export default {
       default: true
     }
   },
+  emits: ['update:modelValue'],
   computed: {
     present () {
-      return typeof this.value !== 'undefined'
+      return typeof this.modelValue !== 'undefined'
     },
     validColor () {
-      return hex2rgb(this.value || this.fallback)
+      return hex2rgb(this.modelValue || this.fallback)
     },
     transparentColor () {
-      return this.value === 'transparent'
+      return this.modelValue === 'transparent'
     },
     computedColor () {
-      return this.value && this.value.startsWith('--')
+      return this.modelValue && this.modelValue.startsWith('--')
     }
   }
 }

+ 28 - 20
src/components/conversation/conversation.vue

@@ -27,20 +27,24 @@
           v-if="shouldShowAllConversationButton"
           class="conversation-dive-to-top-level-box"
         >
-          <i18n
-            path="status.show_all_conversation_with_icon"
+          <i18n-t
+            keypath="status.show_all_conversation_with_icon"
             tag="button"
             class="button-unstyled -link"
             @click.prevent="diveToTopLevel"
+            scope="global"
           >
-            <FAIcon
-              place="icon"
-              icon="angle-double-left"
-            />
-            <span place="text">
-              {{ $tc('status.show_all_conversation', otherTopLevelCount, { numStatus: otherTopLevelCount }) }}
-            </span>
-          </i18n>
+            <template #icon>
+              <FAIcon
+                icon="angle-double-left"
+              />
+            </template>
+            <template #text>
+              <span>
+                {{ $tc('status.show_all_conversation', otherTopLevelCount, { numStatus: otherTopLevelCount }) }}
+              </span>
+            </template>
+          </i18n-t>
         </div>
         <div
           v-if="shouldShowAncestors"
@@ -96,20 +100,24 @@
               <div
                 class="thread-ancestor-dive-box-inner"
               >
-                <i18n
+                <i18n-t
                   tag="button"
-                  path="status.ancestor_follow_with_icon"
+                  scope="global"
+                  keypath="status.ancestor_follow_with_icon"
                   class="button-unstyled -link thread-tree-show-replies-button"
                   @click.prevent="diveIntoStatus(status.id)"
                 >
-                  <FAIcon
-                    place="icon"
-                    icon="angle-double-right"
-                  />
-                  <span place="text">
-                    {{ $tc('status.ancestor_follow', getReplies(status.id).length - 1, { numReplies: getReplies(status.id).length - 1 }) }}
-                  </span>
-                </i18n>
+                  <template #icon>
+                    <FAIcon
+                      icon="angle-double-right"
+                    />
+                  </template>
+                  <template #text>
+                    <span>
+                      {{ $tc('status.ancestor_follow', getReplies(status.id).length - 1, { numReplies: getReplies(status.id).length - 1 }) }}
+                    </span>
+                  </template>
+                </i18n-t>
               </div>
             </div>
           </div>

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

@@ -34,7 +34,7 @@
         <search-bar
           v-if="currentUser || !privateMode"
           @toggled="onSearchBarToggled"
-          @click.stop.native
+          @click.stop
         />
         <button
           class="button-unstyled nav-icon"

+ 17 - 14
src/components/emoji_input/emoji_input.js

@@ -31,6 +31,7 @@ library.add(
  */
 
 const EmojiInput = {
+  emits: ['update:modelValue', 'shown'],
   props: {
     suggest: {
       /**
@@ -57,8 +58,7 @@ const EmojiInput = {
       required: true,
       type: Function
     },
-    // TODO VUE3: change to modelValue, change 'input' event to 'input'
-    value: {
+    modelValue: {
       /**
        * Used for v-model
        */
@@ -137,8 +137,8 @@ const EmojiInput = {
       return (this.wordAtCaret || {}).word || ''
     },
     wordAtCaret () {
-      if (this.value && this.caret) {
-        const word = Completion.wordAtPosition(this.value, this.caret - 1) || {}
+      if (this.modelValue && this.caret) {
+        const word = Completion.wordAtPosition(this.modelValue, this.caret - 1) || {}
         return word
       }
     }
@@ -189,8 +189,11 @@ const EmojiInput = {
           img: imageUrl || ''
         }))
     },
-    suggestions (newValue) {
-      this.$nextTick(this.resize)
+    suggestions: {
+      handler (newValue) {
+        this.$nextTick(this.resize)
+      },
+      deep: true
     }
   },
   methods: {
@@ -225,13 +228,13 @@ const EmojiInput = {
       }
     },
     replace (replacement) {
-      const newValue = Completion.replaceWord(this.value, this.wordAtCaret, replacement)
-      this.$emit('input', newValue)
+      const newValue = Completion.replaceWord(this.modelValue, this.wordAtCaret, replacement)
+      this.$emit('update:modelValue', newValue)
       this.caret = 0
     },
     insert ({ insertion, keepOpen, surroundingSpace = true }) {
-      const before = this.value.substring(0, this.caret) || ''
-      const after = this.value.substring(this.caret) || ''
+      const before = this.modelValue.substring(0, this.caret) || ''
+      const after = this.modelValue.substring(this.caret) || ''
 
       /* Using a bit more smart approach to padding emojis with spaces:
        * - put a space before cursor if there isn't one already, unless we
@@ -259,7 +262,7 @@ const EmojiInput = {
         after
       ].join('')
       this.keepOpen = keepOpen
-      this.$emit('input', newValue)
+      this.$emit('update:modelValue', newValue)
       const position = this.caret + (insertion + spaceAfter + spaceBefore).length
       if (!keepOpen) {
         this.input.focus()
@@ -278,8 +281,8 @@ const EmojiInput = {
       if (len > 0 || suggestion) {
         const chosenSuggestion = suggestion || this.suggestions[this.highlighted]
         const replacement = chosenSuggestion.replacement
-        const newValue = Completion.replaceWord(this.value, this.wordAtCaret, replacement)
-        this.$emit('input', newValue)
+        const newValue = Completion.replaceWord(this.modelValue, this.wordAtCaret, replacement)
+        this.$emit('update:modelValue', newValue)
         this.highlighted = 0
         const position = this.wordAtCaret.start + replacement.length
 
@@ -455,7 +458,7 @@ const EmojiInput = {
       this.showPicker = false
       this.setCaret(e)
       this.resize()
-      this.$emit('input', e.target.value)
+      this.$emit('update:modelValue', e.target.value)
     },
     onClickInput (e) {
       this.showPicker = false

+ 5 - 4
src/components/emoji_picker/emoji_picker.js

@@ -1,3 +1,4 @@
+import { defineAsyncComponent } from 'vue'
 import Checkbox from '../checkbox/checkbox.vue'
 import { library } from '@fortawesome/fontawesome-svg-core'
 import {
@@ -57,7 +58,7 @@ const EmojiPicker = {
     }
   },
   components: {
-    StickerPicker: () => import('../sticker_picker/sticker_picker.vue'),
+    StickerPicker: defineAsyncComponent(() => import('../sticker_picker/sticker_picker.vue')),
     Checkbox
   },
   methods: {
@@ -79,7 +80,7 @@ const EmojiPicker = {
     },
     highlight (key) {
       const ref = this.$refs['group-' + key]
-      const top = ref[0].offsetTop
+      const top = ref.offsetTop
       this.setShowStickers(false)
       this.activeGroup = key
       this.$nextTick(() => {
@@ -96,7 +97,7 @@ const EmojiPicker = {
       }
     },
     triggerLoadMore (target) {
-      const ref = this.$refs['group-end-custom'][0]
+      const ref = this.$refs['group-end-custom']
       if (!ref) return
       const bottom = ref.offsetTop + ref.offsetHeight
 
@@ -119,7 +120,7 @@ const EmojiPicker = {
       this.$nextTick(() => {
         this.emojisView.forEach(group => {
           const ref = this.$refs['group-' + group.id]
-          if (ref[0].offsetTop <= top) {
+          if (ref.offsetTop <= top) {
             this.activeGroup = group.id
           }
         })

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

@@ -15,18 +15,8 @@ const Exporter = {
       type: String,
       default: 'export.csv'
     },
-    exportButtonLabel: {
-      type: String,
-      default () {
-        return this.$t('exporter.export')
-      }
-    },
-    processingMessage: {
-      type: String,
-      default () {
-        return this.$t('exporter.processing')
-      }
-    }
+    exportButtonLabel: { type: String },
+    processingMessage: { type: String }
   },
   data () {
     return {

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

@@ -7,14 +7,14 @@
         spin
       />
 
-      <span>{{ processingMessage }}</span>
+      <span>{{ processingMessage || $t('exporter.processing') }}</span>
     </div>
     <button
       v-else
       class="btn button-default"
       @click="process"
     >
-      {{ exportButtonLabel }}
+      {{ exportButtonLabel || $t('exporter.export') }}
     </button>
   </div>
 </template>

+ 6 - 5
src/components/font_control/font_control.js

@@ -1,4 +1,4 @@
-import { set } from 'vue'
+import { set } from 'lodash'
 import Select from '../select/select.vue'
 
 export default {
@@ -6,11 +6,12 @@ export default {
     Select
   },
   props: [
-    'name', 'label', 'value', 'fallback', 'options', 'no-inherit'
+    'name', 'label', 'modelValue', 'fallback', 'options', 'no-inherit'
   ],
+  emits: ['update:modelValue'],
   data () {
     return {
-      lValue: this.value,
+      lValue: this.modelValue,
       availableOptions: [
         this.noInherit ? '' : 'inherit',
         'custom',
@@ -22,7 +23,7 @@ export default {
     }
   },
   beforeUpdate () {
-    this.lValue = this.value
+    this.lValue = this.modelValue
   },
   computed: {
     present () {
@@ -37,7 +38,7 @@ export default {
       },
       set (v) {
         set(this.lValue, 'family', v)
-        this.$emit('input', this.lValue)
+        this.$emit('update:modelValue', this.lValue)
       }
     },
     isCustom () {

+ 2 - 1
src/components/font_control/font_control.vue

@@ -15,13 +15,14 @@
       class="opt exlcude-disabled"
       type="checkbox"
       :checked="present"
-      @input="$emit('input', typeof value === 'undefined' ? fallback : undefined)"
+      @change="$emit('update:modelValue', typeof modelValue === 'undefined' ? fallback : undefined)"
     >
     <label
       v-if="typeof fallback !== 'undefined'"
       class="opt-l"
       :for="name + '-o'"
     />
+    {{ ' ' }}
     <Select
       :id="name + '-font-switcher'"
       v-model="preset"

+ 2 - 2
src/components/gallery/gallery.js

@@ -1,5 +1,5 @@
 import Attachment from '../attachment/attachment.vue'
-import { sumBy } from 'lodash'
+import { sumBy, set } from 'lodash'
 
 const Gallery = {
   props: [
@@ -85,7 +85,7 @@ const Gallery = {
   },
   methods: {
     onNaturalSizeLoad ({ id, width, height }) {
-      this.$set(this.sizes, id, { width, height })
+      set(this.sizes, id, { width, height })
     },
     rowStyle (row) {
       if (row.audio) {

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

@@ -22,7 +22,6 @@
             class="gallery-item"
             :nsfw="nsfw"
             :attachment="attachment"
-            :allow-play="false"
             :size="size"
             :editable="editable"
             :remove="removeAttachment"

+ 2 - 2
src/components/image_cropper/image_cropper.js

@@ -66,7 +66,7 @@ const ImageCropper = {
     }
   },
   methods: {
-    destroy () {
+    unmounted () {
       if (this.cropper) {
         this.cropper.destroy()
       }
@@ -117,7 +117,7 @@ const ImageCropper = {
     const fileInput = this.$refs.input
     fileInput.addEventListener('change', this.readFile)
   },
-  beforeDestroy: function () {
+  beforeUnmount: function () {
     // remove the event listeners
     const trigger = this.getTriggerDOM()
     if (trigger) {

+ 3 - 18
src/components/importer/importer.js

@@ -15,24 +15,9 @@ const Importer = {
       type: Function,
       required: true
     },
-    submitButtonLabel: {
-      type: String,
-      default () {
-        return this.$t('importer.submit')
-      }
-    },
-    successMessage: {
-      type: String,
-      default () {
-        return this.$t('importer.success')
-      }
-    },
-    errorMessage: {
-      type: String,
-      default () {
-        return this.$t('importer.error')
-      }
-    }
+    submitButtonLabel: { type: String },
+    successMessage: { type: String },
+    errorMessage: { type: String }
   },
   data () {
     return {

+ 19 - 9
src/components/importer/importer.vue

@@ -18,21 +18,31 @@
       class="btn button-default"
       @click="submit"
     >
-      {{ submitButtonLabel }}
+      {{ submitButtonLabel || $t('importer.submit') }}
     </button>
     <div v-if="success">
-      <FAIcon
-        icon="times"
+      <button
+        class="button-unstyled"
         @click="dismiss"
-      />
-      <p>{{ successMessage }}</p>
+      >
+        <FAIcon
+          icon="times"
+        />
+      </button>
+      {{ ' ' }}
+      <span>{{ successMessage || $t('importer.success') }}</span>
     </div>
     <div v-else-if="error">
-      <FAIcon
-        icon="times"
+      <button
+        class="button-unstyled"
         @click="dismiss"
-      />
-      <p>{{ errorMessage }}</p>
+      >
+        <FAIcon
+          icon="times"
+        />
+      </button>
+      {{ ' ' }}
+      <span>{{ errorMessage || $t('importer.error') }}</span>
     </div>
   </div>
 </template>

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

@@ -1,4 +1,5 @@
 import Notifications from '../notifications/notifications.vue'
+import TabSwitcher from 'src/components/tab_switcher/tab_switcher.jsx'
 
 const tabModeDict = {
   mentions: ['mention'],
@@ -20,7 +21,8 @@ const Interactions = {
     }
   },
   components: {
-    Notifications
+    Notifications,
+    TabSwitcher
   }
 }
 

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

@@ -3,6 +3,7 @@
     <label for="interface-language-switcher">
       {{ $t('settings.interfaceLanguage') }}
     </label>
+    {{ ' ' }}
     <Select
       id="interface-language-switcher"
       v-model="language"

+ 8 - 4
src/components/login_form/login_form.vue

@@ -76,11 +76,15 @@
     >
       <div class="alert error">
         {{ error }}
-        <FAIcon
-          class="fa-scale-110 fa-old-padding"
-          icon="times"
+        <button
+          class="button-unstyled"
           @click="clearError"
-        />
+        >
+          <FAIcon
+            class="fa-scale-110 fa-old-padding"
+            icon="times"
+          />
+        </button>
       </div>
     </div>
   </div>

+ 1 - 1
src/components/media_modal/media_modal.js

@@ -142,7 +142,7 @@ const MediaModal = {
     document.addEventListener('keyup', this.handleKeyupEvent)
     document.addEventListener('keydown', this.handleKeydownEvent)
   },
-  destroyed () {
+  unmounted () {
     window.removeEventListener('popstate', this.hide)
     document.removeEventListener('keyup', this.handleKeyupEvent)
     document.removeEventListener('keydown', this.handleKeydownEvent)

+ 4 - 2
src/components/mention_link/mention_link.vue

@@ -41,10 +41,12 @@
           class="serverName"
           :class="{ '-faded': shouldFadeDomain }"
           v-html="'@' + serverName"
-        /></span><span
+        />
+        </span>
+        <span
           v-if="isYou && shouldShowYous"
           :class="{ '-you': shouldBoldenYou }"
-        > {{ $t('status.you') }}</span>
+        > {{ ' ' + $t('status.you') }}</span>
         <!-- eslint-enable vue/no-v-html -->
       </a><span
         v-if="shouldShowTooltip"

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

@@ -6,7 +6,6 @@
       class="mention-link"
       :content="mention.content"
       :url="mention.url"
-      :first-mention="false"
     /><span
       v-if="manyMentions"
       class="extraMentions"
@@ -21,7 +20,6 @@
           class="mention-link"
           :content="mention.content"
           :url="mention.url"
-          :first-mention="false"
         />
       </span><button
         v-if="!expanded"

+ 8 - 4
src/components/mfa_form/recovery_form.vue

@@ -56,11 +56,15 @@
     >
       <div class="alert error">
         {{ error }}
-        <FAIcon
-          class="fa-scale-110 fa-old-padding"
-          icon="times"
+        <button
+          class="button-unstyled"
           @click="clearError"
-        />
+        >
+          <FAIcon
+            class="fa-scale-110 fa-old-padding"
+            icon="times"
+          />
+        </button>
       </div>
     </div>
   </div>

+ 9 - 5
src/components/mfa_form/totp_form.vue

@@ -58,12 +58,16 @@
     >
       <div class="alert error">
         {{ error }}
-        <FAIcon
-          size="lg"
-          class="fa-scale-110 fa-old-padding"
-          icon="times"
+        <button
+          class="button-unstyled"
           @click="clearError"
-        />
+        >
+          <FAIcon
+            size="lg"
+            class="fa-scale-110 fa-old-padding"
+            icon="times"
+          />
+        </button>
       </div>
     </div>
   </div>

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

@@ -29,7 +29,7 @@ const MobilePostStatusButton = {
     }
     window.addEventListener('resize', this.handleOSK)
   },
-  destroyed () {
+  unmounted () {
     if (this.autohideFloatingPostButton) {
       this.deactivateFloatingPostButtonAutohide()
     }

+ 29 - 28
src/components/mobile_post_status_button/mobile_post_status_button.vue

@@ -1,13 +1,12 @@
 <template>
-  <div v-if="isLoggedIn">
-    <button
-      class="button-default new-status-button"
-      :class="{ 'hidden': isHidden, 'always-show': isPersistent }"
-      @click="openPostForm"
-    >
-      <FAIcon icon="pen" />
-    </button>
-  </div>
+  <button
+    v-if="isLoggedIn"
+    class="MobilePostButton button-default new-status-button"
+    :class="{ 'hidden': isHidden, 'always-show': isPersistent }"
+    @click="openPostForm"
+  >
+    <FAIcon icon="pen" />
+  </button>
 </template>
 
 <script src="./mobile_post_status_button.js"></script>
@@ -15,25 +14,27 @@
 <style lang="scss">
 @import '../../_variables.scss';
 
-.new-status-button {
-  width: 5em;
-  height: 5em;
-  border-radius: 100%;
-  position: fixed;
-  bottom: 1.5em;
-  right: 1.5em;
-  // TODO: this needs its own color, it has to stand out enough and link color
-  // is not very optimal for this particular use.
-  background-color: $fallback--fg;
-  background-color: var(--btn, $fallback--fg);
-  display: flex;
-  justify-content: center;
-  align-items: center;
-  box-shadow: 0px 2px 2px rgba(0, 0, 0, 0.3), 0px 4px 6px rgba(0, 0, 0, 0.3);
-  z-index: 10;
-
-  transition: 0.35s transform;
-  transition-timing-function: cubic-bezier(0, 1, 0.5, 1);
+.MobilePostButton {
+  &.button-default {
+    width: 5em;
+    height: 5em;
+    border-radius: 100%;
+    position: fixed;
+    bottom: 1.5em;
+    right: 1.5em;
+    // TODO: this needs its own color, it has to stand out enough and link color
+    // is not very optimal for this particular use.
+    background-color: $fallback--fg;
+    background-color: var(--btn, $fallback--fg);
+    display: flex;
+    justify-content: center;
+    align-items: center;
+    box-shadow: 0px 2px 2px rgba(0, 0, 0, 0.3), 0px 4px 6px rgba(0, 0, 0, 0.3);
+    z-index: 10;
+
+    transition: 0.35s transform;
+    transition-timing-function: cubic-bezier(0, 1, 0.5, 1);
+  }
 
   &.hidden {
     transform: translateY(150%);

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

@@ -132,7 +132,7 @@
         </button>
       </template>
     </Popover>
-    <portal to="modal">
+    <teleport to="#modal">
       <DialogModal
         v-if="showDeleteUserDialog"
         :on-cancel="deleteUserDialog.bind(this, false)"
@@ -156,7 +156,7 @@
           </button>
         </template>
       </DialogModal>
-    </portal>
+    </teleport>
   </div>
 </template>
 

+ 31 - 12
src/components/notification/notification.vue

@@ -33,7 +33,7 @@
     >
       <a
         class="avatar-container"
-        :href="notification.from_profile.statusnet_profile_url"
+        :href="$router.resolve(userProfileLink).href"
         @click.stop.prevent.capture="toggleUserExpanded"
       >
         <UserAvatar
@@ -65,12 +65,16 @@
               v-else
               class="username"
               :title="'@'+notification.from_profile.screen_name_ui"
-            >{{ notification.from_profile.name }}</span>
+            >
+              {{ notification.from_profile.name }}
+            </span>
+            {{ ' ' }}
             <span v-if="notification.type === 'like'">
               <FAIcon
                 class="type-icon"
                 icon="star"
               />
+              {{ ' ' }}
               <small>{{ $t('notifications.favorited_you') }}</small>
             </span>
             <span v-if="notification.type === 'repeat'">
@@ -79,6 +83,7 @@
                 icon="retweet"
                 :title="$t('tool_tip.repeat')"
               />
+              {{ ' ' }}
               <small>{{ $t('notifications.repeated_you') }}</small>
             </span>
             <span v-if="notification.type === 'follow'">
@@ -86,6 +91,7 @@
                 class="type-icon"
                 icon="user-plus"
               />
+              {{ ' ' }}
               <small>{{ $t('notifications.followed_you') }}</small>
             </span>
             <span v-if="notification.type === 'follow_request'">
@@ -93,6 +99,7 @@
                 class="type-icon"
                 icon="user"
               />
+              {{ ' ' }}
               <small>{{ $t('notifications.follow_request') }}</small>
             </span>
             <span v-if="notification.type === 'move'">
@@ -100,13 +107,17 @@
                 class="type-icon"
                 icon="suitcase-rolling"
               />
+              {{ ' ' }}
               <small>{{ $t('notifications.migrated_to') }}</small>
             </span>
             <span v-if="notification.type === 'pleroma:emoji_reaction'">
               <small>
-                <i18n path="notifications.reacted_with">
+                <i18n-t
+                  scope="global"
+                  keypath="notifications.reacted_with"
+                >
                   <span class="emoji-reaction-emoji">{{ notification.emoji }}</span>
-                </i18n>
+                </i18n-t>
               </small>
             </span>
           </div>
@@ -161,18 +172,26 @@
             v-if="notification.type === 'follow_request'"
             style="white-space: nowrap;"
           >
-            <FAIcon
-              icon="check"
-              class="fa-scale-110 fa-old-padding follow-request-accept"
+            <button
+              class="button-unstyled"
               :title="$t('tool_tip.accept_follow_request')"
               @click="approveUser()"
-            />
-            <FAIcon
-              icon="times"
-              class="fa-scale-110 fa-old-padding follow-request-reject"
+            >
+              <FAIcon
+                icon="check"
+                class="fa-scale-110 fa-old-padding follow-request-accept"
+              />
+            </button>
+            <button
+              class="button-unstyled"
               :title="$t('tool_tip.reject_follow_request')"
               @click="denyUser()"
-            />
+            >
+              <FAIcon
+                icon="times"
+                class="fa-scale-110 fa-old-padding follow-request-reject"
+              />
+            </button>
           </div>
         </div>
         <div

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

@@ -64,8 +64,6 @@
   }
 
   .follow-request-accept {
-    cursor: pointer;
-
     &:hover {
       color: $fallback--text;
       color: var(--text, $fallback--text);
@@ -73,8 +71,6 @@
   }
 
   .follow-request-reject {
-    cursor: pointer;
-
     &:hover {
       color: $fallback--cRed;
       color: var(--cRed, $fallback--cRed);

+ 7 - 6
src/components/opacity_input/opacity_input.vue

@@ -11,21 +11,21 @@
     </label>
     <Checkbox
       v-if="typeof fallback !== 'undefined'"
-      :checked="present"
+      :model-value="present"
       :disabled="disabled"
       class="opt"
-      @change="$emit('input', !present ? fallback : undefined)"
+      @update:modelValue="$emit('update:modelValue', !present ? fallback : undefined)"
     />
     <input
       :id="name"
       class="input-number"
       type="number"
-      :value="value || fallback"
+      :value="modelValue || fallback"
       :disabled="!present || disabled"
       max="1"
       min="0"
       step=".05"
-      @input="$emit('input', $event.target.value)"
+      @input="$emit('update:modelValue', $event.target.value)"
     >
   </div>
 </template>
@@ -37,11 +37,12 @@ export default {
     Checkbox
   },
   props: [
-    'name', 'value', 'fallback', 'disabled'
+    'name', 'modelValue', 'fallback', 'disabled'
   ],
+  emits: ['update:modelValue'],
   computed: {
     present () {
-      return typeof this.value !== 'undefined'
+      return typeof this.modelValue !== 'undefined'
     }
   }
 }

+ 1 - 1
src/components/poll/poll.js

@@ -21,7 +21,7 @@ export default {
     }
     this.$store.dispatch('trackPoll', this.pollId)
   },
-  destroyed () {
+  unmounted () {
     this.$store.dispatch('untrackPoll', this.pollId)
   },
   computed: {

+ 12 - 7
src/components/poll/poll.vue

@@ -71,13 +71,18 @@
           {{ $tc("polls.votes_count", poll.votes_count, { count: poll.votes_count }) }}&nbsp;·&nbsp;
         </template>
       </div>
-      <i18n :path="expired ? 'polls.expired' : 'polls.expires_in'">
-        <Timeago
-          :time="expiresAt"
-          :auto-update="60"
-          :now-threshold="0"
-        />
-      </i18n>
+      <span>
+        <i18n-t
+          scope="global"
+          :keypath="expired ? 'polls.expired' : 'polls.expires_in'"
+        >
+          <Timeago
+            :time="expiresAt"
+            :auto-update="60"
+            :now-threshold="0"
+          />
+        </i18n-t>
+      </span>
     </div>
   </div>
 </template>

+ 1 - 0
src/components/poll/poll_form.vue

@@ -72,6 +72,7 @@
           :max="maxExpirationInCurrentUnit"
           @change="expiryAmountChange"
         >
+        {{ ' ' }}
         <Select
           v-model="expiryUnit"
           unstyled="true"

+ 1 - 1
src/components/popover/popover.js

@@ -178,7 +178,7 @@ const Popover = {
   created () {
     document.addEventListener('click', this.onClickOutside)
   },
-  destroyed () {
+  unmounted () {
     document.removeEventListener('click', this.onClickOutside)
     this.hidePopover()
   }

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

@@ -78,6 +78,12 @@ const PostStatusForm = {
     'emojiPickerPlacement',
     'optimisticPosting'
   ],
+  emits: [
+    'posted',
+    'resize',
+    'mediaplay',
+    'mediapause'
+  ],
   components: {
     MediaUpload,
     EmojiInput,

+ 12 - 7
src/components/post_status_form/post_status_form.vue

@@ -18,11 +18,12 @@
         <FAIcon :icon="uploadFileLimitReached ? 'ban' : 'upload'" />
       </div>
       <div class="form-group">
-        <i18n
+        <i18n-t
           v-if="!$store.state.users.currentUser.locked && newStatus.visibility == 'private' && !disableLockWarning"
-          path="post_status.account_not_locked_warning"
+          keypath="post_status.account_not_locked_warning"
           tag="p"
           class="visibility-notice"
+          scope="global"
         >
           <button
             class="button-unstyled -link"
@@ -30,7 +31,7 @@
           >
             {{ $t('post_status.account_not_locked_warning_link') }}
           </button>
-        </i18n>
+        </i18n-t>
         <p
           v-if="!hideScopeNotice && newStatus.visibility === 'public'"
           class="visibility-notice notice-dismissible"
@@ -281,11 +282,15 @@
         class="alert error"
       >
         Error: {{ error }}
-        <FAIcon
-          class="fa-scale-110 fa-old-padding"
-          icon="times"
+        <button
+          class="button-unstyled"
           @click="clearError"
-        />
+        >
+          <FAIcon
+            class="fa-scale-110 fa-old-padding"
+            icon="times"
+          />
+        </button>
       </div>
       <gallery
         v-if="newStatus.files && newStatus.files.length > 0"

+ 1 - 1
src/components/public_and_external_timeline/public_and_external_timeline.js

@@ -9,7 +9,7 @@ const PublicAndExternalTimeline = {
   created () {
     this.$store.dispatch('startFetchingTimeline', { timeline: 'publicAndExternal' })
   },
-  destroyed () {
+  unmounted () {
     this.$store.dispatch('stopFetchingTimeline', 'publicAndExternal')
   }
 }

+ 1 - 1
src/components/public_timeline/public_timeline.js

@@ -9,7 +9,7 @@ const PublicTimeline = {
   created () {
     this.$store.dispatch('startFetchingTimeline', { timeline: 'public' })
   },
-  destroyed () {
+  unmounted () {
     this.$store.dispatch('stopFetchingTimeline', 'public')
   }
 

+ 8 - 7
src/components/range_input/range_input.vue

@@ -15,7 +15,7 @@
       class="opt"
       type="checkbox"
       :checked="present"
-      @input="$emit('input', !present ? fallback : undefined)"
+      @change="$emit('update:modelValue', !present ? fallback : undefined)"
     >
     <label
       v-if="typeof fallback !== 'undefined'"
@@ -26,23 +26,23 @@
       :id="name"
       class="input-number"
       type="range"
-      :value="value || fallback"
+      :value="modelValue || fallback"
       :disabled="!present || disabled"
       :max="max || hardMax || 100"
       :min="min || hardMin || 0"
       :step="step || 1"
-      @input="$emit('input', $event.target.value)"
+      @input="$emit('update:modelValue', $event.target.value)"
     >
     <input
       :id="name"
       class="input-number"
       type="number"
-      :value="value || fallback"
+      :value="modelValue || fallback"
       :disabled="!present || disabled"
       :max="hardMax"
       :min="hardMin"
       :step="step || 1"
-      @input="$emit('input', $event.target.value)"
+      @input="$emit('update:modelValue', $event.target.value)"
     >
   </div>
 </template>
@@ -50,11 +50,12 @@
 <script>
 export default {
   props: [
-    'name', 'value', 'fallback', 'disabled', 'label', 'max', 'min', 'step', 'hardMin', 'hardMax'
+    'name', 'modelValue', 'fallback', 'disabled', 'label', 'max', 'min', 'step', 'hardMin', 'hardMax'
   ],
+  emits: ['update:modelValue'],
   computed: {
     present () {
-      return typeof this.value !== 'undefined'
+      return typeof this.modelValue !== 'undefined'
     }
   }
 }

+ 6 - 6
src/components/registration/registration.js

@@ -1,9 +1,9 @@
-import { validationMixin } from 'vuelidate'
-import { required, requiredIf, sameAs } from 'vuelidate/lib/validators'
+import useVuelidate from '@vuelidate/core'
+import { required, requiredIf, sameAs } from '@vuelidate/validators'
 import { mapActions, mapState } from 'vuex'
 
 const registration = {
-  mixins: [validationMixin],
+  setup () { return { v$: useVuelidate() } },
   data: () => ({
     user: {
       email: '',
@@ -24,7 +24,7 @@ const registration = {
         password: { required },
         confirm: {
           required,
-          sameAsPassword: sameAs('password')
+          sameAs: sameAs(this.user.password)
         },
         reason: { required: requiredIf(() => this.accountApprovalRequired) }
       }
@@ -65,9 +65,9 @@ const registration = {
       this.user.captcha_token = this.captcha.token
       this.user.captcha_answer_data = this.captcha.answer_data
 
-      this.$v.$touch()
+      this.v$.$touch()
 
-      if (!this.$v.$invalid) {
+      if (!this.v$.$invalid) {
         try {
           await this.signUp(this.user)
           this.$router.push({ name: 'friends' })

+ 19 - 19
src/components/registration/registration.vue

@@ -12,7 +12,7 @@
           <div class="text-fields">
             <div
               class="form-group"
-              :class="{ 'form-group--error': $v.user.username.$error }"
+              :class="{ 'form-group--error': v$.user.username.$error }"
             >
               <label
                 class="form--label"
@@ -20,18 +20,18 @@
               >{{ $t('login.username') }}</label>
               <input
                 id="sign-up-username"
-                v-model.trim="$v.user.username.$model"
+                v-model.trim="v$.user.username.$model"
                 :disabled="isPending"
                 class="form-control"
                 :placeholder="$t('registration.username_placeholder')"
               >
             </div>
             <div
-              v-if="$v.user.username.$dirty"
+              v-if="v$.user.username.$dirty"
               class="form-error"
             >
               <ul>
-                <li v-if="!$v.user.username.required">
+                <li v-if="!v$.user.username.required">
                   <span>{{ $t('registration.validations.username_required') }}</span>
                 </li>
               </ul>
@@ -39,7 +39,7 @@
 
             <div
               class="form-group"
-              :class="{ 'form-group--error': $v.user.fullname.$error }"
+              :class="{ 'form-group--error': v$.user.fullname.$error }"
             >
               <label
                 class="form--label"
@@ -47,18 +47,18 @@
               >{{ $t('registration.fullname') }}</label>
               <input
                 id="sign-up-fullname"
-                v-model.trim="$v.user.fullname.$model"
+                v-model.trim="v$.user.fullname.$model"
                 :disabled="isPending"
                 class="form-control"
                 :placeholder="$t('registration.fullname_placeholder')"
               >
             </div>
             <div
-              v-if="$v.user.fullname.$dirty"
+              v-if="v$.user.fullname.$dirty"
               class="form-error"
             >
               <ul>
-                <li v-if="!$v.user.fullname.required">
+                <li v-if="!v$.user.fullname.required">
                   <span>{{ $t('registration.validations.fullname_required') }}</span>
                 </li>
               </ul>
@@ -66,7 +66,7 @@
 
             <div
               class="form-group"
-              :class="{ 'form-group--error': $v.user.email.$error }"
+              :class="{ 'form-group--error': v$.user.email.$error }"
             >
               <label
                 class="form--label"
@@ -74,18 +74,18 @@
               >{{ $t('registration.email') }}</label>
               <input
                 id="email"
-                v-model="$v.user.email.$model"
+                v-model="v$.user.email.$model"
                 :disabled="isPending"
                 class="form-control"
                 type="email"
               >
             </div>
             <div
-              v-if="$v.user.email.$dirty"
+              v-if="v$.user.email.$dirty"
               class="form-error"
             >
               <ul>
-                <li v-if="!$v.user.email.required">
+                <li v-if="!v$.user.email.required">
                   <span>{{ $t('registration.validations.email_required') }}</span>
                 </li>
               </ul>
@@ -107,7 +107,7 @@
 
             <div
               class="form-group"
-              :class="{ 'form-group--error': $v.user.password.$error }"
+              :class="{ 'form-group--error': v$.user.password.$error }"
             >
               <label
                 class="form--label"
@@ -122,11 +122,11 @@
               >
             </div>
             <div
-              v-if="$v.user.password.$dirty"
+              v-if="v$.user.password.$dirty"
               class="form-error"
             >
               <ul>
-                <li v-if="!$v.user.password.required">
+                <li v-if="!v$.user.password.required">
                   <span>{{ $t('registration.validations.password_required') }}</span>
                 </li>
               </ul>
@@ -134,7 +134,7 @@
 
             <div
               class="form-group"
-              :class="{ 'form-group--error': $v.user.confirm.$error }"
+              :class="{ 'form-group--error': v$.user.confirm.$error }"
             >
               <label
                 class="form--label"
@@ -149,14 +149,14 @@
               >
             </div>
             <div
-              v-if="$v.user.confirm.$dirty"
+              v-if="v$.user.confirm.$dirty"
               class="form-error"
             >
               <ul>
-                <li v-if="!$v.user.confirm.required">
+                <li v-if="!v$.user.confirm.required">
                   <span>{{ $t('registration.validations.password_confirmation_required') }}</span>
                 </li>
-                <li v-if="!$v.user.confirm.sameAsPassword">
+                <li v-if="!v$.user.confirm.sameAsPassword">
                   <span>{{ $t('registration.validations.password_confirmation_match') }}</span>
                 </li>
               </ul>

+ 8 - 9
src/components/rich_content/rich_content.jsx

@@ -1,4 +1,3 @@
-import Vue from 'vue'
 import { unescape, flattenDeep } from 'lodash'
 import { getTagName, processTextForEmoji, getAttrs } from 'src/services/html_converter/utility.service.js'
 import { convertHtmlToTree } from 'src/services/html_converter/html_tree_converter.service.js'
@@ -27,7 +26,7 @@ import './rich_content.scss'
  *
  * Apart from that one small hiccup with emit in render this _should_ be vue3-ready
  */
-export default Vue.component('RichContent', {
+export default {
   name: 'RichContent',
   props: {
     // Original html content
@@ -58,7 +57,7 @@ export default Vue.component('RichContent', {
     }
   },
   // NEVER EVER TOUCH DATA INSIDE RENDER
-  render (h) {
+  render () {
     // Pre-process HTML
     const { newHtml: html } = preProcessPerLine(this.html, this.greentext)
     let currentMentions = null // Current chain of mentions, we group all mentions together
@@ -76,18 +75,18 @@ export default Vue.component('RichContent', {
 
     const renderImage = (tag) => {
       return <StillImage
-        {...{ attrs: getAttrs(tag) }}
+        {...getAttrs(tag)}
         class="img"
       />
     }
 
     const renderHashtag = (attrs, children, encounteredTextReverse) => {
-      const linkData = getLinkData(attrs, children, tagsIndex++)
+      const { index, ...linkData } = getLinkData(attrs, children, tagsIndex++)
       writtenTags.push(linkData)
       if (!encounteredTextReverse) {
         lastTags.push(linkData)
       }
-      return <HashtagLink {...{ props: linkData }}/>
+      return <HashtagLink { ...linkData }/>
     }
 
     const renderMention = (attrs, children) => {
@@ -222,7 +221,7 @@ export default Vue.component('RichContent', {
               attrs.target = '_blank'
               const newChildren = [...children].reverse().map(processItemReverse).reverse()
 
-              return <a {...{ attrs }}>
+              return <a {...attrs}>
                 { newChildren }
               </a>
             }
@@ -235,7 +234,7 @@ export default Vue.component('RichContent', {
           const newChildren = Array.isArray(children)
             ? [...children].reverse().map(processItemReverse).reverse()
             : children
-          return <Tag {...{ attrs: getAttrs(opener) }}>
+          return <Tag {...getAttrs(opener)}>
             { newChildren }
           </Tag>
         } else {
@@ -266,7 +265,7 @@ export default Vue.component('RichContent', {
 
     return result
   }
-})
+}
 
 const getLinkData = (attrs, children, index) => {
   const stripTags = (item) => {

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

@@ -16,6 +16,7 @@
         class="fa-scale-110 fa-old-padding"
       />
     </button>
+    {{ ' ' }}
     <button
       v-if="showPrivate"
       class="button-unstyled scope"
@@ -29,6 +30,7 @@
         class="fa-scale-110 fa-old-padding"
       />
     </button>
+    {{ ' ' }}
     <button
       v-if="showUnlisted"
       class="button-unstyled scope"
@@ -42,6 +44,7 @@
         class="fa-scale-110 fa-old-padding"
       />
     </button>
+    {{ ' ' }}
     <button
       v-if="showPublic"
       class="button-unstyled scope"

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

@@ -1,6 +1,7 @@
 import FollowCard from '../follow_card/follow_card.vue'
 import Conversation from '../conversation/conversation.vue'
 import Status from '../status/status.vue'
+import TabSwitcher from 'src/components/tab_switcher/tab_switcher.jsx'
 import map from 'lodash/map'
 import { library } from '@fortawesome/fontawesome-svg-core'
 import {
@@ -17,7 +18,8 @@ const Search = {
   components: {
     FollowCard,
     Conversation,
-    Status
+    Status,
+    TabSwitcher
   },
   props: [
     'query'

+ 2 - 5
src/components/select/select.js

@@ -8,12 +8,9 @@ library.add(
 )
 
 export default {
-  model: {
-    prop: 'value',
-    event: 'change'
-  },
+  emits: ['update:modelValue'],
   props: [
-    'value',
+    'modelValue',
     'disabled',
     'unstyled',
     'kind'

+ 5 - 4
src/components/select/select.vue

@@ -1,4 +1,3 @@
-
 <template>
   <label
     class="Select input"
@@ -6,11 +5,12 @@
   >
     <select
       :disabled="disabled"
-      :value="value"
-      @change="$emit('change', $event.target.value)"
+      :value="modelValue"
+      @change="$emit('update:modelValue', $event.target.value)"
     >
       <slot />
     </select>
+    {{ ' ' }}
     <FAIcon
       class="select-down-icon"
       icon="chevron-down"
@@ -23,7 +23,8 @@
 <style lang="scss">
 @import '../../_variables.scss';
 
-.Select {
+/* TODO fix order of styles */
+label.Select {
   padding: 0;
 
   select {

+ 4 - 4
src/components/selectable_list/selectable_list.vue

@@ -6,9 +6,9 @@
     >
       <div class="selectable-list-checkbox-wrapper">
         <Checkbox
-          :checked="allSelected"
+          :model-value="allSelected"
           :indeterminate="someSelected"
-          @change="toggleAll"
+          @update:model-value="toggleAll"
         >
           {{ $t('selectable_list.select_all') }}
         </Checkbox>
@@ -31,8 +31,8 @@
         >
           <div class="selectable-list-checkbox-wrapper">
             <Checkbox
-              :checked="isSelected(item)"
-              @change="checked => toggle(checked, item)"
+              :model-value="isSelected(item)"
+              @update:model-value="checked => toggle(checked, item)"
             />
           </div>
           <slot

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

@@ -4,9 +4,9 @@
     class="BooleanSetting"
   >
     <Checkbox
-      :checked="state"
+      :model-value="state"
       :disabled="disabled"
-      @change="update"
+      @update:modelValue="update"
     >
       <span
         v-if="!!$slots.default"
@@ -14,6 +14,7 @@
       >
         <slot />
       </span>
+      {{ ' ' }}
       <ModifiedIndicator :changed="isChanged" /><ServerSideIndicator :server-side="isServerSide" /> </Checkbox>
   </label>
 </template>

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

@@ -4,10 +4,11 @@
     class="ChoiceSetting"
   >
     <slot />
+    {{ ' ' }}
     <Select
-      :value="state"
+      :model-value="state"
       :disabled="disabled"
-      @change="update"
+      @update:modelValue="update"
     >
       <option
         v-for="option in options"

+ 1 - 1
src/components/settings_modal/helpers/integer_setting.js

@@ -8,7 +8,7 @@ export default {
     path: String,
     disabled: Boolean,
     min: Number,
-    expert: Number
+    expert: [Number, String]
   },
   computed: {
     pathDefault () {

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

@@ -16,6 +16,7 @@
       :value="state"
       @change="update"
     >
+    {{ ' ' }}
     <ModifiedIndicator :changed="isChanged" />
   </span>
 </template>

+ 2 - 2
src/components/settings_modal/settings_modal.js

@@ -56,8 +56,8 @@ const SettingsModal = {
     SettingsModalContent: getResettableAsyncComponent(
       () => import('./settings_modal_content.vue'),
       {
-        loading: PanelLoading,
-        error: AsyncComponentError,
+        loadingComponent: PanelLoading,
+        errorComponent: AsyncComponentError,
         delay: 0
       }
     )

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

@@ -11,7 +11,7 @@
           {{ $t('settings.settings') }}
         </span>
         <transition name="fade">
-          <template v-if="currentSaveStateNotice">
+          <div v-if="currentSaveStateNotice">
             <div
               v-if="currentSaveStateNotice.error"
               class="alert error"
@@ -27,7 +27,7 @@
             >
               {{ $t('settings.saving_ok') }}
             </div>
-          </template>
+          </div>
         </transition>
         <button
           class="btn button-default"
@@ -68,6 +68,7 @@
               :title="$t('general.close')"
             >
               <span>{{ $t("settings.file_export_import.backup_restore") }}</span>
+              {{ ' ' }}
               <FAIcon
                 icon="chevron-down"
               />
@@ -109,12 +110,15 @@
           </template>
         </Popover>
 
-        <Checkbox v-model="expertLevel">
+        <Checkbox
+          :model-value="!!expertLevel"
+          @update:modelValue="expertLevel = Number($event)"
+        >
           {{ $t("settings.expert_mode") }}
         </Checkbox>
-        <portal-target
+        <span
+          id="unscrolled-content"
           class="extra-content"
-          name="unscrolled-content"
         />
       </div>
     </div>

+ 6 - 3
src/components/settings_modal/settings_modal_content.js

@@ -1,4 +1,4 @@
-import TabSwitcher from 'src/components/tab_switcher/tab_switcher.js'
+import TabSwitcher from 'src/components/tab_switcher/tab_switcher.jsx'
 
 import DataImportExportTab from './tabs/data_import_export_tab.vue'
 import MutesAndBlocksTab from './tabs/mutes_and_blocks_tab.vue'
@@ -53,6 +53,9 @@ const SettingsModalContent = {
     },
     open () {
       return this.$store.state.interface.settingsModalState !== 'hidden'
+    },
+    bodyLock () {
+      return this.$store.state.interface.settingsModalState === 'visible'
     }
   },
   methods: {
@@ -60,8 +63,8 @@ const SettingsModalContent = {
       const targetTab = this.$store.state.interface.settingsModalTargetTab
       // We're being told to open in specific tab
       if (targetTab) {
-        const tabIndex = this.$refs.tabSwitcher.$slots.default.findIndex(elm => {
-          return elm.data && elm.data.attrs['data-tab-name'] === targetTab
+        const tabIndex = this.$refs.tabSwitcher.$slots.default().findIndex(elm => {
+          return elm.props && elm.props['data-tab-name'] === targetTab
         })
         if (tabIndex >= 0) {
           this.$refs.tabSwitcher.setTab(tabIndex)

+ 1 - 0
src/components/settings_modal/settings_modal_content.vue

@@ -4,6 +4,7 @@
     class="settings_tab-switcher"
     :side-tab-bar="true"
     :scrollable-tabs="true"
+    :body-scroll-lock="bodyLock"
   >
     <div
       :label="$t('settings.general')"

+ 1 - 13
src/components/settings_modal/tabs/filtering_tab.vue

@@ -72,22 +72,10 @@
           <div>{{ $t('settings.filtering_explanation') }}</div>
         </li>
         <h3>{{ $t('settings.attachments') }}</h3>
-        <li v-if="expertLevel > 0">
-          <label for="maxThumbnails">
-            {{ $t('settings.max_thumbnails') }}
-          </label>
-          <input
-            id="maxThumbnails"
-            path.number="maxThumbnails"
-            class="number-input"
-            type="number"
-            min="0"
-            step="1"
-          >
-        </li>
         <li>
           <IntegerSetting
             path="maxThumbnails"
+            expert="1"
             :min="0"
           >
             {{ $t('settings.max_thumbnails') }}

+ 1 - 1
src/components/settings_modal/tabs/mutes_and_blocks_tab.js

@@ -2,7 +2,7 @@ import get from 'lodash/get'
 import map from 'lodash/map'
 import reject from 'lodash/reject'
 import Autosuggest from 'src/components/autosuggest/autosuggest.vue'
-import TabSwitcher from 'src/components/tab_switcher/tab_switcher.js'
+import TabSwitcher from 'src/components/tab_switcher/tab_switcher.jsx'
 import BlockCard from 'src/components/block_card/block_card.vue'
 import MuteCard from 'src/components/mute_card/mute_card.vue'
 import DomainMuteCard from 'src/components/domain_mute_card/domain_mute_card.vue'

+ 5 - 1
src/components/settings_modal/tabs/profile_tab.scss

@@ -54,16 +54,20 @@
     border-radius: var(--tooltipRadius, $fallback--tooltipRadius);
     background-color: rgba(0, 0, 0, 0.6);
     opacity: 0.7;
-    color: white;
     width: 1.5em;
     height: 1.5em;
     text-align: center;
     line-height: 1.5em;
     font-size: 1.5em;
     cursor: pointer;
+
     &:hover {
       opacity: 1;
     }
+
+    svg {
+      color: white;
+    }
   }
 
   .oauth-tokens {

+ 26 - 16
src/components/settings_modal/tabs/profile_tab.vue

@@ -68,8 +68,9 @@
             class="delete-field button-unstyled -hover-highlight"
             @click="deleteField(i)"
           >
+            <!-- TODO something is wrong with v-show here -->
             <FAIcon
-              v-show="newFields.length > 1"
+              v-if="newFields.length > 1"
               icon="times"
             />
           </button>
@@ -106,14 +107,17 @@
           :src="user.profile_image_url_original"
           class="current-avatar"
         >
-        <FAIcon
+        <button
           v-if="!isDefaultAvatar && pickAvatarBtnVisible"
           :title="$t('settings.reset_avatar')"
-          class="reset-button"
-          icon="times"
-          type="button"
           @click="resetAvatar"
-        />
+          class="button-unstyled reset-button"
+        >
+          <FAIcon
+            icon="times"
+            type="button"
+          />
+        </button>
       </div>
       <p>{{ $t('settings.set_new_avatar') }}</p>
       <button
@@ -135,14 +139,17 @@
       <h2>{{ $t('settings.profile_banner') }}</h2>
       <div class="banner-background-preview">
         <img :src="user.cover_photo">
-        <FAIcon
+        <button
           v-if="!isDefaultBanner"
+          class="button-unstyled reset-button"
           :title="$t('settings.reset_profile_banner')"
-          class="reset-button"
-          icon="times"
-          type="button"
           @click="resetBanner"
-        />
+        >
+          <FAIcon
+            icon="times"
+            type="button"
+          />
+        </button>
       </div>
       <p>{{ $t('settings.set_new_profile_banner') }}</p>
       <img
@@ -174,14 +181,17 @@
       <h2>{{ $t('settings.profile_background') }}</h2>
       <div class="banner-background-preview">
         <img :src="user.background_image">
-        <FAIcon
+        <button
           v-if="!isDefaultBackground"
+          class="button-unstyled reset-button"
           :title="$t('settings.reset_profile_background')"
-          class="reset-button"
-          icon="times"
-          type="button"
           @click="resetBackground"
-        />
+        >
+          <FAIcon
+            icon="times"
+            type="button"
+          />
+        </button>
       </div>
       <p>{{ $t('settings.set_new_profile_background') }}</p>
       <img

+ 6 - 5
src/components/settings_modal/tabs/theme_tab/preview.vue

@@ -29,14 +29,14 @@
               {{ $t('settings.style.preview.content') }}
             </h4>
 
-            <i18n path="settings.style.preview.text">
+            <i18n-t scope="global" keypath="settings.style.preview.text">
               <code style="font-family: var(--postCodeFont)">
                 {{ $t('settings.style.preview.mono') }}
               </code>
               <a style="color: var(--link)">
                 {{ $t('settings.style.preview.link') }}
               </a>
-            </i18n>
+            </i18n-t>
 
             <div class="icons">
               <FAIcon
@@ -72,15 +72,16 @@
             :^)
           </div>
           <div class="content">
-            <i18n
-              path="settings.style.preview.fine_print"
+            <i18n-t
+              keypath="settings.style.preview.fine_print"
               tag="span"
               class="faint"
+              scope="global"
             >
               <a style="color: var(--faintLink)">
                 {{ $t('settings.style.preview.faint_link') }}
               </a>
-            </i18n>
+            </i18n-t>
           </div>
         </div>
         <div class="separator" />

+ 7 - 8
src/components/settings_modal/tabs/theme_tab/theme_tab.js

@@ -1,4 +1,3 @@
-import { set, delete as del } from 'vue'
 import {
   rgb2hex,
   hex2rgb,
@@ -34,7 +33,7 @@ import OpacityInput from 'src/components/opacity_input/opacity_input.vue'
 import ShadowControl from 'src/components/shadow_control/shadow_control.vue'
 import FontControl from 'src/components/font_control/font_control.vue'
 import ContrastRatio from 'src/components/contrast_ratio/contrast_ratio.vue'
-import TabSwitcher from 'src/components/tab_switcher/tab_switcher.js'
+import TabSwitcher from 'src/components/tab_switcher/tab_switcher.jsx'
 import Checkbox from 'src/components/checkbox/checkbox.vue'
 import Select from 'src/components/select/select.vue'
 
@@ -320,9 +319,9 @@ export default {
       },
       set (val) {
         if (val) {
-          set(this.shadowsLocal, this.shadowSelected, this.currentShadowFallback.map(_ => Object.assign({}, _)))
+          this.shadowsLocal[this.shadowSelected] = this.currentShadowFallback.map(_ => Object.assign({}, _))
         } else {
-          del(this.shadowsLocal, this.shadowSelected)
+          delete this.shadowsLocal[this.shadowSelected]
         }
       }
     },
@@ -334,7 +333,7 @@ export default {
         return this.shadowsLocal[this.shadowSelected]
       },
       set (v) {
-        set(this.shadowsLocal, this.shadowSelected, v)
+        this.shadowsLocal[this.shadowSelected] = v
       }
     },
     themeValid () {
@@ -561,7 +560,7 @@ export default {
         .filter(_ => _.endsWith('ColorLocal') || _.endsWith('OpacityLocal'))
         .filter(_ => !v1OnlyNames.includes(_))
         .forEach(key => {
-          set(this.$data, key, undefined)
+          this.$data[key] = undefined
         })
     },
 
@@ -569,7 +568,7 @@ export default {
       Object.keys(this.$data)
         .filter(_ => _.endsWith('RadiusLocal'))
         .forEach(key => {
-          set(this.$data, key, undefined)
+          this.$data[key] = undefined
         })
     },
 
@@ -577,7 +576,7 @@ export default {
       Object.keys(this.$data)
         .filter(_ => _.endsWith('OpacityLocal'))
         .forEach(key => {
-          set(this.$data, key, undefined)
+          this.$data[key] = undefined
         })
     },
 

+ 17 - 12
src/components/settings_modal/tabs/theme_tab/theme_tab.vue

@@ -903,6 +903,7 @@
           <div class="tab-header shadow-selector">
             <div class="select-container">
               {{ $t('settings.style.shadows.component') }}
+              {{ ' ' }}
               <Select
                 id="shadow-switcher"
                 v-model="shadowSelected"
@@ -924,6 +925,7 @@
               >
                 {{ $t('settings.style.shadows.override') }}
               </label>
+              {{ ' ' }}
               <input
                 id="override"
                 v-model="currentShadowOverriden"
@@ -949,27 +951,30 @@
             :fallback="currentShadowFallback"
           />
           <div v-if="shadowSelected === 'avatar' || shadowSelected === 'avatarStatus'">
-            <i18n
-              path="settings.style.shadows.filter_hint.always_drop_shadow"
+            <i18n-t
+              scope="global"
+              keypath="settings.style.shadows.filter_hint.always_drop_shadow"
               tag="p"
             >
               <code>filter: drop-shadow()</code>
-            </i18n>
+            </i18n-t>
             <p>{{ $t('settings.style.shadows.filter_hint.avatar_inset') }}</p>
-            <i18n
-              path="settings.style.shadows.filter_hint.drop_shadow_syntax"
+            <i18n-t
+              scope="global"
+              keypath="settings.style.shadows.filter_hint.drop_shadow_syntax"
               tag="p"
             >
               <code>drop-shadow</code>
               <code>spread-radius</code>
               <code>inset</code>
-            </i18n>
-            <i18n
-              path="settings.style.shadows.filter_hint.inset_classic"
+            </i18n-t>
+            <i18n-t
+              scope="global"
+              keypath="settings.style.shadows.filter_hint.inset_classic"
               tag="p"
             >
               <code>box-shadow</code>
-            </i18n>
+            </i18n-t>
             <p>{{ $t('settings.style.shadows.filter_hint.spread_zero') }}</p>
           </div>
         </div>
@@ -1016,9 +1021,9 @@
       </tab-switcher>
     </keep-alive>
 
-    <portal
+    <teleport
       v-if="isActive"
-      to="unscrolled-content"
+      to="#unscrolled-content"
     >
       <div class="apply-container">
         <button
@@ -1035,7 +1040,7 @@
           {{ $t('settings.style.switcher.reset') }}
         </button>
       </div>
-    </portal>
+    </teleport>
   </div>
 </template>
 

+ 1 - 1
src/components/settings_modal/tabs/version_tab.vue

@@ -28,4 +28,4 @@
     </div>
   </div>
 </template>
-<script src="./version_tab.js">
+<script src="./version_tab.js" />

+ 6 - 5
src/components/shadow_control/shadow_control.js

@@ -30,18 +30,19 @@ const toModel = (object = {}) => ({
 })
 
 export default {
-  // 'Value' and 'Fallback' can be undefined, but if they are
+  // 'modelValue' and 'Fallback' can be undefined, but if they are
   // initially vue won't detect it when they become something else
   // therefore i'm using "ready" which should be passed as true when
   // data becomes available
   props: [
-    'value', 'fallback', 'ready'
+    'modelValue', 'fallback', 'ready'
   ],
+  emits: ['update:modelValue'],
   data () {
     return {
       selectedId: 0,
       // TODO there are some bugs regarding display of array (it's not getting updated when deleting for some reason)
-      cValue: (this.value || this.fallback || []).map(toModel)
+      cValue: (this.modelValue || this.fallback || []).map(toModel)
     }
   },
   components: {
@@ -70,7 +71,7 @@ export default {
     }
   },
   beforeUpdate () {
-    this.cValue = this.value || this.fallback
+    this.cValue = this.modelValue || this.fallback
   },
   computed: {
     anyShadows () {
@@ -105,7 +106,7 @@ export default {
         !this.usingFallback
     },
     usingFallback () {
-      return typeof this.value === 'undefined'
+      return typeof this.modelValue === 'undefined'
     },
     rgb () {
       return hex2rgb(this.selected.color)

+ 4 - 3
src/components/shadow_control/shadow_control.vue

@@ -204,12 +204,13 @@
         v-model="selected.alpha"
         :disabled="!present"
       />
-      <i18n
-        path="settings.style.shadows.hintV3"
+      <i18n-t
+        scope="global"
+        keypath="settings.style.shadows.hintV3"
         tag="p"
       >
         <code>--variable,mod</code>
-      </i18n>
+      </i18n-t>
     </div>
   </div>
 </template>

+ 5 - 7
src/components/status/status.js

@@ -69,7 +69,7 @@ const controlledOrUncontrolledGetters = list => list.reduce((res, name) => {
   const controlledName = `controlled${camelized}`
   const uncontrolledName = `uncontrolled${camelized}`
   res[name] = function () {
-    return this[toggle] ? this[controlledName] : this[uncontrolledName]
+    return ((this.$data[toggle] !== undefined || this.$props[toggle] !== undefined) && this[toggle]) ? this[controlledName] : this[uncontrolledName]
   }
   return res
 }, {})
@@ -311,7 +311,7 @@ const Status = {
       return this.mergedConfig.hideWordFilteredPosts
     },
     hideStatus () {
-      return (this.virtualHidden || !this.shouldNotMute) && (
+      return (!this.shouldNotMute) && (
         (this.muted && this.hideFilteredStatuses) ||
         (this.userIsMuted && this.hideMutedUsers) ||
         (this.status.thread_muted && this.hideMutedThreads) ||
@@ -389,6 +389,9 @@ const Status = {
     },
     threadShowing () {
       return this.controlledThreadDisplayStatus === 'showing'
+    },
+    visibilityLocalized () {
+      return this.$i18n.t('general.scope_in_timeline.' + this.status.visibility)
     }
   },
   methods: {
@@ -478,11 +481,6 @@ const Status = {
     'isSuspendable': function (val) {
       this.suspendable = val
     }
-  },
-  filters: {
-    capitalize: function (str) {
-      return str.charAt(0).toUpperCase() + str.slice(1)
-    }
   }
 }
 

+ 8 - 6
src/components/status/status.vue

@@ -1,6 +1,7 @@
 <template>
   <div
     v-if="!hideStatus"
+    ref="root"
     class="Status"
     :class="[{ '-focused': isFocused }, { '-conversation': inlineExpanded }]"
   >
@@ -100,6 +101,7 @@
               :to="retweeterProfileLink"
             >{{ retweeter }}</router-link>
           </span>
+          {{ ' ' }}
           <FAIcon
             icon="retweet"
             class="repeat-icon"
@@ -120,9 +122,9 @@
           v-if="!noHeading"
           class="left-side"
         >
-          <router-link
-            :to="userProfileLink"
-            @click.stop.prevent.capture.native="toggleUserExpanded"
+          <a
+            :href="$router.resolve(userProfileLink).href"
+            @click.stop.prevent.capture="toggleUserExpanded"
           >
             <UserAvatar
               class="post-avatar"
@@ -131,7 +133,7 @@
               :better-shadow="betterShadow"
               :user="status.user"
             />
-          </router-link>
+          </a>
         </div>
         <div class="right-side">
           <UserCard
@@ -191,7 +193,7 @@
                 <span
                   v-if="status.visibility"
                   class="visibility-icon"
-                  :title="status.visibility | capitalize"
+                  :title="visibilityLocalized"
                 >
                   <FAIcon
                     fixed-width
@@ -274,6 +276,7 @@
                       icon="reply"
                       flip="horizontal"
                     />
+                    {{ ' ' }}
                     <span
                       class="reply-to-text"
                     >
@@ -293,7 +296,6 @@
                   :url="replyProfileLink"
                   :user-id="status.in_reply_to_user_id"
                   :user-screen-name="status.in_reply_to_screen_name"
-                  :first-mention="false"
                 />
               </span>
 

+ 4 - 2
src/components/status_content/status_content.js

@@ -31,7 +31,7 @@ const controlledOrUncontrolledGetters = list => list.reduce((res, name) => {
   const controlledName = `controlled${camelized}`
   const uncontrolledName = `uncontrolled${camelized}`
   res[name] = function () {
-    return this[toggle] ? this[controlledName] : this[uncontrolledName]
+    return ((this.$data[toggle] !== undefined || this.$props[toggle] !== undefined) && this[toggle]) ? this[controlledName] : this[uncontrolledName]
   }
   return res
 }, {})
@@ -59,7 +59,9 @@ const StatusContent = {
     'controlledShowingTall',
     'controlledExpandingSubject',
     'controlledToggleShowingTall',
-    'controlledToggleExpandingSubject'
+    'controlledToggleExpandingSubject',
+    'controlledShowingLongSubject',
+    'controlledToggleShowingLongSubject'
   ],
   data () {
     return {

+ 3 - 2
src/components/status_popover/status_popover.js

@@ -1,6 +1,7 @@
 import { find } from 'lodash'
 import { library } from '@fortawesome/fontawesome-svg-core'
 import { faCircleNotch } from '@fortawesome/free-solid-svg-icons'
+import { defineAsyncComponent } from 'vue'
 
 library.add(
   faCircleNotch
@@ -22,8 +23,8 @@ const StatusPopover = {
     }
   },
   components: {
-    Status: () => import('../status/status.vue'),
-    Popover: () => import('../popover/popover.vue')
+    Status: defineAsyncComponent(() => import('../status/status.vue')),
+    Popover: defineAsyncComponent(() => import('../popover/popover.vue'))
   },
   methods: {
     enter () {

+ 1 - 1
src/components/sticker_picker/sticker_picker.js

@@ -1,6 +1,6 @@
 /* eslint-env browser */
 import statusPosterService from '../../services/status_poster/status_poster.service.js'
-import TabSwitcher from '../tab_switcher/tab_switcher.js'
+import TabSwitcher from '../tab_switcher/tab_switcher.jsx'
 
 const StickerPicker = {
   components: {

+ 47 - 26
src/components/tab_switcher/tab_switcher.js → src/components/tab_switcher/tab_switcher.jsx

@@ -1,10 +1,13 @@
-import Vue from 'vue'
+// eslint-disable-next-line no-unused
+import { h, Fragment } from 'vue'
 import { mapState } from 'vuex'
 import { FontAwesomeIcon as FAIcon } from '@fortawesome/vue-fontawesome'
 
 import './tab_switcher.scss'
 
-export default Vue.component('tab-switcher', {
+const findFirstUsable = (slots) => slots.findIndex(_ => _.props)
+
+export default {
   name: 'TabSwitcher',
   props: {
     renderOnlyFocused: {
@@ -31,26 +34,31 @@ export default Vue.component('tab-switcher', {
       required: false,
       type: Boolean,
       default: false
+    },
+    bodyScrollLock: {
+      required: false,
+      type: Boolean,
+      default: false
     }
   },
   data () {
     return {
-      active: this.$slots.default.findIndex(_ => _.tag)
+      active: findFirstUsable(this.slots())
     }
   },
   computed: {
     activeIndex () {
       // In case of controlled component
       if (this.activeTab) {
-        return this.$slots.default.findIndex(slot => this.activeTab === slot.key)
+        return this.slots().findIndex(slot => this.activeTab === slot.props.key)
       } else {
         return this.active
       }
     },
     isActive () {
       return tabName => {
-        const isWanted = slot => slot.data && slot.data.attrs['data-tab-name'] === tabName
-        return this.$slots.default.findIndex(isWanted) === this.activeIndex
+        const isWanted = slot => slot.props && slot.props['data-tab-name'] === tabName
+        return this.$slots.default().findIndex(isWanted) === this.activeIndex
       }
     },
     settingsModalVisible () {
@@ -61,9 +69,9 @@ export default Vue.component('tab-switcher', {
     })
   },
   beforeUpdate () {
-    const currentSlot = this.$slots.default[this.active]
-    if (!currentSlot.tag) {
-      this.active = this.$slots.default.findIndex(_ => _.tag)
+    const currentSlot = this.slots()[this.active]
+    if (!currentSlot.props) {
+      this.active = findFirstUsable(this.slots())
     }
   },
   methods: {
@@ -73,9 +81,16 @@ export default Vue.component('tab-switcher', {
         this.setTab(index)
       }
     },
+    // DO NOT put it to computed, it doesn't work (caching?)
+    slots () {
+      if (this.$slots.default()[0].type === Fragment) {
+        return this.$slots.default()[0].children
+      }
+      return this.$slots.default()
+    },
     setTab (index) {
       if (typeof this.onSwitch === 'function') {
-        this.onSwitch.call(null, this.$slots.default[index].key)
+        this.onSwitch.call(null, this.slots()[index].key)
       }
       this.active = index
       if (this.scrollableTabs) {
@@ -83,27 +98,28 @@ export default Vue.component('tab-switcher', {
       }
     }
   },
-  render (h) {
-    const tabs = this.$slots.default
+  render () {
+    const tabs = this.slots()
       .map((slot, index) => {
-        if (!slot.tag) return
+        const props = slot.props
+        if (!props) return
         const classesTab = ['tab', 'button-default']
         const classesWrapper = ['tab-wrapper']
         if (this.activeIndex === index) {
           classesTab.push('active')
           classesWrapper.push('active')
         }
-        if (slot.data.attrs.image) {
+        if (props.image) {
           return (
             <div class={classesWrapper.join(' ')}>
               <button
-                disabled={slot.data.attrs.disabled}
+                disabled={props.disabled}
                 onClick={this.clickTab(index)}
                 class={classesTab.join(' ')}
                 type="button"
               >
-                <img src={slot.data.attrs.image} title={slot.data.attrs['image-tooltip']}/>
-                {slot.data.attrs.label ? '' : slot.data.attrs.label}
+                <img src={props.image} title={props['image-tooltip']}/>
+                {props.label ? '' : props.label}
               </button>
             </div>
           )
@@ -111,25 +127,26 @@ export default Vue.component('tab-switcher', {
         return (
           <div class={classesWrapper.join(' ')}>
             <button
-              disabled={slot.data.attrs.disabled}
+              disabled={props.disabled}
               onClick={this.clickTab(index)}
               class={classesTab.join(' ')}
               type="button"
             >
-              {!slot.data.attrs.icon ? '' : (<FAIcon class="tab-icon" size="2x" fixed-width icon={slot.data.attrs.icon}/>)}
+              {!props.icon ? '' : (<FAIcon class="tab-icon" size="2x" fixed-width icon={props.icon}/>)}
               <span class="text">
-                {slot.data.attrs.label}
+                {props.label}
               </span>
             </button>
           </div>
         )
       })
 
-    const contents = this.$slots.default.map((slot, index) => {
-      if (!slot.tag) return
+    const contents = this.slots().map((slot, index) => {
+      const props = slot.props
+      if (!props) return
       const active = this.activeIndex === index
       const classes = [ active ? 'active' : 'hidden' ]
-      if (slot.data.attrs.fullHeight) {
+      if (props.fullHeight) {
         classes.push('full-height')
       }
       const renderSlot = (!this.renderOnlyFocused || active)
@@ -140,7 +157,7 @@ export default Vue.component('tab-switcher', {
         <div class={classes}>
           {
             this.sideTabBar
-              ? <h1 class="mobile-label">{slot.data.attrs.label}</h1>
+              ? <h1 class="mobile-label">{props.label}</h1>
               : ''
           }
           {renderSlot}
@@ -153,10 +170,14 @@ export default Vue.component('tab-switcher', {
         <div class="tabs">
           {tabs}
         </div>
-        <div ref="contents" class={'contents' + (this.scrollableTabs ? ' scrollable-tabs' : '')} v-body-scroll-lock={this.settingsModalVisible}>
+        <div
+          ref="contents"
+          class={'contents' + (this.scrollableTabs ? ' scrollable-tabs' : '')}
+          v-body-scroll-lock={this.bodyScrollLock}
+        >
           {contents}
         </div>
       </div>
     )
   }
-})
+}

+ 0 - 7
src/components/tab_switcher/tab_switcher.scss

@@ -166,13 +166,6 @@
     position: relative;
     white-space: nowrap;
     padding: 6px 1em;
-    background-color: $fallback--fg;
-    background-color: var(--tab, $fallback--fg);
-
-    &, &:active .tab-icon {
-      color: $fallback--text;
-      color: var(--tabText, $fallback--text);
-    }
 
     &:not(.active) {
       z-index: 4;

+ 1 - 1
src/components/tag_timeline/tag_timeline.js

@@ -18,7 +18,7 @@ const TagTimeline = {
       this.$store.dispatch('startFetchingTimeline', { timeline: 'tag', tag: this.tag })
     }
   },
-  destroyed () {
+  unmounted () {
     this.$store.dispatch('stopFetchingTimeline', 'tag')
   }
 }

+ 28 - 20
src/components/thread_tree/thread_tree.vue

@@ -74,36 +74,44 @@
       v-if="currentReplies.length && !threadShowing"
       class="thread-tree-replies thread-tree-replies-hidden"
     >
-      <i18n
+      <i18n-t
         v-if="simple"
+        scope="global"
         tag="button"
-        path="status.thread_follow_with_icon"
+        keypath="status.thread_follow_with_icon"
         class="button-unstyled -link thread-tree-show-replies-button"
         @click.prevent="dive(status.id)"
       >
-        <FAIcon
-          place="icon"
-          icon="angle-double-right"
-        />
-        <span place="text">
-          {{ $tc('status.thread_follow', totalReplyCount[status.id], { numStatus: totalReplyCount[status.id] }) }}
-        </span>
-      </i18n>
-      <i18n
+        <template #icon>
+          <FAIcon
+            icon="angle-double-right"
+          />
+        </template>
+        <template #text>
+          <span>
+            {{ $tc('status.thread_follow', totalReplyCount[status.id], { numStatus: totalReplyCount[status.id] }) }}
+          </span>
+        </template>
+      </i18n-t>
+      <i18n-t
         v-else
+        scope="global"
         tag="button"
-        path="status.thread_show_full_with_icon"
+        keypath="status.thread_show_full_with_icon"
         class="button-unstyled -link thread-tree-show-replies-button"
         @click.prevent="showThreadRecursively(status.id)"
       >
-        <FAIcon
-          place="icon"
-          icon="angle-double-down"
-        />
-        <span place="text">
-          {{ $tc('status.thread_show_full', totalReplyCount[status.id], { numStatus: totalReplyCount[status.id], depth: totalReplyDepth[status.id] }) }}
-        </span>
-      </i18n>
+        <template #icon>
+          <FAIcon
+            icon="angle-double-down"
+          />
+        </template>
+        <template #text>
+          <span>
+            {{ $tc('status.thread_show_full', totalReplyCount[status.id], { numStatus: totalReplyCount[status.id], depth: totalReplyDepth[status.id] }) }}
+          </span>
+        </template>
+      </i18n-t>
     </div>
   </div>
 </template>

+ 1 - 1
src/components/timeago/timeago.vue

@@ -31,7 +31,7 @@ export default {
   created () {
     this.refreshRelativeTimeObject()
   },
-  destroyed () {
+  unmounted () {
     clearTimeout(this.interval)
   },
   methods: {

+ 7 - 1
src/components/timeline/timeline.js

@@ -40,6 +40,12 @@ const Timeline = {
     TimelineQuickSettings
   },
   computed: {
+    filteredVisibleStatuses () {
+      return this.timeline.visibleStatuses.filter(status => this.timelineName !== 'user' || (status.id >= this.timeline.minId && status.id <= this.timeline.maxId))
+    },
+    filteredPinnedStatusIds () {
+      return (this.pinnedStatusIds || []).filter(statusId => this.timeline.statusesObject[statusId])
+    },
     newStatusCount () {
       return this.timeline.newStatusCount
     },
@@ -104,7 +110,7 @@ const Timeline = {
     window.addEventListener('keydown', this.handleShortKey)
     setTimeout(this.determineVisibleStatuses, 250)
   },
-  destroyed () {
+  unmounted () {
     window.removeEventListener('scroll', this.handleScroll)
     window.removeEventListener('keydown', this.handleShortKey)
     if (typeof document.hidden !== 'undefined') document.removeEventListener('visibilitychange', this.handleVisibilityChange, false)

+ 20 - 24
src/components/timeline/timeline.vue

@@ -23,30 +23,26 @@
         ref="timeline"
         class="timeline"
       >
-        <template v-for="statusId in pinnedStatusIds">
-          <conversation
-            v-if="timeline.statusesObject[statusId]"
-            :key="statusId + '-pinned'"
-            class="status-fadein"
-            :status-id="statusId"
-            :collapsable="true"
-            :pinned-status-ids-object="pinnedStatusIdsObject"
-            :in-profile="inProfile"
-            :profile-user-id="userId"
-          />
-        </template>
-        <template v-for="status in timeline.visibleStatuses">
-          <conversation
-            v-if="timelineName !== 'user' || (status.id >= timeline.minId && status.id <= timeline.maxId)"
-            :key="status.id"
-            class="status-fadein"
-            :status-id="status.id"
-            :collapsable="true"
-            :in-profile="inProfile"
-            :profile-user-id="userId"
-            :virtual-hidden="virtualScrollingEnabled && !statusesToDisplay.includes(status.id)"
-          />
-        </template>
+        <conversation
+          v-for="statusId in filteredPinnedStatusIds"
+          :key="statusId + '-pinned'"
+          class="status-fadein"
+          :status-id="statusId"
+          :collapsable="true"
+          :pinned-status-ids-object="pinnedStatusIdsObject"
+          :in-profile="inProfile"
+          :profile-user-id="userId"
+        />
+        <conversation
+          v-for="status in filteredVisibleStatuses"
+          :key="status.id"
+          class="status-fadein"
+          :status-id="status.id"
+          :collapsable="true"
+          :in-profile="inProfile"
+          :profile-user-id="userId"
+          :virtual-hidden="virtualScrollingEnabled && !statusesToDisplay.includes(status.id)"
+        />
       </div>
     </div>
     <div :class="classes.footer">

+ 1 - 1
src/components/user_avatar/user_avatar.vue

@@ -2,7 +2,7 @@
   <span
     class="Avatar"
     :class="{ '-compact': compact }"
-    >
+  >
     <StillImage
       v-if="user"
       class="avatar"

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

@@ -141,6 +141,7 @@
               class="userHighlightCl"
               type="color"
             >
+            {{ ' ' }}
             <Select
               :id="'userHighlightSel'+user.id"
               v-model="userHighlightType"

+ 6 - 2
src/components/user_list_popover/user_list_popover.js

@@ -1,3 +1,6 @@
+import { defineAsyncComponent } from 'vue'
+import RichContent from 'src/components/rich_content/rich_content.jsx'
+
 import { library } from '@fortawesome/fontawesome-svg-core'
 import { faCircleNotch } from '@fortawesome/free-solid-svg-icons'
 
@@ -11,8 +14,9 @@ const UserListPopover = {
     'users'
   ],
   components: {
-    Popover: () => import('../popover/popover.vue'),
-    UserAvatar: () => import('../user_avatar/user_avatar.vue')
+    RichContent,
+    Popover: defineAsyncComponent(() => import('../popover/popover.vue')),
+    UserAvatar: defineAsyncComponent(() => import('../user_avatar/user_avatar.vue'))
   },
   computed: {
     usersCapped () {

+ 1 - 1
src/components/user_panel/user_panel.vue

@@ -2,7 +2,7 @@
   <div class="user-panel">
     <div
       v-if="signedIn"
-      key="user-panel"
+      key="user-panel-signed"
       class="panel panel-default signed-in"
     >
       <UserCard

+ 2 - 2
src/components/user_profile/user_profile.js

@@ -3,7 +3,7 @@ import UserCard from '../user_card/user_card.vue'
 import FollowCard from '../follow_card/follow_card.vue'
 import Timeline from '../timeline/timeline.vue'
 import Conversation from '../conversation/conversation.vue'
-import TabSwitcher from 'src/components/tab_switcher/tab_switcher.js'
+import TabSwitcher from 'src/components/tab_switcher/tab_switcher.jsx'
 import RichContent from 'src/components/rich_content/rich_content.jsx'
 import List from '../list/list.vue'
 import withLoadMore from '../../hocs/with_load_more/with_load_more'
@@ -47,7 +47,7 @@ const UserProfile = {
     this.load(routeParams.name || routeParams.id)
     this.tab = get(this.$route, 'query.tab', defaultTabKey)
   },
-  destroyed () {
+  unmounted () {
     this.stopFetching()
   },
   computed: {

이 변경점에서 너무 많은 파일들이 변경되어 몇몇 파일들은 표시되지 않았습니다.