diff --git a/.babelrc b/.babelrc
index 373d2c59..4ec10416 100644
--- a/.babelrc
+++ b/.babelrc
@@ -1,5 +1,5 @@
{
"presets": ["@babel/preset-env"],
"plugins": ["@babel/plugin-transform-runtime", "lodash", "@vue/babel-plugin-jsx"],
- "comments": false
+ "comments": true
}
diff --git a/.drone.yml b/.drone.yml
index 4746f643..53140ab2 100644
--- a/.drone.yml
+++ b/.drone.yml
@@ -28,8 +28,8 @@ steps:
from_secret: SSH_KEY
commands:
- apt update && apt install -y wget dos2unix openssh-client rsync
- - wget https://f.ruina.exposed/add-froth-key.sh && dos2unix ./add-froth-key.sh && chmod +x add-froth-key.sh && bash ./add-froth-key.sh
- - wget https://f.ruina.exposed/pleroma-fe-build-froth.sh && dos2unix ./pleroma-fe-build-froth.sh && chmod +x pleroma-fe-build-froth.sh && bash ./pleroma-fe-build-froth.sh
+ - ./ci/add-key.sh
+ - ./ci/deploy.sh
when:
event:
- push
diff --git a/.eslintrc.js b/.eslintrc.js
index 3c48baa8..361cff5f 100644
--- a/.eslintrc.js
+++ b/.eslintrc.js
@@ -1,7 +1,7 @@
module.exports = {
root: true,
parserOptions: {
- parser: 'babel-eslint',
+ parser: '@babel/eslint-parser',
sourceType: 'module'
},
// https://github.com/feross/standard/blob/master/RULES.md#javascript-standard-style
@@ -21,6 +21,7 @@ module.exports = {
'generator-star-spacing': 0,
// allow debugger during development
'no-debugger': process.env.NODE_ENV === 'production' ? 2 : 0,
- 'vue/require-prop-types': 0
+ 'vue/require-prop-types': 0,
+ 'vue/multi-word-component-names': 0
}
}
diff --git a/.gitignore b/.gitignore
index 479d57c4..a328a1b5 100644
--- a/.gitignore
+++ b/.gitignore
@@ -7,3 +7,6 @@ test/e2e/reports
selenium-debug.log
.idea/
config/local.json
+static/emoji.json
+
+.dccache
\ No newline at end of file
diff --git a/.gitlab-ci.yml b/.gitlab-ci.yml
index ab601173..305155d8 100644
--- a/.gitlab-ci.yml
+++ b/.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:12
+image: node:16
stages:
- lint
diff --git a/.node-version b/.node-version
index b26a34e4..431076a9 100644
--- a/.node-version
+++ b/.node-version
@@ -1 +1 @@
-7.2.1
+16.16.0
diff --git a/CHANGELOG.md b/CHANGELOG.md
index d7cc6994..b7eea727 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -16,17 +16,26 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/).
- Attachments are ALWAYS in same order as user uploaded, no more "videos first"
- Attachment description is prefilled with backend-provided default when uploading
- Proper visual feedback that next image is loading when browsing
+- UI no longer lags when switching between mobile and desktop mode
+- Popovers no longer constrained by DOM hierarchy, shouldn't be cut off by anything
+- "Always show mobile button" is working now
### Changed
+- Using Vue 3 now
- (You)s are optional (opt-in) now, bolding your nickname is also optional (opt-out)
- User highlight background now also covers the `@`
- Reverted back to textual `@`, svg version is opt-in.
-- Settings window has been throughly rearranged to make make more sense and make navication settings easier.
+- Settings window has been thoroughly rearranged to make more sense and make navigation settings easier.
- Uploaded attachments are uniform with displayed attachments
- Flash is watchable in media-modal (takes up nearly full screen though due to sizing issues)
- Notifications about likes/repeats/emoji reacts are now minimized so they always take up same amount of space irrelevant to size of post.
+- Slight width/spacing adjustments
+- More sizing stuff is font-size dependent now
+- Scrollbars are styled/colorized now
+- Scrollbars are toggleable (for stuff that didn't have visible scrollbars before) (opt-in)
### Added
+- 3 column mode: only enables when there's space for it (opt-out, customizable)
- Options to show domains in mentions
- Option to show user avatars in mention links (opt-in)
- Option to disable the tooltip for mentions
@@ -37,6 +46,8 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/).
- Media modal now also displays description and counter position in gallery (i.e. 1/5)
- Ability to rearrange order of attachments when uploading
- Enabled users to zoom and pan images in media viewer with mouse and touch
+- Timelines/panels and conversations have sticky headers now
+- Added frontend ui for account migration
## [2.4.2] - 2022-01-09
diff --git a/CONTRIBUTORS.md b/CONTRIBUTORS.md
index f666a4ef..bfc41ac4 100644
--- a/CONTRIBUTORS.md
+++ b/CONTRIBUTORS.md
@@ -10,3 +10,5 @@ Contributors of this project.
- shpuld (shpuld@shitposter.club): CSS and styling
- Vincent Guth (https://unsplash.com/photos/XrwVIFy6rTw): Background images.
- hj (hj@shigusegubu.club): Code
+- Sean King (seanking@freespeechextremist.com): Code
+- Tusooa Zhu (tusooa@kazv.moe): Code
diff --git a/add-froth-key.sh b/add-froth-key.sh
new file mode 100644
index 00000000..64614f90
--- /dev/null
+++ b/add-froth-key.sh
@@ -0,0 +1,17 @@
+#!/bin/sh
+
+# only execute this script as part of the pipeline.
+[ -z "$CI" ] && echo "missing ci environment variable" && exit 2
+
+# only execute the script when github token exists.
+[ -z "$SSH_KEY" ] && echo "missing ssh key" && exit 3
+
+# write the ssh key.
+mkdir /root/.ssh
+echo -n "${SSH_KEY}" > /root/.ssh/id_ed25519
+chmod 600 /root/.ssh/id_ed25519
+
+# add froth.zone to our known hosts.
+touch /root/.ssh/known_hosts
+chmod 600 /root/.ssh/known_hosts
+ssh-keyscan -H froth.zone > /etc/ssh/ssh_known_hosts 2> /dev/null
diff --git a/build/build.js b/build/build.js
index b3c9aad4..35969eb6 100644
--- a/build/build.js
+++ b/build/build.js
@@ -18,6 +18,9 @@ console.log(
var spinner = ora('building for production...')
spinner.start()
+var updateEmoji = require('./update-emoji').updateEmoji
+updateEmoji()
+
var assetsPath = path.join(config.build.assetsRoot, config.build.assetsSubDirectory)
rm('-rf', assetsPath)
mkdir('-p', assetsPath)
diff --git a/build/dev-server.js b/build/dev-server.js
index 48574214..536265f9 100644
--- a/build/dev-server.js
+++ b/build/dev-server.js
@@ -10,6 +10,9 @@ var webpackConfig = process.env.NODE_ENV === 'testing'
? require('./webpack.prod.conf')
: require('./webpack.dev.conf')
+var updateEmoji = require('./update-emoji').updateEmoji
+updateEmoji()
+
// default port where dev server listens for incoming traffic
var port = process.env.PORT || config.dev.port
// Define HTTP proxies to your custom API backend
@@ -28,18 +31,20 @@ var devMiddleware = require('webpack-dev-middleware')(compiler, {
})
var hotMiddleware = require('webpack-hot-middleware')(compiler)
-// force page reload when html-webpack-plugin template changes
-compiler.plugin('compilation', function (compilation) {
- compilation.plugin('html-webpack-plugin-after-emit', function (data, cb) {
- // FIXME: This supposed to reload whole page when index.html is changed,
- // however now it reloads entire page on every breath, i suppose the order
- // of plugins changed or something. It's a minor thing and douesn't hurt
- // disabling it, constant reloads hurt much more
- // hotMiddleware.publish({ action: 'reload' })
- // cb()
- })
-})
+// FIXME: The statement below gives error about hooks being required in webpack 5.
+// force page reload when html-webpack-plugin template changes
+// compiler.plugin('compilation', function (compilation) {
+// compilation.plugin('html-webpack-plugin-after-emit', function (data, cb) {
+// // FIXME: This supposed to reload whole page when index.html is changed,
+// // however now it reloads entire page on every breath, i suppose the order
+// // of plugins changed or something. It's a minor thing and douesn't hurt
+// // disabling it, constant reloads hurt much more
+
+// // hotMiddleware.publish({ action: 'reload' })
+// // cb()
+// })
+// })
// proxy api requests
Object.keys(proxyTable).forEach(function (context) {
@@ -47,7 +52,7 @@ Object.keys(proxyTable).forEach(function (context) {
if (typeof options === 'string') {
options = { target: options }
}
- app.use(proxyMiddleware(context, options))
+ app.use(proxyMiddleware.createProxyMiddleware(context, options))
})
// handle fallback for HTML5 history API
diff --git a/build/update-emoji.js b/build/update-emoji.js
new file mode 100644
index 00000000..9f4b4e67
--- /dev/null
+++ b/build/update-emoji.js
@@ -0,0 +1,27 @@
+
+module.exports = {
+ updateEmoji () {
+ const emojis = require('@kazvmoe-infra/unicode-emoji-json/data-by-group')
+ const fs = require('fs')
+
+ Object.keys(emojis)
+ .map(k => {
+ emojis[k].map(e => {
+ delete e.unicode_version
+ delete e.emoji_version
+ delete e.skin_tone_support_unicode_version
+ })
+ })
+
+ const res = {}
+ Object.keys(emojis)
+ .map(k => {
+ const groupId = k.replace('&', 'and').replace(/ /g, '-').toLowerCase()
+ res[groupId] = emojis[k]
+ })
+
+ console.info('Updating emojis...')
+ fs.writeFileSync('static/emoji.json', JSON.stringify(res))
+ console.info('Done.')
+ }
+}
diff --git a/build/webpack.base.conf.js b/build/webpack.base.conf.js
index 615464a5..bf946922 100644
--- a/build/webpack.base.conf.js
+++ b/build/webpack.base.conf.js
@@ -2,8 +2,11 @@ var path = require('path')
var config = require('../config')
var utils = require('./utils')
var projectRoot = path.resolve(__dirname, '../')
-var ServiceWorkerWebpackPlugin = require('serviceworker-webpack-plugin')
-var { VueLoaderPlugin } = require("vue-loader");
+var ServiceWorkerWebpackPlugin = require('serviceworker-webpack5-plugin')
+var CopyPlugin = require('copy-webpack-plugin');
+var { VueLoaderPlugin } = require('vue-loader')
+var ESLintPlugin = require('eslint-webpack-plugin');
+
var env = process.env.NODE_ENV
// check env & config/index.js to decide weither to enable CSS Sourcemaps for the
@@ -21,7 +24,8 @@ module.exports = {
output: {
path: config.build.assetsRoot,
publicPath: process.env.NODE_ENV === 'production' ? config.build.assetsPublicPath : config.dev.assetsPublicPath,
- filename: '[name].js'
+ filename: '[name].js',
+ chunkFilename: '[name].js'
},
optimization: {
splitChunks: {
@@ -29,7 +33,7 @@ module.exports = {
}
},
resolve: {
- extensions: ['.js', '.jsx', '.vue'],
+ extensions: ['.mjs', '.js', '.jsx', '.vue'],
modules: [
path.join(__dirname, '../node_modules')
],
@@ -39,25 +43,15 @@ module.exports = {
'assets': path.resolve(__dirname, '../src/assets'),
'components': path.resolve(__dirname, '../src/components'),
'vue-i18n': 'vue-i18n/dist/vue-i18n.runtime.esm-bundler.js'
+ },
+ fallback: {
+ 'querystring': require.resolve('querystring-es3'),
+ 'url': require.resolve('url/')
}
},
module: {
noParse: /node_modules\/localforage\/dist\/localforage.js/,
rules: [
- {
- enforce: 'pre',
- test: /\.(js|vue)$/,
- include: projectRoot,
- exclude: /node_modules/,
- use: {
- loader: 'eslint-loader',
- options: {
- formatter: require('eslint-friendly-formatter'),
- sourceMap: config.build.productionSourceMap,
- extract: true
- }
- }
- },
{
enforce: 'post',
test: /\.(json5?|ya?ml)$/, // target json, json5, yaml and yml files
@@ -89,24 +83,23 @@ module.exports = {
},
{
test: /\.(png|jpe?g|gif|svg)(\?.*)?$/,
- use: {
- loader: 'url-loader',
- options: {
- limit: 10000,
- name: utils.assetsPath('img/[name].[hash:7].[ext]')
- }
+ type: 'asset',
+ generator: {
+ filename: utils.assetsPath('img/[name].[hash:7][ext]')
}
},
{
test: /\.(woff2?|eot|ttf|otf)(\?.*)?$/,
- use: {
- loader: 'url-loader',
- options: {
- limit: 10000,
- name: utils.assetsPath('fonts/[name].[hash:7].[ext]')
- }
+ type: 'asset',
+ generator: {
+ filename: utils.assetsPath('fonts/[name].[hash:7][ext]')
}
},
+ {
+ test: /\.mjs$/,
+ include: /node_modules/,
+ type: 'javascript/auto'
+ }
]
},
plugins: [
@@ -114,6 +107,22 @@ module.exports = {
entry: path.join(__dirname, '..', 'src/sw.js'),
filename: 'sw-pleroma.js'
}),
- new VueLoaderPlugin()
+ new ESLintPlugin({
+ extensions: ['js', 'vue'],
+ formatter: require('eslint-formatter-friendly')
+ }),
+ new VueLoaderPlugin(),
+ // This copies Ruffle's WASM to a directory so that JS side can access it
+ new CopyPlugin({
+ patterns: [
+ {
+ from: "node_modules/@ruffle-rs/ruffle/**/*",
+ to: "static/ruffle/[name][ext]"
+ },
+ ],
+ options: {
+ concurrency: 100,
+ },
+ })
]
}
diff --git a/build/webpack.dev.conf.js b/build/webpack.dev.conf.js
index 4605b93d..97799f82 100644
--- a/build/webpack.dev.conf.js
+++ b/build/webpack.dev.conf.js
@@ -16,7 +16,7 @@ module.exports = merge(baseWebpackConfig, {
},
mode: 'development',
// eval-source-map is faster for development
- devtool: '#eval-source-map',
+ devtool: 'eval-source-map',
plugins: [
new webpack.DefinePlugin({
'process.env': config.dev.env,
diff --git a/build/webpack.prod.conf.js b/build/webpack.prod.conf.js
index a67ed2f6..7de93721 100644
--- a/build/webpack.prod.conf.js
+++ b/build/webpack.prod.conf.js
@@ -5,6 +5,7 @@ var webpack = require('webpack')
var merge = require('webpack-merge')
var baseWebpackConfig = require('./webpack.base.conf')
var MiniCssExtractPlugin = require('mini-css-extract-plugin')
+const CssMinimizerPlugin = require("css-minimizer-webpack-plugin")
var HtmlWebpackPlugin = require('html-webpack-plugin')
var env = process.env.NODE_ENV === 'testing'
? require('../config/test.env')
@@ -19,12 +20,16 @@ var webpackConfig = merge(baseWebpackConfig, {
module: {
rules: utils.styleLoaders({ sourceMap: config.dev.cssSourceMap, extract: true })
},
- devtool: config.build.productionSourceMap ? '#source-map' : false,
+ devtool: config.build.productionSourceMap ? 'source-map' : false,
optimization: {
minimize: true,
splitChunks: {
chunks: 'all'
- }
+ },
+ minimizer: [
+ `...`,
+ new CssMinimizerPlugin()
+ ]
},
output: {
path: config.build.assetsRoot,
@@ -60,9 +65,7 @@ var webpackConfig = merge(baseWebpackConfig, {
ignoreCustomComments: [/server-generated-meta/]
// more options:
// https://github.com/kangax/html-minifier#options-quick-reference
- },
- // necessary to consistently work with multiple chunks via CommonsChunkPlugin
- chunksSortMode: 'dependency'
+ }
}),
// split vendor js into its own file
// extract webpack runtime and module manifest to its own file in order to
diff --git a/ci/add-key.sh b/ci/add-key.sh
new file mode 100755
index 00000000..64614f90
--- /dev/null
+++ b/ci/add-key.sh
@@ -0,0 +1,17 @@
+#!/bin/sh
+
+# only execute this script as part of the pipeline.
+[ -z "$CI" ] && echo "missing ci environment variable" && exit 2
+
+# only execute the script when github token exists.
+[ -z "$SSH_KEY" ] && echo "missing ssh key" && exit 3
+
+# write the ssh key.
+mkdir /root/.ssh
+echo -n "${SSH_KEY}" > /root/.ssh/id_ed25519
+chmod 600 /root/.ssh/id_ed25519
+
+# add froth.zone to our known hosts.
+touch /root/.ssh/known_hosts
+chmod 600 /root/.ssh/known_hosts
+ssh-keyscan -H froth.zone > /etc/ssh/ssh_known_hosts 2> /dev/null
diff --git a/ci/deploy.sh b/ci/deploy.sh
new file mode 100755
index 00000000..9625d473
--- /dev/null
+++ b/ci/deploy.sh
@@ -0,0 +1,11 @@
+#!/usr/bin/env bash
+TARGET="pleroma@froth.zone:/opt/pleroma"
+
+#rsync -ra public/ "${TARGET}/instance/static"
+#cp dist/index.html "${TARGET}/instance/static/index.html"
+rsync --update -Pr dist/ "${TARGET}/instance/static/"
+rsync --update -ra dist/static/ "${TARGET}/instance/static/static"
+#rsync --delete -ra images/ "${TARGET}/instance/static/images"
+#rsync --delete -ra sounds/ "${TARGET}/instance/static/sounds"
+rsync -ra instance/ "${TARGET}/instance/static/instance"
+#rsync --delete -ra pages/ "${TARGET}/instance/static/pages"
diff --git a/package.json b/package.json
index 58aeb363..260df573 100644
--- a/package.json
+++ b/package.json
@@ -16,111 +16,112 @@
"lint-fix": "eslint --fix --ext .js,.vue src test/unit/specs test/e2e/specs"
},
"dependencies": {
- "@babel/runtime": "7.17.8",
+ "@babel/runtime": "7.18.9",
"@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": "3.0.0-5",
+ "@fortawesome/fontawesome-svg-core": "6.2.0",
+ "@fortawesome/free-regular-svg-icons": "6.2.0",
+ "@fortawesome/free-solid-svg-icons": "6.2.0",
+ "@fortawesome/vue-fontawesome": "3.0.1",
"@kazvmoe-infra/pinch-zoom-element": "1.2.0",
- "@vuelidate/core": "2.0.0-alpha.41",
- "@vuelidate/validators": "2.0.0-alpha.27",
- "body-scroll-lock": "2.7.1",
+ "@kazvmoe-infra/unicode-emoji-json": "^0.4.0",
+ "@ruffle-rs/ruffle": "0.1.0-nightly.2022.7.12",
+ "@vuelidate/core": "2.0.0-alpha.44",
+ "@vuelidate/validators": "2.0.0-alpha.31",
+ "body-scroll-lock": "3.1.5",
"chromatism": "3.0.0",
"click-outside-vue3": "4.0.1",
"cropperjs": "1.5.12",
"diff": "3.5.0",
"escape-html": "1.0.3",
- "js-cookie": "^3.0.1",
+ "js-cookie": "3.0.1",
"localforage": "1.10.0",
- "parse-link-header": "1.0.1",
+ "lozad": "^1.16.0",
+ "parse-link-header": "2.0.0",
"phoenix": "1.6.2",
"punycode.js": "2.1.0",
- "qrcode": "1",
- "ruffle-mirror": "2021.12.31",
- "vue": "^3.2.31",
- "vue-i18n": "^9.2.0-beta.34",
- "vue-router": "4.0.14",
- "vue-template-compiler": "2.6.11",
+ "qrcode": "1.5.0",
+ "querystring-es3": "0.2.1",
+ "url": "0.11.0",
+ "utf8": "3.0.0",
+ "vue": "3.2.38",
+ "vue-i18n": "9.2.2",
+ "vue-router": "4.1.5",
+ "vue-template-compiler": "2.7.10",
"vuex": "4.0.2"
},
"devDependencies": {
- "@babel/core": "7.17.8",
- "@babel/plugin-transform-runtime": "7.17.0",
- "@babel/preset-env": "7.16.11",
- "@babel/register": "7.17.7",
- "@intlify/vue-i18n-loader": "^5.0.0",
+ "@babel/core": "7.18.13",
+ "@babel/eslint-parser": "7.18.9",
+ "@babel/plugin-transform-runtime": "7.18.10",
+ "@babel/preset-env": "7.18.10",
+ "@babel/register": "7.18.9",
+ "@intlify/vue-i18n-loader": "5.0.0",
"@ungap/event-target": "0.2.3",
- "@vue/babel-helper-vue-jsx-merge-props": "1.2.1",
+ "@vue/babel-helper-vue-jsx-merge-props": "1.4.0",
"@vue/babel-plugin-jsx": "1.1.1",
- "@vue/compiler-sfc": "^3.1.0",
- "@vue/test-utils": "2.0.0-rc.17",
- "autoprefixer": "6.7.7",
- "babel-eslint": "7.2.3",
- "babel-loader": "8.2.4",
+ "@vue/compiler-sfc": "3.2.38",
+ "@vue/test-utils": "2.0.2",
+ "autoprefixer": "10.4.8",
+ "babel-loader": "8.2.5",
"babel-plugin-lodash": "3.3.4",
- "chai": "3.5.0",
+ "chai": "4.3.6",
"chalk": "1.1.3",
- "chromedriver": "87.0.7",
- "connect-history-api-fallback": "1.6.0",
- "copy-webpack-plugin": "6.4.1",
- "cross-spawn": "4.0.2",
- "css-loader": "0.28.11",
+ "chromedriver": "104.0.0",
+ "connect-history-api-fallback": "2.0.0",
+ "copy-webpack-plugin": "11.0.0",
+ "cross-spawn": "7.0.3",
+ "css-loader": "6.7.1",
+ "css-minimizer-webpack-plugin": "4.0.0",
"custom-event-polyfill": "1.0.7",
- "eslint": "5.16.0",
- "eslint-config-standard": "12.0.0",
- "eslint-friendly-formatter": "2.0.7",
- "eslint-loader": "2.2.1",
- "eslint-plugin-import": "2.25.4",
- "eslint-plugin-node": "7.0.1",
- "eslint-plugin-promise": "4.3.1",
- "eslint-plugin-standard": "4.1.0",
- "eslint-plugin-vue": "5.2.3",
+ "eslint": "8.23.0",
+ "eslint-config-standard": "17.0.0",
+ "eslint-formatter-friendly": "7.0.0",
+ "eslint-plugin-import": "2.26.0",
+ "eslint-plugin-n": "15.2.5",
+ "eslint-plugin-promise": "6.0.1",
+ "eslint-plugin-vue": "9.4.0",
+ "eslint-webpack-plugin": "3.2.0",
"eventsource-polyfill": "0.9.6",
- "express": "4.17.3",
- "file-loader": "3.0.1",
+ "express": "4.18.1",
"function-bind": "1.1.1",
- "html-webpack-plugin": "3.2.0",
- "http-proxy-middleware": "0.21.0",
- "inject-loader": "2.0.1",
- "iso-639-1": "2.1.13",
- "isparta-loader": "2.0.0",
+ "html-webpack-plugin": "5.5.0",
+ "http-proxy-middleware": "2.0.6",
+ "iso-639-1": "2.1.15",
"json-loader": "0.5.7",
- "karma": "6.3.17",
- "karma-coverage": "1.1.2",
- "karma-firefox-launcher": "1.3.0",
+ "karma": "6.4.0",
+ "karma-coverage": "2.2.0",
+ "karma-firefox-launcher": "2.1.2",
"karma-mocha": "2.0.1",
"karma-mocha-reporter": "2.2.5",
"karma-sinon-chai": "2.0.2",
"karma-sourcemap-loader": "0.3.8",
- "karma-spec-reporter": "0.0.33",
- "karma-webpack": "4.0.2",
+ "karma-spec-reporter": "0.0.34",
+ "karma-webpack": "5.0.0",
"lodash": "4.17.21",
"lolex": "1.6.0",
- "mini-css-extract-plugin": "0.12.0",
- "mocha": "3.5.3",
- "nightwatch": "0.9.21",
- "opn": "4.0.2",
+ "mini-css-extract-plugin": "2.6.1",
+ "mocha": "10.0.0",
+ "nightwatch": "2.3.3",
+ "opn": "5.5.0",
"ora": "0.4.1",
- "postcss-loader": "3.0.0",
- "raw-loader": "0.5.1",
- "sass": "1.20.1",
- "sass-loader": "7.2.0",
+ "postcss": "8.4.16",
+ "postcss-loader": "7.0.1",
+ "sass": "1.54.8",
+ "sass-loader": "13.0.2",
"selenium-server": "2.53.1",
- "semver": "5.7.1",
- "serviceworker-webpack-plugin": "1.0.1",
+ "semver": "7.3.7",
+ "serviceworker-webpack5-plugin": "2.0.0",
"shelljs": "0.8.5",
- "sinon": "2.4.1",
- "sinon-chai": "2.14.0",
- "stylelint": "13.6.1",
+ "sinon": "14.0.0",
+ "sinon-chai": "3.7.0",
+ "stylelint": "13.13.1",
"stylelint-config-standard": "20.0.0",
"stylelint-rscss": "0.4.0",
- "url-loader": "1.1.2",
- "vue-loader": "^16.0.0",
- "vue-style-loader": "4.1.2",
- "webpack": "4.46.0",
+ "vue-loader": "17.0.0",
+ "vue-style-loader": "4.1.3",
+ "webpack": "5.74.0",
"webpack-dev-middleware": "3.7.3",
- "webpack-hot-middleware": "2.24.3",
+ "webpack-hot-middleware": "2.25.2",
"webpack-merge": "0.20.0"
},
"engines": {
diff --git a/src/App.js b/src/App.js
index f01f8788..b7eb2f72 100644
--- a/src/App.js
+++ b/src/App.js
@@ -4,14 +4,15 @@ import InstanceSpecificPanel from './components/instance_specific_panel/instance
import FeaturesPanel from './components/features_panel/features_panel.vue'
import WhoToFollowPanel from './components/who_to_follow_panel/who_to_follow_panel.vue'
import ShoutPanel from './components/shout_panel/shout_panel.vue'
-import SettingsModal from './components/settings_modal/settings_modal.vue'
import MediaModal from './components/media_modal/media_modal.vue'
import SideDrawer from './components/side_drawer/side_drawer.vue'
import MobilePostStatusButton from './components/mobile_post_status_button/mobile_post_status_button.vue'
import MobileNav from './components/mobile_nav/mobile_nav.vue'
import DesktopNav from './components/desktop_nav/desktop_nav.vue'
import UserReportingModal from './components/user_reporting_modal/user_reporting_modal.vue'
+import EditStatusModal from './components/edit_status_modal/edit_status_modal.vue'
import PostStatusModal from './components/post_status_modal/post_status_modal.vue'
+import StatusHistoryModal from './components/status_history_modal/status_history_modal.vue'
import GlobalNoticeList from './components/global_notice_list/global_notice_list.vue'
import { windowWidth, windowHeight } from './services/window_utils/window_utils'
import { mapGetters } from 'vuex'
@@ -32,9 +33,12 @@ export default {
MobilePostStatusButton,
MobileNav,
DesktopNav,
- SettingsModal,
+ SettingsModal: defineAsyncComponent(() => import('./components/settings_modal/settings_modal.vue')),
+ UpdateNotification: defineAsyncComponent(() => import('./components/update_notification/update_notification.vue')),
UserReportingModal,
PostStatusModal,
+ EditStatusModal,
+ StatusHistoryModal,
GlobalNoticeList
},
data: () => ({
@@ -60,6 +64,13 @@ export default {
'-' + this.layoutType
]
},
+ navClasses () {
+ const { navbarColumnStretch } = this.$store.getters.mergedConfig
+ return [
+ '-' + this.layoutType,
+ ...(navbarColumnStretch ? ['-column-stretch'] : [])
+ ]
+ },
currentUser () { return this.$store.state.users.currentUser },
userBackground () { return this.currentUser.background_image },
instanceBackground () {
@@ -85,11 +96,16 @@ export default {
isChats () {
return this.$route.name === 'chat' || this.$route.name === 'chats'
},
+ isListEdit () {
+ return this.$route.name === 'lists-edit'
+ },
newPostButtonShown () {
if (this.isChats) return false
+ if (this.isListEdit) return false
return this.$store.getters.mergedConfig.alwaysShowNewPostButton || this.layoutType === 'mobile'
},
showFeaturesPanel () { return this.$store.state.instance.showFeaturesPanel },
+ editingAvailable () { return this.$store.state.instance.editingAvailable },
shoutboxPosition () {
return this.$store.getters.mergedConfig.alwaysShowNewPostButton || false
},
diff --git a/src/App.scss b/src/App.scss
index 5cd0b96e..75b2667c 100644
--- a/src/App.scss
+++ b/src/App.scss
@@ -4,6 +4,13 @@
:root {
--navbar-height: 3.5rem;
--post-line-height: 1.4;
+ // Z-Index stuff
+ --ZI_media_modal: 9000;
+ --ZI_modals_popovers: 8500;
+ --ZI_modals: 8000;
+ --ZI_navbar_popovers: 7500;
+ --ZI_navbar: 7000;
+ --ZI_popovers: 6000;
}
html {
@@ -110,14 +117,30 @@ h4 {
margin: 0;
}
+.iconLetter {
+ display: inline-block;
+ text-align: center;
+ font-weight: 1000;
+}
+
i[class*=icon-],
-.svg-inline--fa {
+.svg-inline--fa,
+.iconLetter {
color: $fallback--icon;
color: var(--icon, $fallback--icon);
}
+.button-unstyled:hover,
+a:hover {
+ > i[class*=icon-],
+ > .svg-inline--fa,
+ > .iconLetter {
+ color: var(--text);
+ }
+}
+
nav {
- z-index: 1000;
+ z-index: var(--ZI_navbar);
color: var(--topBarText);
background-color: $fallback--fg;
background-color: var(--topBar, $fallback--fg);
@@ -134,6 +157,11 @@ nav {
grid-area: sidebar;
}
+#modal {
+ position: absolute;
+ z-index: var(--ZI_modals);
+}
+
.column.-scrollable {
top: var(--navbar-height);
position: sticky;
@@ -175,13 +203,18 @@ nav {
.app-layout {
--miniColumn: 25rem;
- --maxiColumn: minmax(var(--miniColumn), 45rem);
+ --maxiColumn: 45rem;
--columnGap: 1em;
--status-margin: 0.75em;
+ --effectiveSidebarColumnWidth: minmax(var(--miniColumn), var(--sidebarColumnWidth, var(--miniColumn)));
+ --effectiveNotifsColumnWidth: minmax(var(--miniColumn), var(--notifsColumnWidth, var(--miniColumn)));
+ --effectiveContentColumnWidth: minmax(var(--miniColumn), var(--contentColumnWidth, var(--maxiColumn)));
position: relative;
display: grid;
- grid-template-columns: var(--miniColumn) var(--maxiColumn);
+ grid-template-columns:
+ var(--effectiveSidebarColumnWidth)
+ var(--effectiveContentColumnWidth);
grid-template-areas: "sidebar content";
grid-template-rows: 1fr;
box-sizing: border-box;
@@ -275,15 +308,24 @@ nav {
}
&.-reverse:not(.-wide):not(.-mobile) {
- grid-template-columns: var(--maxiColumn) var(--miniColumn);
+ grid-template-columns:
+ var(--effectiveContentColumnWidth)
+ var(--effectiveSidebarColumnWidth);
grid-template-areas: "content sidebar";
}
&.-wide {
- grid-template-columns: var(--miniColumn) var(--maxiColumn) var(--miniColumn);
+ grid-template-columns:
+ var(--effectiveSidebarColumnWidth)
+ var(--effectiveContentColumnWidth)
+ var(--effectiveNotifsColumnWidth);
grid-template-areas: "sidebar content notifs";
&.-reverse {
+ grid-template-columns:
+ var(--effectiveNotifsColumnWidth)
+ var(--effectiveContentColumnWidth)
+ var(--effectiveSidebarColumnWidth);
grid-template-areas: "notifs content sidebar";
}
}
@@ -310,7 +352,6 @@ nav {
border-top-right-radius: 0;
}
- .underlay,
#sidebar,
#notifs-column {
display: none;
@@ -740,17 +781,23 @@ option {
}
.fa-scale-110 {
- &.svg-inline--fa {
+ &.svg-inline--fa,
+ &.iconLetter {
font-size: 1.1em;
}
}
.fa-old-padding {
- &.svg-inline--fa {
+ &.iconLetter,
+ &.svg-inline--fa, &-layer {
padding: 0 0.3em;
}
}
+.veryfaint {
+ opacity: 0.25;
+}
+
.login-hint {
text-align: center;
@@ -829,7 +876,7 @@ option {
// Vue transitions
.fade-enter-active,
.fade-leave-active {
- transition: opacity 0.2s;
+ transition: opacity 0.3s;
}
.fade-enter-from,
diff --git a/src/App.vue b/src/App.vue
index d1d4217b..e0d709f7 100644
--- a/src/App.vue
+++ b/src/App.vue
@@ -8,7 +8,10 @@
class="app-bg-wrapper"
/>
-
+
-
+
+
+
+
+
diff --git a/src/_mixins.scss b/src/_mixins.scss
new file mode 100644
index 00000000..1fed16c3
--- /dev/null
+++ b/src/_mixins.scss
@@ -0,0 +1,17 @@
+@mixin unfocused-style {
+ @content;
+
+ &:focus:not(:focus-visible):not(:hover) {
+ @content;
+ }
+}
+
+@mixin focused-style {
+ &:hover, &:focus {
+ @content;
+ }
+
+ &:focus-visible {
+ @content;
+ }
+}
diff --git a/src/assets/pleromatan_apology.png b/src/assets/pleromatan_apology.png
new file mode 100644
index 00000000..36ad7aeb
Binary files /dev/null and b/src/assets/pleromatan_apology.png differ
diff --git a/src/assets/pleromatan_apology_fox.png b/src/assets/pleromatan_apology_fox.png
new file mode 100644
index 00000000..17f87694
Binary files /dev/null and b/src/assets/pleromatan_apology_fox.png differ
diff --git a/src/assets/pleromatan_apology_fox_mask.png b/src/assets/pleromatan_apology_fox_mask.png
new file mode 100644
index 00000000..4d1990d5
Binary files /dev/null and b/src/assets/pleromatan_apology_fox_mask.png differ
diff --git a/src/assets/pleromatan_apology_mask.png b/src/assets/pleromatan_apology_mask.png
new file mode 100644
index 00000000..18adafff
Binary files /dev/null and b/src/assets/pleromatan_apology_mask.png differ
diff --git a/src/boot/after_store.js b/src/boot/after_store.js
index 514982a4..36e0eec8 100644
--- a/src/boot/after_store.js
+++ b/src/boot/after_store.js
@@ -12,7 +12,7 @@ import { windowWidth, windowHeight } from '../services/window_utils/window_utils
import { getOrCreateApp, getClientToken } from '../services/new_api/oauth.js'
import backendInteractorService from '../services/backend_interactor_service/backend_interactor_service.js'
import { CURRENT_VERSION } from '../services/theme_data/theme_data.service.js'
-import { applyTheme } from '../services/style_setter/style_setter.js'
+import { applyTheme, applyConfig } from '../services/style_setter/style_setter.js'
import FaviconService from '../services/favicon_service/favicon_service.js'
import DomNodeToComponent from '../modules/domNodeToComponent.js'
@@ -158,7 +158,7 @@ const setSettings = async ({ apiConfig, staticConfig, store }) => {
copyInstanceOption('hideSitename')
copyInstanceOption('sidebarRight')
- return store.dispatch('setTheme', config['theme'])
+ return store.dispatch('setTheme', config.theme)
}
const getTOS = async ({ store }) => {
@@ -199,7 +199,7 @@ const getStickers = async ({ store }) => {
const stickers = (await Promise.all(
Object.entries(values).map(async ([name, path]) => {
const resPack = await window.fetch(path + 'pack.json')
- var meta = {}
+ let meta = {}
if (resPack.ok) {
meta = await resPack.json()
}
@@ -253,6 +253,7 @@ const getNodeInfo = async ({ store }) => {
store.dispatch('setInstanceOption', { name: 'pleromaChatMessagesAvailable', value: features.includes('pleroma_chat_messages') })
store.dispatch('setInstanceOption', { name: 'gopherAvailable', value: features.includes('gopher') })
store.dispatch('setInstanceOption', { name: 'pollsAvailable', value: features.includes('polls') })
+ store.dispatch('setInstanceOption', { name: 'editingAvailable', value: features.includes('editing') })
store.dispatch('setInstanceOption', { name: 'pollLimits', value: metadata.pollLimits })
store.dispatch('setInstanceOption', { name: 'mailerEnabled', value: metadata.mailerEnabled })
@@ -321,6 +322,7 @@ const setConfig = async ({ store }) => {
}
const checkOAuthToken = async ({ store }) => {
+ // eslint-disable-next-line no-async-promise-executor
return new Promise(async (resolve, reject) => {
if (store.getters.getUserToken()) {
try {
@@ -361,6 +363,8 @@ const afterStoreSetup = async ({ store, i18n }) => {
console.error('Failed to load any theme!')
}
+ applyConfig(store.state.config)
+
// Now we can try getting the server settings and logging in
// Most of these are preloaded into the index.html so blocking is minimized
await Promise.all([
@@ -399,6 +403,9 @@ const afterStoreSetup = async ({ store, i18n }) => {
app.component('FAIcon', FontAwesomeIcon)
app.component('FALayers', FontAwesomeLayers)
+ // remove after vue 3.3
+ app.config.unwrapInjectedRef = true
+
app.mount('#app')
return app
diff --git a/src/boot/routes.js b/src/boot/routes.js
index 726476a8..63dd1297 100644
--- a/src/boot/routes.js
+++ b/src/boot/routes.js
@@ -20,6 +20,10 @@ import ShoutPanel from 'components/shout_panel/shout_panel.vue'
import WhoToFollow from 'components/who_to_follow/who_to_follow.vue'
import About from 'components/about/about.vue'
import RemoteUserResolver from 'components/remote_user_resolver/remote_user_resolver.vue'
+import Lists from 'components/lists/lists.vue'
+import ListsTimeline from 'components/lists_timeline/lists_timeline.vue'
+import ListsEdit from 'components/lists_edit/lists_edit.vue'
+import NavPanel from 'src/components/nav_panel/nav_panel.vue'
export default (store) => {
const validateAuthenticatedRoute = (to, from, next) => {
@@ -31,7 +35,8 @@ export default (store) => {
}
let routes = [
- { name: 'root',
+ {
+ name: 'root',
path: '/',
redirect: _to => {
return (store.state.users.currentUser
@@ -45,17 +50,19 @@ export default (store) => {
{ name: 'tag-timeline', path: '/tag/:tag', component: TagTimeline },
{ name: 'bookmarks', path: '/bookmarks', component: BookmarkTimeline },
{ name: 'conversation', path: '/notice/:id', component: ConversationPage, meta: { dontScroll: true } },
- { name: 'remote-user-profile-acct',
+ {
+ name: 'remote-user-profile-acct',
path: '/remote-users/:_(@)?:username([^/@]+)@:hostname([^/@]+)',
component: RemoteUserResolver,
beforeEnter: validateAuthenticatedRoute
},
- { name: 'remote-user-profile',
+ {
+ name: 'remote-user-profile',
path: '/remote-users/:hostname/:username',
component: RemoteUserResolver,
beforeEnter: validateAuthenticatedRoute
},
- { name: 'external-user-profile', path: '/users/:id', component: UserProfile },
+ { name: 'external-user-profile', path: '/users/$:id', component: UserProfile },
{ name: 'interactions', path: '/users/:username/interactions', component: Interactions, beforeEnter: validateAuthenticatedRoute },
{ name: 'dms', path: '/users/:username/dms', component: DMs, beforeEnter: validateAuthenticatedRoute },
{ name: 'registration', path: '/registration', component: Registration },
@@ -69,7 +76,13 @@ export default (store) => {
{ name: 'search', path: '/search', component: Search, props: (route) => ({ query: route.query.query }) },
{ name: 'who-to-follow', path: '/who-to-follow', component: WhoToFollow, beforeEnter: validateAuthenticatedRoute },
{ name: 'about', path: '/about', component: About },
- { name: 'user-profile', path: '/:_(users)?/:name', component: UserProfile }
+ { name: 'user-profile', path: '/users/:name', component: UserProfile },
+ { name: 'legacy-user-profile', path: '/:name', component: UserProfile },
+ { name: 'lists', path: '/lists', component: Lists },
+ { name: 'lists-timeline', path: '/lists/:id', component: ListsTimeline },
+ { name: 'lists-edit', path: '/lists/:id/edit', component: ListsEdit },
+ { name: 'lists-new', path: '/lists/new', component: ListsEdit },
+ { name: 'edit-navigation', path: '/nav-edit', component: NavPanel, props: () => ({ forceExpand: true, forceEditMode: true }), beforeEnter: validateAuthenticatedRoute }
]
if (store.state.instance.pleromaChatMessagesAvailable) {
diff --git a/src/components/about/about.vue b/src/components/about/about.vue
index 5d5d6479..33586c97 100644
--- a/src/components/about/about.vue
+++ b/src/components/about/about.vue
@@ -8,7 +8,7 @@
-
+
diff --git a/src/components/account_actions/account_actions.js b/src/components/account_actions/account_actions.js
index 99762562..c23407f9 100644
--- a/src/components/account_actions/account_actions.js
+++ b/src/components/account_actions/account_actions.js
@@ -1,6 +1,7 @@
import { mapState } from 'vuex'
import ProgressButton from '../progress_button/progress_button.vue'
import Popover from '../popover/popover.vue'
+import UserListMenu from 'src/components/user_list_menu/user_list_menu.vue'
import { library } from '@fortawesome/fontawesome-svg-core'
import {
faEllipsisV
@@ -19,7 +20,8 @@ const AccountActions = {
},
components: {
ProgressButton,
- Popover
+ Popover,
+ UserListMenu
},
methods: {
showRepeats () {
@@ -34,6 +36,9 @@ const AccountActions = {
unblockUser () {
this.$store.dispatch('unblockUser', this.user.id)
},
+ removeUserFromFollowers () {
+ this.$store.dispatch('removeUserFromFollowers', this.user.id)
+ },
reportUser () {
this.$store.dispatch('openUserReportingModal', { userId: this.user.id })
},
diff --git a/src/components/account_actions/account_actions.vue b/src/components/account_actions/account_actions.vue
index c35d01af..218aa6b3 100644
--- a/src/components/account_actions/account_actions.vue
+++ b/src/components/account_actions/account_actions.vue
@@ -6,7 +6,7 @@
:bound-to="{ x: 'container' }"
remove-padding
>
-
+
-
+
-
+
+
diff --git a/src/components/emoji_input/emoji_input.js b/src/components/emoji_input/emoji_input.js
index 7ca4e8a8..bdfa785e 100644
--- a/src/components/emoji_input/emoji_input.js
+++ b/src/components/emoji_input/emoji_input.js
@@ -1,8 +1,9 @@
import Completion from '../../services/completion/completion.js'
import EmojiPicker from '../emoji_picker/emoji_picker.vue'
+import UnicodeDomainIndicator from '../unicode_domain_indicator/unicode_domain_indicator.vue'
import { take } from 'lodash'
import { findOffset } from '../../services/offset_finder/offset_finder.service.js'
-
+import { ensureFinalFallback } from '../../i18n/languages.js'
import { library } from '@fortawesome/fontawesome-svg-core'
import {
faSmileBeam
@@ -120,7 +121,8 @@ const EmojiInput = {
}
},
components: {
- EmojiPicker
+ EmojiPicker,
+ UnicodeDomainIndicator
},
computed: {
padEmoji () {
@@ -141,6 +143,51 @@ const EmojiInput = {
const word = Completion.wordAtPosition(this.modelValue, this.caret - 1) || {}
return word
}
+ },
+ languages () {
+ return ensureFinalFallback(this.$store.getters.mergedConfig.interfaceLanguage)
+ },
+ maybeLocalizedEmojiNamesAndKeywords () {
+ return emoji => {
+ const names = [emoji.displayText]
+ const keywords = []
+
+ if (emoji.displayTextI18n) {
+ names.push(this.$t(emoji.displayTextI18n.key, emoji.displayTextI18n.args))
+ }
+
+ if (emoji.annotations) {
+ this.languages.forEach(lang => {
+ names.push(emoji.annotations[lang]?.name)
+
+ keywords.push(...(emoji.annotations[lang]?.keywords || []))
+ })
+ }
+
+ return {
+ names: names.filter(k => k),
+ keywords: keywords.filter(k => k)
+ }
+ }
+ },
+ maybeLocalizedEmojiName () {
+ return emoji => {
+ if (!emoji.annotations) {
+ return emoji.displayText
+ }
+
+ if (emoji.displayTextI18n) {
+ return this.$t(emoji.displayTextI18n.key, emoji.displayTextI18n.args)
+ }
+
+ for (const lang of this.languages) {
+ if (emoji.annotations[lang]?.name) {
+ return emoji.annotations[lang].name
+ }
+ }
+
+ return emoji.displayText
+ }
}
},
mounted () {
@@ -179,7 +226,7 @@ const EmojiInput = {
const firstchar = newWord.charAt(0)
this.suggestions = []
if (newWord === firstchar) return
- const matchedSuggestions = await this.suggest(newWord)
+ const matchedSuggestions = await this.suggest(newWord, this.maybeLocalizedEmojiNamesAndKeywords)
// Async: cancel if textAtCaret has changed during wait
if (this.textAtCaret !== newWord) return
if (matchedSuggestions.length <= 0) return
@@ -205,7 +252,6 @@ const EmojiInput = {
},
triggerShowPicker () {
this.showPicker = true
- this.$refs.picker.startEmojiLoad()
this.$nextTick(() => {
this.scrollIntoView()
this.focusPickerInput()
@@ -321,7 +367,7 @@ const EmojiInput = {
}
},
scrollIntoView () {
- const rootRef = this.$refs['picker'].$el
+ const rootRef = this.$refs.picker.$el
/* Scroller is either `window` (replies in TL), sidebar (main post form,
* replies in notifs) or mobile post form. Note that getting and setting
* scroll is different for `Window` and `Element`s
diff --git a/src/components/emoji_input/emoji_input.vue b/src/components/emoji_input/emoji_input.vue
index 7d95ab7e..43581dbf 100644
--- a/src/components/emoji_input/emoji_input.vue
+++ b/src/components/emoji_input/emoji_input.vue
@@ -19,6 +19,7 @@
v-if="enableEmojiPicker"
ref="picker"
:class="{ hide: !showPicker }"
+ :showing="showPicker"
:enable-sticker-picker="enableStickerPicker"
class="emoji-picker-panel"
@emoji="insert"
@@ -50,7 +51,21 @@
{{ suggestion.replacement }}
- {{ suggestion.displayText }}
+
+ {{ suggestion.displayText }}
+
+
+ {{ maybeLocalizedEmojiName(suggestion) }}
+
{{ suggestion.detailText }}
diff --git a/src/components/emoji_input/suggestor.js b/src/components/emoji_input/suggestor.js
index e8efbd1e..adaa879e 100644
--- a/src/components/emoji_input/suggestor.js
+++ b/src/components/emoji_input/suggestor.js
@@ -2,7 +2,7 @@
* suggest - generates a suggestor function to be used by emoji-input
* data: object providing source information for specific types of suggestions:
* data.emoji - optional, an array of all emoji available i.e.
- * (state.instance.emoji + state.instance.customEmoji)
+ * (getters.standardEmojiList + state.instance.customEmoji)
* data.users - optional, an array of all known users
* updateUsersList - optional, a function to search and append to users
*
@@ -13,10 +13,10 @@
export default data => {
const emojiCurry = suggestEmoji(data.emoji)
const usersCurry = data.store && suggestUsers(data.store)
- return input => {
+ return (input, nameKeywordLocalizer) => {
const firstChar = input[0]
if (firstChar === ':' && data.emoji) {
- return emojiCurry(input)
+ return emojiCurry(input, nameKeywordLocalizer)
}
if (firstChar === '@' && usersCurry) {
return usersCurry(input)
@@ -25,34 +25,34 @@ export default data => {
}
}
-export const suggestEmoji = emojis => input => {
+export const suggestEmoji = emojis => (input, nameKeywordLocalizer) => {
const noPrefix = input.toLowerCase().substr(1)
return emojis
- .filter(({ displayText }) => displayText.toLowerCase().match(noPrefix))
- .sort((a, b) => {
- let aScore = 0
- let bScore = 0
+ .map(emoji => ({ ...emoji, ...nameKeywordLocalizer(emoji) }))
+ .filter((emoji) => (emoji.names.concat(emoji.keywords)).filter(kw => kw.toLowerCase().match(noPrefix)).length)
+ .map(k => {
+ let score = 0
// An exact match always wins
- aScore += a.displayText.toLowerCase() === noPrefix ? 200 : 0
- bScore += b.displayText.toLowerCase() === noPrefix ? 200 : 0
+ score += Math.max(...k.names.map(name => name.toLowerCase() === noPrefix ? 200 : 0), 0)
// Prioritize custom emoji a lot
- aScore += a.imageUrl ? 100 : 0
- bScore += b.imageUrl ? 100 : 0
+ score += k.imageUrl ? 100 : 0
// Prioritize prefix matches somewhat
- aScore += a.displayText.toLowerCase().startsWith(noPrefix) ? 10 : 0
- bScore += b.displayText.toLowerCase().startsWith(noPrefix) ? 10 : 0
+ score += Math.max(...k.names.map(kw => kw.toLowerCase().startsWith(noPrefix) ? 10 : 0), 0)
// Sort by length
- aScore -= a.displayText.length
- bScore -= b.displayText.length
+ score -= k.displayText.length
+ k.score = score
+ return k
+ })
+ .sort((a, b) => {
// Break ties alphabetically
const alphabetically = a.displayText > b.displayText ? 0.5 : -0.5
- return bScore - aScore + alphabetically
+ return b.score - a.score + alphabetically
})
}
@@ -116,11 +116,12 @@ export const suggestUsers = ({ dispatch, state }) => {
return diff + nameAlphabetically + screenNameAlphabetically
/* eslint-disable camelcase */
- }).map(({ screen_name, screen_name_ui, name, profile_image_url_original }) => ({
- displayText: screen_name_ui,
- detailText: name,
- imageUrl: profile_image_url_original,
- replacement: '@' + screen_name + ' '
+ }).map((user) => ({
+ user,
+ displayText: user.screen_name_ui,
+ detailText: user.name,
+ imageUrl: user.profile_image_url_original,
+ replacement: '@' + user.screen_name + ' '
}))
/* eslint-enable camelcase */
diff --git a/src/components/emoji_picker/emoji_picker.js b/src/components/emoji_picker/emoji_picker.js
index 6b589079..fafc2af1 100644
--- a/src/components/emoji_picker/emoji_picker.js
+++ b/src/components/emoji_picker/emoji_picker.js
@@ -1,32 +1,76 @@
import { defineAsyncComponent } from 'vue'
import Checkbox from '../checkbox/checkbox.vue'
+import StillImage from '../still-image/still-image.vue'
+import { ensureFinalFallback } from '../../i18n/languages.js'
+import lozad from 'lozad'
import { library } from '@fortawesome/fontawesome-svg-core'
import {
faBoxOpen,
faStickyNote,
- faSmileBeam
+ faSmileBeam,
+ faSmile,
+ faUser,
+ faPaw,
+ faIceCream,
+ faBus,
+ faBasketballBall,
+ faLightbulb,
+ faCode,
+ faFlag
} from '@fortawesome/free-solid-svg-icons'
+import { debounce, trim } from 'lodash'
library.add(
faBoxOpen,
faStickyNote,
- faSmileBeam
+ faSmileBeam,
+ faSmile,
+ faUser,
+ faPaw,
+ faIceCream,
+ faBus,
+ faBasketballBall,
+ faLightbulb,
+ faCode,
+ faFlag
)
-// At widest, approximately 20 emoji are visible in a row,
-// loading 3 rows, could be overkill for narrow picker
-const LOAD_EMOJI_BY = 60
+const UNICODE_EMOJI_GROUP_ICON = {
+ 'smileys-and-emotion': 'smile',
+ 'people-and-body': 'user',
+ 'animals-and-nature': 'paw',
+ 'food-and-drink': 'ice-cream',
+ 'travel-and-places': 'bus',
+ activities: 'basketball-ball',
+ objects: 'lightbulb',
+ symbols: 'code',
+ flags: 'flag'
+}
-// When to start loading new batch emoji, in pixels
-const LOAD_EMOJI_MARGIN = 64
+const maybeLocalizedKeywords = (emoji, languages, nameLocalizer) => {
+ const res = [emoji.displayText, nameLocalizer(emoji)]
+ if (emoji.annotations) {
+ languages.forEach(lang => {
+ const keywords = emoji.annotations[lang]?.keywords || []
+ const name = emoji.annotations[lang]?.name
+ res.push(...(keywords.concat([name]).filter(k => k)))
+ })
+ }
+ return res
+}
-const filterByKeyword = (list, keyword = '') => {
+const filterByKeyword = (list, keyword = '', languages, nameLocalizer) => {
if (keyword === '') return list
const keywordLowercase = keyword.toLowerCase()
- let orderedEmojiList = []
+ const orderedEmojiList = []
for (const emoji of list) {
- const indexOfKeyword = emoji.displayText.toLowerCase().indexOf(keywordLowercase)
+ const indices = maybeLocalizedKeywords(emoji, languages, nameLocalizer)
+ .map(k => k.toLowerCase().indexOf(keywordLowercase))
+ .filter(k => k > -1)
+
+ const indexOfKeyword = indices.length ? Math.min(...indices) : -1
+
if (indexOfKeyword > -1) {
if (!Array.isArray(orderedEmojiList[indexOfKeyword])) {
orderedEmojiList[indexOfKeyword] = []
@@ -43,6 +87,10 @@ const EmojiPicker = {
required: false,
type: Boolean,
default: false
+ },
+ showing: {
+ required: true,
+ type: Boolean
}
},
data () {
@@ -52,16 +100,26 @@ const EmojiPicker = {
showingStickers: false,
groupsScrolledClass: 'scrolled-top',
keepOpen: false,
- customEmojiBufferSlice: LOAD_EMOJI_BY,
customEmojiTimeout: null,
- customEmojiLoadAllConfirmed: false
+ // Lazy-load only after the first time `showing` becomes true.
+ contentLoaded: false,
+ groupRefs: {},
+ emojiRefs: {},
+ filteredEmojiGroups: []
}
},
components: {
StickerPicker: defineAsyncComponent(() => import('../sticker_picker/sticker_picker.vue')),
- Checkbox
+ Checkbox,
+ StillImage
},
methods: {
+ setGroupRef (name) {
+ return el => { this.groupRefs[name] = el }
+ },
+ setEmojiRef (name) {
+ return el => { this.emojiRefs[name] = el }
+ },
onStickerUploaded (e) {
this.$emit('sticker-uploaded', e)
},
@@ -76,10 +134,38 @@ const EmojiPicker = {
const target = (e && e.target) || this.$refs['emoji-groups']
this.updateScrolledClass(target)
this.scrolledGroup(target)
- this.triggerLoadMore(target)
+ },
+ scrolledGroup (target) {
+ const top = target.scrollTop + 5
+ this.$nextTick(() => {
+ this.allEmojiGroups.forEach(group => {
+ const ref = this.groupRefs['group-' + group.id]
+ if (ref && ref.offsetTop <= top) {
+ this.activeGroup = group.id
+ }
+ })
+ this.scrollHeader()
+ })
+ },
+ scrollHeader () {
+ // Scroll the active tab's header into view
+ const headerRef = this.groupRefs['group-header-' + this.activeGroup]
+ const left = headerRef.offsetLeft
+ const right = left + headerRef.offsetWidth
+ const headerCont = this.$refs.header
+ const currentScroll = headerCont.scrollLeft
+ const currentScrollRight = currentScroll + headerCont.clientWidth
+ const setScroll = s => { headerCont.scrollLeft = s }
+
+ const margin = 7 // .emoji-tabs-item: padding
+ if (left - margin < currentScroll) {
+ setScroll(left - margin)
+ } else if (right + margin > currentScrollRight) {
+ setScroll(right + margin - headerCont.clientWidth)
+ }
},
highlight (key) {
- const ref = this.$refs['group-' + key]
+ const ref = this.groupRefs['group-' + key]
const top = ref.offsetTop
this.setShowStickers(false)
this.activeGroup = key
@@ -96,73 +182,90 @@ const EmojiPicker = {
this.groupsScrolledClass = 'scrolled-middle'
}
},
- triggerLoadMore (target) {
- const ref = this.$refs['group-end-custom']
- if (!ref) return
- const bottom = ref.offsetTop + ref.offsetHeight
-
- const scrollerBottom = target.scrollTop + target.clientHeight
- const scrollerTop = target.scrollTop
- const scrollerMax = target.scrollHeight
-
- // Loads more emoji when they come into view
- const approachingBottom = bottom - scrollerBottom < LOAD_EMOJI_MARGIN
- // Always load when at the very top in case there's no scroll space yet
- const atTop = scrollerTop < 5
- // Don't load when looking at unicode category or at the very bottom
- const bottomAboveViewport = bottom < scrollerTop || scrollerBottom === scrollerMax
- if (!bottomAboveViewport && (approachingBottom || atTop)) {
- this.loadEmoji()
- }
- },
- scrolledGroup (target) {
- const top = target.scrollTop + 5
- this.$nextTick(() => {
- this.emojisView.forEach(group => {
- const ref = this.$refs['group-' + group.id]
- if (ref.offsetTop <= top) {
- this.activeGroup = group.id
- }
- })
- })
- },
- loadEmoji () {
- const allLoaded = this.customEmojiBuffer.length === this.filteredEmoji.length
-
- if (allLoaded) {
- return
- }
-
- this.customEmojiBufferSlice += LOAD_EMOJI_BY
- },
- startEmojiLoad (forceUpdate = false) {
- if (!forceUpdate) {
- this.keyword = ''
- }
- this.$nextTick(() => {
- this.$refs['emoji-groups'].scrollTop = 0
- })
- const bufferSize = this.customEmojiBuffer.length
- const bufferPrefilledAll = bufferSize === this.filteredEmoji.length
- if (bufferPrefilledAll && !forceUpdate) {
- return
- }
- this.customEmojiBufferSlice = LOAD_EMOJI_BY
- },
toggleStickers () {
this.showingStickers = !this.showingStickers
},
setShowStickers (value) {
this.showingStickers = value
+ },
+ filterByKeyword (list, keyword) {
+ return filterByKeyword(list, keyword, this.languages, this.maybeLocalizedEmojiName)
+ },
+ initializeLazyLoad () {
+ this.destroyLazyLoad()
+ this.$nextTick(() => {
+ this.$lozad = lozad('.still-image.emoji-picker-emoji', {
+ load: el => {
+ const name = el.getAttribute('data-emoji-name')
+ const vn = this.emojiRefs[name]
+ if (!vn) {
+ return
+ }
+
+ vn.loadLazy()
+ }
+ })
+ this.$lozad.observe()
+ })
+ },
+ waitForDomAndInitializeLazyLoad () {
+ this.$nextTick(() => this.initializeLazyLoad())
+ },
+ destroyLazyLoad () {
+ if (this.$lozad) {
+ if (this.$lozad.observer) {
+ this.$lozad.observer.disconnect()
+ }
+ if (this.$lozad.mutationObserver) {
+ this.$lozad.mutationObserver.disconnect()
+ }
+ }
+ },
+ onShowing () {
+ const oldContentLoaded = this.contentLoaded
+ this.contentLoaded = true
+ this.waitForDomAndInitializeLazyLoad()
+ this.filteredEmojiGroups = this.getFilteredEmojiGroups()
+ if (!oldContentLoaded) {
+ this.$nextTick(() => {
+ if (this.defaultGroup) {
+ this.highlight(this.defaultGroup)
+ }
+ })
+ }
+ },
+ getFilteredEmojiGroups () {
+ return this.allEmojiGroups
+ .map(group => ({
+ ...group,
+ emojis: this.filterByKeyword(group.emojis, trim(this.keyword))
+ }))
+ .filter(group => group.emojis.length > 0)
}
},
watch: {
keyword () {
- this.customEmojiLoadAllConfirmed = false
this.onScroll()
- this.startEmojiLoad(true)
+ this.debouncedHandleKeywordChange()
+ },
+ allCustomGroups () {
+ this.waitForDomAndInitializeLazyLoad()
+ this.filteredEmojiGroups = this.getFilteredEmojiGroups()
+ },
+ showing (val) {
+ if (val) {
+ this.onShowing()
+ }
}
},
+ mounted () {
+ if (this.showing) {
+ this.onShowing()
+ }
+ },
+ destroyed () {
+ this.destroyLazyLoad()
+ },
computed: {
activeGroupView () {
return this.showingStickers ? '' : this.activeGroup
@@ -173,39 +276,55 @@ const EmojiPicker = {
}
return 0
},
- filteredEmoji () {
- return filterByKeyword(
- this.$store.state.instance.customEmoji || [],
- this.keyword
- )
+ allCustomGroups () {
+ return this.$store.getters.groupedCustomEmojis
},
- customEmojiBuffer () {
- return this.filteredEmoji.slice(0, this.customEmojiBufferSlice)
+ defaultGroup () {
+ return Object.keys(this.allCustomGroups)[0]
},
- emojis () {
- const standardEmojis = this.$store.state.instance.emoji || []
- const customEmojis = this.customEmojiBuffer
-
- return [
- {
- id: 'custom',
- text: this.$t('emoji.custom'),
- icon: 'smile-beam',
- emojis: customEmojis
- },
- {
- id: 'standard',
- text: this.$t('emoji.unicode'),
- icon: 'box-open',
- emojis: filterByKeyword(standardEmojis, this.keyword)
- }
- ]
+ unicodeEmojiGroups () {
+ return this.$store.getters.standardEmojiGroupList.map(group => ({
+ id: `standard-${group.id}`,
+ text: this.$t(`emoji.unicode_groups.${group.id}`),
+ icon: UNICODE_EMOJI_GROUP_ICON[group.id],
+ emojis: group.emojis
+ }))
},
- emojisView () {
- return this.emojis.filter(value => value.emojis.length > 0)
+ allEmojiGroups () {
+ return Object.entries(this.allCustomGroups)
+ .map(([_, v]) => v)
+ .concat(this.unicodeEmojiGroups)
},
stickerPickerEnabled () {
return (this.$store.state.instance.stickers || []).length !== 0
+ },
+ debouncedHandleKeywordChange () {
+ return debounce(() => {
+ this.waitForDomAndInitializeLazyLoad()
+ this.filteredEmojiGroups = this.getFilteredEmojiGroups()
+ }, 500)
+ },
+ languages () {
+ return ensureFinalFallback(this.$store.getters.mergedConfig.interfaceLanguage)
+ },
+ maybeLocalizedEmojiName () {
+ return emoji => {
+ if (!emoji.annotations) {
+ return emoji.displayText
+ }
+
+ if (emoji.displayTextI18n) {
+ return this.$t(emoji.displayTextI18n.key, emoji.displayTextI18n.args)
+ }
+
+ for (const lang of this.languages) {
+ if (emoji.annotations[lang]?.name) {
+ return emoji.annotations[lang].name
+ }
+ }
+
+ return emoji.displayText
+ }
}
}
}
diff --git a/src/components/emoji_picker/emoji_picker.scss b/src/components/emoji_picker/emoji_picker.scss
index 2055e02e..016c46d7 100644
--- a/src/components/emoji_picker/emoji_picker.scss
+++ b/src/components/emoji_picker/emoji_picker.scss
@@ -1,5 +1,10 @@
@import '../../_variables.scss';
+$emoji-picker-header-height: 36px;
+$emoji-picker-header-picture-width: 32px;
+$emoji-picker-header-picture-height: 32px;
+$emoji-picker-emoji-size: 32px;
+
.emoji-picker {
display: flex;
flex-direction: column;
@@ -7,7 +12,8 @@
right: 0;
left: 0;
margin: 0 !important;
- z-index: 100;
+ // TODO: actually use popover in emoji picker
+ z-index: var(--ZI_popovers);
background-color: $fallback--bg;
background-color: var(--popover, $fallback--bg);
color: $fallback--link;
@@ -18,6 +24,23 @@
--lightText: var(--popoverLightText, $fallback--lightText);
--icon: var(--popoverIcon, $fallback--icon);
+ &-header-image {
+ display: inline-flex;
+ justify-content: center;
+ align-items: center;
+ width: $emoji-picker-header-picture-width;
+ max-width: $emoji-picker-header-picture-width;
+ height: $emoji-picker-header-picture-height;
+ max-height: $emoji-picker-header-picture-height;
+ .still-image {
+ max-width: 100%;
+ max-height: 100%;
+ height: 100%;
+ width: 100%;
+ object-fit: contain;
+ }
+ }
+
.keep-open,
.too-many-emoji {
padding: 7px;
@@ -36,7 +59,6 @@
.heading {
display: flex;
- height: 32px;
padding: 10px 7px 5px;
}
@@ -49,6 +71,10 @@
.emoji-tabs {
flex-grow: 1;
+ display: flex;
+ flex-direction: row;
+ flex-wrap: nowrap;
+ overflow-x: auto;
}
.emoji-groups {
@@ -56,6 +82,8 @@
}
.additional-tabs {
+ display: flex;
+ flex: 1;
border-left: 1px solid;
border-left-color: $fallback--icon;
border-left-color: var(--icon, $fallback--icon);
@@ -65,15 +93,20 @@
.additional-tabs,
.emoji-tabs {
- display: block;
- min-width: 0;
flex-basis: auto;
- flex-shrink: 1;
+ display: flex;
+ align-content: center;
&-item {
padding: 0 7px;
cursor: pointer;
font-size: 1.85em;
+ width: $emoji-picker-header-picture-width;
+ max-width: $emoji-picker-header-picture-width;
+ height: $emoji-picker-header-picture-height;
+ max-height: $emoji-picker-header-picture-height;
+ display: flex;
+ align-items: center;
&.disabled {
opacity: 0.5;
@@ -163,22 +196,26 @@
}
&-item {
- width: 32px;
- height: 32px;
+ width: $emoji-picker-emoji-size;
+ height: $emoji-picker-emoji-size;
box-sizing: border-box;
display: flex;
- font-size: 32px;
+ line-height: $emoji-picker-emoji-size;
align-items: center;
justify-content: center;
margin: 4px;
cursor: pointer;
- img {
+ .emoji-picker-emoji.-custom {
object-fit: contain;
max-width: 100%;
max-height: 100%;
}
+ .emoji-picker-emoji.-unicode {
+ font-size: 24px;
+ overflow: hidden;
+ }
}
}
diff --git a/src/components/emoji_picker/emoji_picker.vue b/src/components/emoji_picker/emoji_picker.vue
index 3262a3d9..57bb0037 100644
--- a/src/components/emoji_picker/emoji_picker.vue
+++ b/src/components/emoji_picker/emoji_picker.vue
@@ -1,19 +1,34 @@
-
+
-
+
+
@@ -36,7 +51,10 @@
-
+
{{ group.text }}
@@ -69,17 +88,23 @@
- {{ emoji.replacement }}
- {{ emoji.replacement }}
+
+ :ref="setEmojiRef(group.id + emoji.displayText)"
+ class="emoji-picker-emoji -custom"
+ :data-src="emoji.imageUrl"
+ :data-emoji-name="group.id + emoji.displayText"
+ />
-
+
diff --git a/src/components/emoji_reactions/emoji_reactions.vue b/src/components/emoji_reactions/emoji_reactions.vue
index b1ce50cd..b914f48b 100644
--- a/src/components/emoji_reactions/emoji_reactions.vue
+++ b/src/components/emoji_reactions/emoji_reactions.vue
@@ -26,7 +26,7 @@
-
+
diff --git a/src/components/favorite_button/favorite_button.js b/src/components/favorite_button/favorite_button.js
index 5cd05f73..c996cba2 100644
--- a/src/components/favorite_button/favorite_button.js
+++ b/src/components/favorite_button/favorite_button.js
@@ -1,13 +1,21 @@
import { mapGetters } from 'vuex'
import { library } from '@fortawesome/fontawesome-svg-core'
-import { faStar } from '@fortawesome/free-solid-svg-icons'
+import {
+ faStar,
+ faPlus,
+ faMinus,
+ faCheck
+} from '@fortawesome/free-solid-svg-icons'
import {
faStar as faStarRegular
} from '@fortawesome/free-regular-svg-icons'
library.add(
faStar,
- faStarRegular
+ faStarRegular,
+ faPlus,
+ faMinus,
+ faCheck
)
const FavoriteButton = {
diff --git a/src/components/favorite_button/favorite_button.vue b/src/components/favorite_button/favorite_button.vue
index dce25e24..74a1dfbb 100644
--- a/src/components/favorite_button/favorite_button.vue
+++ b/src/components/favorite_button/favorite_button.vue
@@ -7,11 +7,31 @@
:title="$t('tool_tip.favorite')"
@click.prevent="favorite()"
>
-
+
+
+
+
+
+
-
+
diff --git a/src/components/features_panel/features_panel.vue b/src/components/features_panel/features_panel.vue
index a58a99af..4cdf56d0 100644
--- a/src/components/features_panel/features_panel.vue
+++ b/src/components/features_panel/features_panel.vue
@@ -32,7 +32,7 @@
-
+
diff --git a/src/components/lists_card/lists_card.js b/src/components/lists_card/lists_card.js
new file mode 100644
index 00000000..b503caec
--- /dev/null
+++ b/src/components/lists_card/lists_card.js
@@ -0,0 +1,16 @@
+import { library } from '@fortawesome/fontawesome-svg-core'
+import {
+ faEllipsisH
+} from '@fortawesome/free-solid-svg-icons'
+
+library.add(
+ faEllipsisH
+)
+
+const ListsCard = {
+ props: [
+ 'list'
+ ]
+}
+
+export default ListsCard
diff --git a/src/components/lists_card/lists_card.vue b/src/components/lists_card/lists_card.vue
new file mode 100644
index 00000000..13866d8c
--- /dev/null
+++ b/src/components/lists_card/lists_card.vue
@@ -0,0 +1,51 @@
+
+
+
+ {{ list.title }}
+
+
+
+
+
+
+
+
+
+
diff --git a/src/components/lists_edit/lists_edit.js b/src/components/lists_edit/lists_edit.js
new file mode 100644
index 00000000..c22d1323
--- /dev/null
+++ b/src/components/lists_edit/lists_edit.js
@@ -0,0 +1,145 @@
+import { mapState, mapGetters } from 'vuex'
+import BasicUserCard from '../basic_user_card/basic_user_card.vue'
+import ListsUserSearch from '../lists_user_search/lists_user_search.vue'
+import PanelLoading from 'src/components/panel_loading/panel_loading.vue'
+import UserAvatar from '../user_avatar/user_avatar.vue'
+import TabSwitcher from 'src/components/tab_switcher/tab_switcher.jsx'
+import { library } from '@fortawesome/fontawesome-svg-core'
+import {
+ faSearch,
+ faChevronLeft
+} from '@fortawesome/free-solid-svg-icons'
+
+library.add(
+ faSearch,
+ faChevronLeft
+)
+
+const ListsNew = {
+ components: {
+ BasicUserCard,
+ UserAvatar,
+ ListsUserSearch,
+ TabSwitcher,
+ PanelLoading
+ },
+ data () {
+ return {
+ title: '',
+ titleDraft: '',
+ membersUserIds: [],
+ removedUserIds: new Set([]), // users we added for members, to undo
+ searchUserIds: [],
+ addedUserIds: new Set([]), // users we added from search, to undo
+ searchLoading: false,
+ reallyDelete: false
+ }
+ },
+ created () {
+ if (!this.id) return
+ this.$store.dispatch('fetchList', { listId: this.id })
+ .then(() => {
+ this.title = this.findListTitle(this.id)
+ this.titleDraft = this.title
+ })
+ this.$store.dispatch('fetchListAccounts', { listId: this.id })
+ .then(() => {
+ this.membersUserIds = this.findListAccounts(this.id)
+ this.membersUserIds.forEach(userId => {
+ this.$store.dispatch('fetchUserIfMissing', userId)
+ })
+ })
+ },
+ computed: {
+ id () {
+ return this.$route.params.id
+ },
+ membersUsers () {
+ return [...this.membersUserIds, ...this.addedUserIds]
+ .map(userId => this.findUser(userId)).filter(user => user)
+ },
+ searchUsers () {
+ return this.searchUserIds.map(userId => this.findUser(userId)).filter(user => user)
+ },
+ ...mapState({
+ currentUser: state => state.users.currentUser
+ }),
+ ...mapGetters(['findUser', 'findListTitle', 'findListAccounts'])
+ },
+ methods: {
+ onInput () {
+ this.search(this.query)
+ },
+ toggleRemoveMember (user) {
+ if (this.removedUserIds.has(user.id)) {
+ this.id && this.addUser(user)
+ this.removedUserIds.delete(user.id)
+ } else {
+ this.id && this.removeUser(user.id)
+ this.removedUserIds.add(user.id)
+ }
+ },
+ toggleAddFromSearch (user) {
+ if (this.addedUserIds.has(user.id)) {
+ this.id && this.removeUser(user.id)
+ this.addedUserIds.delete(user.id)
+ } else {
+ this.id && this.addUser(user)
+ this.addedUserIds.add(user.id)
+ }
+ },
+ isRemoved (user) {
+ return this.removedUserIds.has(user.id)
+ },
+ isAdded (user) {
+ return this.addedUserIds.has(user.id)
+ },
+ addUser (user) {
+ this.$store.dispatch('addListAccount', { accountId: this.user.id, listId: this.id })
+ },
+ removeUser (userId) {
+ this.$store.dispatch('removeListAccount', { accountId: this.user.id, listId: this.id })
+ },
+ onSearchLoading (results) {
+ this.searchLoading = true
+ },
+ onSearchLoadingDone (results) {
+ this.searchLoading = false
+ },
+ onSearchResults (results) {
+ this.searchLoading = false
+ this.searchUserIds = results
+ },
+ updateListTitle () {
+ this.$store.dispatch('setList', { listId: this.id, title: this.titleDraft })
+ .then(() => {
+ this.title = this.findListTitle(this.id)
+ })
+ },
+ createList () {
+ this.$store.dispatch('createList', { title: this.titleDraft })
+ .then((list) => {
+ return this
+ .$store
+ .dispatch('setListAccounts', { listId: list.id, accountIds: [...this.addedUserIds] })
+ .then(() => list.id)
+ })
+ .then((listId) => {
+ this.$router.push({ name: 'lists-timeline', params: { id: listId } })
+ })
+ .catch((e) => {
+ this.$store.dispatch('pushGlobalNotice', {
+ messageKey: 'lists.error',
+ messageArgs: [e.message],
+ level: 'error'
+ })
+ })
+ },
+ deleteList () {
+ this.$store.dispatch('deleteList', { listId: this.id })
+ this.$router.push({ name: 'lists' })
+ }
+ }
+}
+
+export default ListsNew
diff --git a/src/components/lists_edit/lists_edit.vue b/src/components/lists_edit/lists_edit.vue
new file mode 100644
index 00000000..6521aba6
--- /dev/null
+++ b/src/components/lists_edit/lists_edit.vue
@@ -0,0 +1,228 @@
+
+
+
+
+
+
+
+ {{ title }}
+
+
+
+
+
+
+
+
+ {{ ' ' }}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {{ $t('lists.is_in_list') }}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/src/components/lists_menu/lists_menu_content.js b/src/components/lists_menu/lists_menu_content.js
new file mode 100644
index 00000000..97b32210
--- /dev/null
+++ b/src/components/lists_menu/lists_menu_content.js
@@ -0,0 +1,22 @@
+import { mapState } from 'vuex'
+import NavigationEntry from 'src/components/navigation/navigation_entry.vue'
+import { getListEntries } from 'src/components/navigation/filter.js'
+
+export const ListsMenuContent = {
+ props: [
+ 'showPin'
+ ],
+ components: {
+ NavigationEntry
+ },
+ computed: {
+ ...mapState({
+ lists: getListEntries,
+ currentUser: state => state.users.currentUser,
+ privateMode: state => state.instance.private,
+ federating: state => state.instance.federating
+ })
+ }
+}
+
+export default ListsMenuContent
diff --git a/src/components/lists_menu/lists_menu_content.vue b/src/components/lists_menu/lists_menu_content.vue
new file mode 100644
index 00000000..f93e80c9
--- /dev/null
+++ b/src/components/lists_menu/lists_menu_content.vue
@@ -0,0 +1,12 @@
+
+
+
+
+
diff --git a/src/components/lists_timeline/lists_timeline.js b/src/components/lists_timeline/lists_timeline.js
new file mode 100644
index 00000000..c3f408bd
--- /dev/null
+++ b/src/components/lists_timeline/lists_timeline.js
@@ -0,0 +1,36 @@
+import Timeline from '../timeline/timeline.vue'
+const ListsTimeline = {
+ data () {
+ return {
+ listId: null
+ }
+ },
+ components: {
+ Timeline
+ },
+ computed: {
+ timeline () { return this.$store.state.statuses.timelines.list }
+ },
+ watch: {
+ $route: function (route) {
+ if (route.name === 'lists-timeline' && route.params.id !== this.listId) {
+ this.listId = route.params.id
+ this.$store.dispatch('stopFetchingTimeline', 'list')
+ this.$store.commit('clearTimeline', { timeline: 'list' })
+ this.$store.dispatch('fetchList', { listId: this.listId })
+ this.$store.dispatch('startFetchingTimeline', { timeline: 'list', listId: this.listId })
+ }
+ }
+ },
+ created () {
+ this.listId = this.$route.params.id
+ this.$store.dispatch('fetchList', { listId: this.listId })
+ this.$store.dispatch('startFetchingTimeline', { timeline: 'list', listId: this.listId })
+ },
+ unmounted () {
+ this.$store.dispatch('stopFetchingTimeline', 'list')
+ this.$store.commit('clearTimeline', { timeline: 'list' })
+ }
+}
+
+export default ListsTimeline
diff --git a/src/components/lists_timeline/lists_timeline.vue b/src/components/lists_timeline/lists_timeline.vue
new file mode 100644
index 00000000..18156b81
--- /dev/null
+++ b/src/components/lists_timeline/lists_timeline.vue
@@ -0,0 +1,10 @@
+
+
+
+
+
diff --git a/src/components/lists_user_search/lists_user_search.js b/src/components/lists_user_search/lists_user_search.js
new file mode 100644
index 00000000..c92ec0ee
--- /dev/null
+++ b/src/components/lists_user_search/lists_user_search.js
@@ -0,0 +1,51 @@
+import { library } from '@fortawesome/fontawesome-svg-core'
+import {
+ faSearch,
+ faChevronLeft
+} from '@fortawesome/free-solid-svg-icons'
+import { debounce } from 'lodash'
+import Checkbox from '../checkbox/checkbox.vue'
+
+library.add(
+ faSearch,
+ faChevronLeft
+)
+
+const ListsUserSearch = {
+ components: {
+ Checkbox
+ },
+ emits: ['loading', 'loadingDone', 'results'],
+ data () {
+ return {
+ loading: false,
+ query: '',
+ followingOnly: true
+ }
+ },
+ methods: {
+ onInput: debounce(function () {
+ this.search(this.query)
+ }, 2000),
+ search (query) {
+ if (!query) {
+ this.loading = false
+ return
+ }
+
+ this.loading = true
+ this.$emit('loading')
+ this.userIds = []
+ this.$store.dispatch('search', { q: query, resolve: true, type: 'accounts', following: this.followingOnly })
+ .then(data => {
+ this.$emit('results', data.accounts.map(a => a.id))
+ })
+ .finally(() => {
+ this.loading = false
+ this.$emit('loadingDone')
+ })
+ }
+ }
+}
+
+export default ListsUserSearch
diff --git a/src/components/lists_user_search/lists_user_search.vue b/src/components/lists_user_search/lists_user_search.vue
new file mode 100644
index 00000000..8633170c
--- /dev/null
+++ b/src/components/lists_user_search/lists_user_search.vue
@@ -0,0 +1,47 @@
+
+
+
+
+
+ {{ $t('lists.following_only') }}
+
+
+
+
+
+
+
diff --git a/src/components/login_form/login_form.js b/src/components/login_form/login_form.js
index 638bd812..b795640e 100644
--- a/src/components/login_form/login_form.js
+++ b/src/components/login_form/login_form.js
@@ -83,7 +83,7 @@ const LoginForm = {
},
clearError () { this.error = false },
focusOnPasswordInput () {
- let passwordInput = this.$refs.passwordInput
+ const passwordInput = this.$refs.passwordInput
passwordInput.focus()
passwordInput.setSelectionRange(0, passwordInput.value.length)
}
diff --git a/src/components/login_form/login_form.vue b/src/components/login_form/login_form.vue
index 21482977..7a430c51 100644
--- a/src/components/login_form/login_form.vue
+++ b/src/components/login_form/login_form.vue
@@ -90,7 +90,7 @@
-
+
diff --git a/src/components/navigation/filter.js b/src/components/navigation/filter.js
new file mode 100644
index 00000000..31b55486
--- /dev/null
+++ b/src/components/navigation/filter.js
@@ -0,0 +1,18 @@
+export const filterNavigation = (list = [], { hasChats, isFederating, isPrivate, currentUser }) => {
+ return list.filter(({ criteria, anon, anonRoute }) => {
+ const set = new Set(criteria || [])
+ if (!isFederating && set.has('federating')) return false
+ if (isPrivate && set.has('!private')) return false
+ if (!currentUser && !(anon || anonRoute)) return false
+ if ((!currentUser || !currentUser.locked) && set.has('lockedUser')) return false
+ if (!hasChats && set.has('chats')) return false
+ return true
+ })
+}
+
+export const getListEntries = state => state.lists.allLists.map(list => ({
+ name: 'list-' + list.id,
+ routeObject: { name: 'lists-timeline', params: { id: list.id } },
+ labelRaw: list.title,
+ iconLetter: list.title[0]
+}))
diff --git a/src/components/navigation/navigation.js b/src/components/navigation/navigation.js
new file mode 100644
index 00000000..f66dd981
--- /dev/null
+++ b/src/components/navigation/navigation.js
@@ -0,0 +1,75 @@
+export const USERNAME_ROUTES = new Set([
+ 'bookmarks',
+ 'dms',
+ 'interactions',
+ 'notifications',
+ 'chat',
+ 'chats',
+ 'user-profile'
+])
+
+export const TIMELINES = {
+ home: {
+ route: 'friends',
+ icon: 'home',
+ label: 'nav.home_timeline',
+ criteria: ['!private']
+ },
+ public: {
+ route: 'public-timeline',
+ anon: true,
+ icon: 'users',
+ label: 'nav.public_tl',
+ criteria: ['!private']
+ },
+ twkn: {
+ route: 'public-external-timeline',
+ anon: true,
+ icon: 'globe',
+ label: 'nav.twkn',
+ criteria: ['!private', 'federating']
+ },
+ bookmarks: {
+ route: 'bookmarks',
+ icon: 'bookmark',
+ label: 'nav.bookmarks'
+ },
+ favorites: {
+ routeObject: { name: 'user-profile', query: { tab: 'favorites' } },
+ icon: 'star',
+ label: 'user_card.favorites'
+ },
+ dms: {
+ route: 'dms',
+ icon: 'envelope',
+ label: 'nav.dms'
+ }
+}
+
+export const ROOT_ITEMS = {
+ interactions: {
+ route: 'interactions',
+ icon: 'bell',
+ label: 'nav.interactions'
+ },
+ chats: {
+ route: 'chats',
+ icon: 'comments',
+ label: 'nav.chats',
+ badgeGetter: 'unreadChatCount',
+ criteria: ['chats']
+ },
+ friendRequests: {
+ route: 'friend-requests',
+ icon: 'user-plus',
+ label: 'nav.friend_requests',
+ criteria: ['lockedUser'],
+ badgeGetter: 'followRequestCount'
+ },
+ about: {
+ route: 'about',
+ anon: true,
+ icon: 'info-circle',
+ label: 'nav.about'
+ }
+}
diff --git a/src/components/navigation/navigation_entry.js b/src/components/navigation/navigation_entry.js
new file mode 100644
index 00000000..81cc936a
--- /dev/null
+++ b/src/components/navigation/navigation_entry.js
@@ -0,0 +1,51 @@
+import { mapState } from 'vuex'
+import { USERNAME_ROUTES } from 'src/components/navigation/navigation.js'
+import OptionalRouterLink from 'src/components/optional_router_link/optional_router_link.vue'
+import { library } from '@fortawesome/fontawesome-svg-core'
+import { faThumbtack } from '@fortawesome/free-solid-svg-icons'
+
+library.add(faThumbtack)
+
+const NavigationEntry = {
+ props: ['item', 'showPin'],
+ components: {
+ OptionalRouterLink
+ },
+ methods: {
+ isPinned (value) {
+ return this.pinnedItems.has(value)
+ },
+ togglePin (value) {
+ if (this.isPinned(value)) {
+ this.$store.commit('removeCollectionPreference', { path: 'collections.pinnedNavItems', value })
+ } else {
+ this.$store.commit('addCollectionPreference', { path: 'collections.pinnedNavItems', value })
+ }
+ this.$store.dispatch('pushServerSideStorage')
+ }
+ },
+ computed: {
+ routeTo () {
+ if (!this.item.route && !this.item.routeObject) return null
+ let route
+ if (this.item.routeObject) {
+ route = this.item.routeObject
+ } else {
+ route = { name: (this.item.anon || this.currentUser) ? this.item.route : this.item.anonRoute }
+ }
+ if (USERNAME_ROUTES.has(route.name)) {
+ route.params = { username: this.currentUser.screen_name, name: this.currentUser.screen_name }
+ }
+ return route
+ },
+ getters () {
+ return this.$store.getters
+ },
+ ...mapState({
+ currentUser: state => state.users.currentUser,
+ pinnedItems: state => new Set(state.serverSideStorage.prefsStorage.collections.pinnedNavItems)
+ })
+ }
+}
+
+export default NavigationEntry
diff --git a/src/components/navigation/navigation_entry.vue b/src/components/navigation/navigation_entry.vue
new file mode 100644
index 00000000..f4d53836
--- /dev/null
+++ b/src/components/navigation/navigation_entry.vue
@@ -0,0 +1,133 @@
+
+
+
+
+
+
+
+
+
diff --git a/src/components/navigation/navigation_pins.js b/src/components/navigation/navigation_pins.js
new file mode 100644
index 00000000..57b8d589
--- /dev/null
+++ b/src/components/navigation/navigation_pins.js
@@ -0,0 +1,88 @@
+import { mapState } from 'vuex'
+import { TIMELINES, ROOT_ITEMS, USERNAME_ROUTES } from 'src/components/navigation/navigation.js'
+import { getListEntries, filterNavigation } from 'src/components/navigation/filter.js'
+
+import { library } from '@fortawesome/fontawesome-svg-core'
+import {
+ faUsers,
+ faGlobe,
+ faBookmark,
+ faEnvelope,
+ faComments,
+ faBell,
+ faInfoCircle,
+ faStream,
+ faList
+} from '@fortawesome/free-solid-svg-icons'
+
+library.add(
+ faUsers,
+ faGlobe,
+ faBookmark,
+ faEnvelope,
+ faComments,
+ faBell,
+ faInfoCircle,
+ faStream,
+ faList
+)
+
+const NavPanel = {
+ props: ['limit'],
+ methods: {
+ getRouteTo (item) {
+ if (item.routeObject) {
+ return item.routeObject
+ }
+ const route = { name: (item.anon || this.currentUser) ? item.route : item.anonRoute }
+ if (USERNAME_ROUTES.has(route.name)) {
+ route.params = { username: this.currentUser.screen_name }
+ }
+ return route
+ }
+ },
+ computed: {
+ getters () {
+ return this.$store.getters
+ },
+ ...mapState({
+ lists: getListEntries,
+ currentUser: state => state.users.currentUser,
+ followRequestCount: state => state.api.followRequests.length,
+ privateMode: state => state.instance.private,
+ federating: state => state.instance.federating,
+ pleromaChatMessagesAvailable: state => state.instance.pleromaChatMessagesAvailable,
+ pinnedItems: state => new Set(state.serverSideStorage.prefsStorage.collections.pinnedNavItems)
+ }),
+ pinnedList () {
+ if (!this.currentUser) {
+ return [
+ { ...TIMELINES.public, name: 'public' },
+ { ...TIMELINES.twkn, name: 'twkn' },
+ { ...ROOT_ITEMS.about, name: 'about' }
+ ]
+ }
+ return filterNavigation(
+ [
+ ...Object
+ .entries({ ...TIMELINES })
+ .filter(([k]) => this.pinnedItems.has(k))
+ .map(([k, v]) => ({ ...v, name: k })),
+ ...this.lists.filter((k) => this.pinnedItems.has(k.name)),
+ ...Object
+ .entries({ ...ROOT_ITEMS })
+ .filter(([k]) => this.pinnedItems.has(k))
+ .map(([k, v]) => ({ ...v, name: k }))
+ ],
+ {
+ hasChats: this.pleromaChatMessagesAvailable,
+ isFederating: this.federating,
+ isPrivate: this.privateMode,
+ currentUser: this.currentUser
+ }
+ ).slice(0, this.limit)
+ }
+ }
+}
+
+export default NavPanel
diff --git a/src/components/navigation/navigation_pins.vue b/src/components/navigation/navigation_pins.vue
new file mode 100644
index 00000000..5b3fa6f4
--- /dev/null
+++ b/src/components/navigation/navigation_pins.vue
@@ -0,0 +1,76 @@
+
+
+
+
+ {{ item.iconLetter }}
+
+
+
+
+
+
+
+
diff --git a/src/components/notification/notification.js b/src/components/notification/notification.js
index fc8affd7..a2713fc7 100644
--- a/src/components/notification/notification.js
+++ b/src/components/notification/notification.js
@@ -3,8 +3,10 @@ import Status from '../status/status.vue'
import UserAvatar from '../user_avatar/user_avatar.vue'
import UserCard from '../user_card/user_card.vue'
import Timeago from '../timeago/timeago.vue'
-import StatusContent from '../status_content/status_content.vue'
+import Report from '../report/report.vue'
+import UserLink from '../user_link/user_link.vue'
import RichContent from 'src/components/rich_content/rich_content.jsx'
+import UserPopover from '../user_popover/user_popover.vue'
import { isStatusNotification } from '../../services/notification_utils/notification_utils.js'
import { highlightClass, highlightStyle } from '../../services/user_highlighter/user_highlighter.js'
import generateProfileLink from 'src/services/user_profile_link_generator/user_profile_link_generator'
@@ -39,14 +41,17 @@ const Notification = {
unmuted: false
}
},
- props: [ 'notification' ],
+ props: ['notification'],
components: {
- StatusContent,
+ // StatusContent,
UserAvatar,
UserCard,
Timeago,
Status,
- RichContent
+ Report,
+ RichContent,
+ UserPopover,
+ UserLink
},
methods: {
toggleUserExpanded () {
diff --git a/src/components/notification/notification.vue b/src/components/notification/notification.vue
index 7d3d0c69..26b174ff 100644
--- a/src/components/notification/notification.vue
+++ b/src/components/notification/notification.vue
@@ -11,9 +11,10 @@
class="Notification container -muted"
>
-
- {{ notification.from_profile.screen_name_ui }}
-
+
@@ -290,18 +306,14 @@
{{ $t('settings.mention_link_display') }}
-
- -
-
- {{ $t('settings.mention_link_show_tooltip') }}
-
-
-
+
+
+ {{ $t('settings.mention_link_use_tooltip') }}
+
+
+
+
diff --git a/src/components/settings_modal/tabs/mutes_and_blocks_tab.vue b/src/components/settings_modal/tabs/mutes_and_blocks_tab.vue
index 32a21415..c515d542 100644
--- a/src/components/settings_modal/tabs/mutes_and_blocks_tab.vue
+++ b/src/components/settings_modal/tabs/mutes_and_blocks_tab.vue
@@ -10,7 +10,7 @@
:query="queryUserIds"
:placeholder="$t('settings.search_user_to_block')"
>
-
+
@@ -21,7 +21,7 @@
:refresh="true"
:get-key="i => i"
>
-
+
{{ $t('user_card.block') }}
-
+
{{ $t('user_card.block_progress') }}
@@ -39,16 +39,16 @@
:click="() => unblockUsers(selected)"
>
{{ $t('user_card.unblock') }}
-
+
{{ $t('user_card.unblock_progress') }}
-
+
-
+
{{ $t('settings.no_blocks') }}
@@ -63,7 +63,7 @@
:query="queryUserIds"
:placeholder="$t('settings.search_user_to_mute')"
>
-
+
@@ -74,7 +74,7 @@
:refresh="true"
:get-key="i => i"
>
-
+
{{ $t('user_card.mute') }}
-
+
{{ $t('user_card.mute_progress') }}
@@ -92,16 +92,16 @@
:click="() => unmuteUsers(selected)"
>
{{ $t('user_card.unmute') }}
-
+
{{ $t('user_card.unmute_progress') }}
-
+
-
+
{{ $t('settings.no_mutes') }}
@@ -114,7 +114,7 @@
:query="queryKnownDomains"
:placeholder="$t('settings.type_domains_to_mute')"
>
-
+
@@ -125,7 +125,7 @@
:refresh="true"
:get-key="i => i"
>
-
+
{{ $t('domain_mute_card.unmute') }}
-
+
{{ $t('domain_mute_card.unmute_progress') }}
-
+
-
+
{{ $t('settings.no_mutes') }}
diff --git a/src/components/settings_modal/tabs/profile_tab.js b/src/components/settings_modal/tabs/profile_tab.js
index 8781bb91..b86faef0 100644
--- a/src/components/settings_modal/tabs/profile_tab.js
+++ b/src/components/settings_modal/tabs/profile_tab.js
@@ -64,17 +64,19 @@ const ProfileTab = {
emojiUserSuggestor () {
return suggestor({
emoji: [
- ...this.$store.state.instance.emoji,
+ ...this.$store.getters.standardEmojiList,
...this.$store.state.instance.customEmoji
],
store: this.$store
})
},
emojiSuggestor () {
- return suggestor({ emoji: [
- ...this.$store.state.instance.emoji,
- ...this.$store.state.instance.customEmoji
- ] })
+ return suggestor({
+ emoji: [
+ ...this.$store.getters.standardEmojiList,
+ ...this.$store.state.instance.customEmoji
+ ]
+ })
},
userSuggestor () {
return suggestor({ store: this.$store })
diff --git a/src/components/settings_modal/tabs/security_tab/mfa.js b/src/components/settings_modal/tabs/security_tab/mfa.js
index abf37062..5337d150 100644
--- a/src/components/settings_modal/tabs/security_tab/mfa.js
+++ b/src/components/settings_modal/tabs/security_tab/mfa.js
@@ -32,8 +32,8 @@ const Mfa = {
components: {
'recovery-codes': RecoveryCodes,
'totp-item': TOTP,
- 'qrcode': VueQrcode,
- 'confirm': Confirm
+ qrcode: VueQrcode,
+ confirm: Confirm
},
computed: {
canSetupOTP () {
@@ -139,7 +139,7 @@ const Mfa = {
// fetch settings from server
async fetchSettings () {
- let result = await this.backendInteractor.settingsMFA()
+ const result = await this.backendInteractor.settingsMFA()
if (result.error) return
this.settings = result.settings
this.settings.available = true
diff --git a/src/components/settings_modal/tabs/security_tab/mfa_totp.js b/src/components/settings_modal/tabs/security_tab/mfa_totp.js
index 8408d8e9..b0adb530 100644
--- a/src/components/settings_modal/tabs/security_tab/mfa_totp.js
+++ b/src/components/settings_modal/tabs/security_tab/mfa_totp.js
@@ -10,7 +10,7 @@ export default {
inProgress: false // progress peform request to disable otp method
}),
components: {
- 'confirm': Confirm
+ confirm: Confirm
},
computed: {
isActivated () {
diff --git a/src/components/settings_modal/tabs/security_tab/security_tab.js b/src/components/settings_modal/tabs/security_tab/security_tab.js
index bd18ab54..dd5adb90 100644
--- a/src/components/settings_modal/tabs/security_tab/security_tab.js
+++ b/src/components/settings_modal/tabs/security_tab/security_tab.js
@@ -13,13 +13,23 @@ const SecurityTab = {
deletingAccount: false,
deleteAccountConfirmPasswordInput: '',
deleteAccountError: false,
- changePasswordInputs: [ '', '', '' ],
+ changePasswordInputs: ['', '', ''],
changedPassword: false,
- changePasswordError: false
+ changePasswordError: false,
+ moveAccountTarget: '',
+ moveAccountPassword: '',
+ movedAccount: false,
+ moveAccountError: false,
+ aliases: [],
+ listAliasesError: false,
+ addAliasTarget: '',
+ addedAlias: false,
+ addAliasError: false
}
},
created () {
this.$store.dispatch('fetchTokens')
+ this.fetchAliases()
},
components: {
ProgressButton,
@@ -92,6 +102,49 @@ const SecurityTab = {
}
})
},
+ moveAccount () {
+ const params = {
+ targetAccount: this.moveAccountTarget,
+ password: this.moveAccountPassword
+ }
+ this.$store.state.api.backendInteractor.moveAccount(params)
+ .then((res) => {
+ if (res.status === 'success') {
+ this.movedAccount = true
+ this.moveAccountError = false
+ } else {
+ this.movedAccount = false
+ this.moveAccountError = res.error
+ }
+ })
+ },
+ removeAlias (alias) {
+ this.$store.state.api.backendInteractor.deleteAlias({ alias })
+ .then(() => this.fetchAliases())
+ },
+ addAlias () {
+ this.$store.state.api.backendInteractor.addAlias({ alias: this.addAliasTarget })
+ .then((res) => {
+ this.addedAlias = true
+ this.addAliasError = false
+ this.addAliasTarget = ''
+ })
+ .catch((error) => {
+ this.addedAlias = false
+ this.addAliasError = error
+ })
+ .then(() => this.fetchAliases())
+ },
+ fetchAliases () {
+ this.$store.state.api.backendInteractor.listAliases()
+ .then((res) => {
+ this.aliases = res.aliases
+ this.listAliasesError = false
+ })
+ .catch((error) => {
+ this.listAliasesError = error.error
+ })
+ },
logout () {
this.$store.dispatch('logout')
this.$router.replace('/')
diff --git a/src/components/settings_modal/tabs/security_tab/security_tab.vue b/src/components/settings_modal/tabs/security_tab/security_tab.vue
index 275d4616..c74a0c67 100644
--- a/src/components/settings_modal/tabs/security_tab/security_tab.vue
+++ b/src/components/settings_modal/tabs/security_tab/security_tab.vue
@@ -103,6 +103,114 @@
+
+
+
{{ $t('settings.account_alias') }}
+
+
+
+ {{ $t('settings.account_alias_table_head') }} |
+ |
+
+
+
+
+ {{ alias }} |
+
+
+ {{ $t('settings.remove_alias') }}
+
+ |
+
+
+
+
+ {{ $t('settings.list_aliases_error', { error }) }}
+
+
+
+
+
+ foo@example.org
+
+
+
+
+
+ {{ $t('settings.save') }}
+
+
+ {{ $t('settings.added_alias') }}
+
+
+ {{ $t('settings.add_alias_error', { error: addAliasError }) }}
+
+
+
+
+
{{ $t('settings.move_account') }}
+
{{ $t('settings.move_account_notes') }}
+
+
+
+ foo@example.org
+
+
+
+
+
+
{{ $t('settings.current_password') }}
+
+
+
+ {{ $t('settings.save') }}
+
+
+ {{ $t('settings.moved_account') }}
+
+
+ {{ $t('settings.move_account_error', { error: moveAccountError }) }}
+
+
+
{{ $t('settings.delete_account') }}
diff --git a/src/components/settings_modal/tabs/theme_tab/theme_tab.js b/src/components/settings_modal/tabs/theme_tab/theme_tab.js
index 7e1da7ab..282cb384 100644
--- a/src/components/settings_modal/tabs/theme_tab/theme_tab.js
+++ b/src/components/settings_modal/tabs/theme_tab/theme_tab.js
@@ -95,11 +95,11 @@ export default {
...Object.keys(SLOT_INHERITANCE)
.map(key => [key, ''])
- .reduce((acc, [key, val]) => ({ ...acc, [ key + 'ColorLocal' ]: val }), {}),
+ .reduce((acc, [key, val]) => ({ ...acc, [key + 'ColorLocal']: val }), {}),
...Object.keys(OPACITIES)
.map(key => [key, ''])
- .reduce((acc, [key, val]) => ({ ...acc, [ key + 'OpacityLocal' ]: val }), {}),
+ .reduce((acc, [key, val]) => ({ ...acc, [key + 'OpacityLocal']: val }), {}),
shadowSelected: undefined,
shadowsLocal: {},
@@ -212,12 +212,12 @@ export default {
currentColors () {
return Object.keys(SLOT_INHERITANCE)
.map(key => [key, this[key + 'ColorLocal']])
- .reduce((acc, [key, val]) => ({ ...acc, [ key ]: val }), {})
+ .reduce((acc, [key, val]) => ({ ...acc, [key]: val }), {})
},
currentOpacity () {
return Object.keys(OPACITIES)
.map(key => [key, this[key + 'OpacityLocal']])
- .reduce((acc, [key, val]) => ({ ...acc, [ key ]: val }), {})
+ .reduce((acc, [key, val]) => ({ ...acc, [key]: val }), {})
},
currentRadii () {
return {
diff --git a/src/components/shadow_control/shadow_control.js b/src/components/shadow_control/shadow_control.js
index 386a5756..a1d1012b 100644
--- a/src/components/shadow_control/shadow_control.js
+++ b/src/components/shadow_control/shadow_control.js
@@ -112,9 +112,11 @@ export default {
return hex2rgb(this.selected.color)
},
style () {
- return this.ready ? {
- boxShadow: getCssShadow(this.fallback)
- } : {}
+ return this.ready
+ ? {
+ boxShadow: getCssShadow(this.fallback)
+ }
+ : {}
}
}
}
diff --git a/src/components/shadow_control/shadow_control.vue b/src/components/shadow_control/shadow_control.vue
index f2fc7b99..669cac71 100644
--- a/src/components/shadow_control/shadow_control.vue
+++ b/src/components/shadow_control/shadow_control.vue
@@ -215,7 +215,7 @@
-
+
diff --git a/src/components/status_body/status_body.vue b/src/components/status_body/status_body.vue
index 976fe98c..fb356360 100644
--- a/src/components/status_body/status_body.vue
+++ b/src/components/status_body/status_body.vue
@@ -96,5 +96,5 @@
-
+
diff --git a/src/components/status_content/status_content.vue b/src/components/status_content/status_content.vue
index 9e7d7956..e2120f7a 100644
--- a/src/components/status_content/status_content.vue
+++ b/src/components/status_content/status_content.vue
@@ -56,7 +56,7 @@
-
+
diff --git a/src/components/status_popover/status_popover.js b/src/components/status_popover/status_popover.js
index e0962ccd..c55bd85b 100644
--- a/src/components/status_popover/status_popover.js
+++ b/src/components/status_popover/status_popover.js
@@ -38,6 +38,13 @@ const StatusPopover = {
.catch(e => (this.error = true))
}
}
+ },
+ watch: {
+ status (newStatus, oldStatus) {
+ if (newStatus !== oldStatus) {
+ this.$nextTick(() => this.$refs.popover.updateStyles())
+ }
+ }
}
}
diff --git a/src/components/status_popover/status_popover.vue b/src/components/status_popover/status_popover.vue
index fdca8c9c..f4ab357b 100644
--- a/src/components/status_popover/status_popover.vue
+++ b/src/components/status_popover/status_popover.vue
@@ -1,14 +1,16 @@
-
+
-
+
-
+
diff --git a/src/components/user_card/user_card.js b/src/components/user_card/user_card.js
index 4168c54a..37458ba2 100644
--- a/src/components/user_card/user_card.js
+++ b/src/components/user_card/user_card.js
@@ -5,6 +5,7 @@ import FollowButton from '../follow_button/follow_button.vue'
import ModerationTools from '../moderation_tools/moderation_tools.vue'
import AccountActions from '../account_actions/account_actions.vue'
import Select from '../select/select.vue'
+import UserLink from '../user_link/user_link.vue'
import RichContent from 'src/components/rich_content/rich_content.jsx'
import generateProfileLink from 'src/services/user_profile_link_generator/user_profile_link_generator'
import { mapGetters } from 'vuex'
@@ -14,7 +15,9 @@ import {
faRss,
faSearchPlus,
faExternalLinkAlt,
- faEdit
+ faEdit,
+ faTimes,
+ faExpandAlt
} from '@fortawesome/free-solid-svg-icons'
library.add(
@@ -22,12 +25,21 @@ library.add(
faBell,
faSearchPlus,
faExternalLinkAlt,
- faEdit
+ faEdit,
+ faTimes,
+ faExpandAlt
)
export default {
props: [
- 'userId', 'switcher', 'selected', 'hideBio', 'rounded', 'bordered', 'allowZoomingAvatar'
+ 'userId',
+ 'switcher',
+ 'selected',
+ 'hideBio',
+ 'rounded',
+ 'bordered',
+ 'avatarAction', // default - open profile, 'zoom' - zoom, function - call function
+ 'onClose'
],
data () {
return {
@@ -47,15 +59,16 @@ export default {
},
classes () {
return [{
- 'user-card-rounded-t': this.rounded === 'top', // set border-top-left-radius and border-top-right-radius
- 'user-card-rounded': this.rounded === true, // set border-radius for all sides
- 'user-card-bordered': this.bordered === true // set border for all sides
+ '-rounded-t': this.rounded === 'top', // set border-top-left-radius and border-top-right-radius
+ '-rounded': this.rounded === true, // set border-radius for all sides
+ '-bordered': this.bordered === true, // set border for all sides
+ '-popover': !!this.onClose // set popover rounding
}]
},
style () {
return {
backgroundImage: [
- `linear-gradient(to bottom, var(--profileTint), var(--profileTint))`,
+ 'linear-gradient(to bottom, var(--profileTint), var(--profileTint))',
`url(${this.user.cover_photo})`
].join(', ')
}
@@ -122,7 +135,8 @@ export default {
ProgressButton,
FollowButton,
Select,
- RichContent
+ RichContent,
+ UserLink
},
methods: {
muteUser () {
@@ -170,6 +184,12 @@ export default {
},
mentionUser () {
this.$store.dispatch('openPostStatusModal', { replyTo: true, repliedUser: this.user })
+ },
+ onAvatarClickHandler (e) {
+ if (this.onAvatarClick) {
+ e.preventDefault()
+ this.onAvatarClick()
+ }
}
}
}
diff --git a/src/components/user_card/user_card.scss b/src/components/user_card/user_card.scss
index 2e153120..a0bbc6a6 100644
--- a/src/components/user_card/user_card.scss
+++ b/src/components/user_card/user_card.scss
@@ -42,8 +42,10 @@
mask-composite: exclude;
background-size: cover;
mask-size: 100% 60%;
- border-top-left-radius: calc(var(--panelRadius) - 1px);
- border-top-right-radius: calc(var(--panelRadius) - 1px);
+ border-top-left-radius: calc(var(--__roundnessTop, --panelRadius) - 1px);
+ border-top-right-radius: calc(var(--__roundnessTop, --panelRadius) - 1px);
+ border-bottom-left-radius: calc(var(--__roundnessBottom, --panelRadius) - 1px);
+ border-bottom-right-radius: calc(var(--__roundnessBottom, --panelRadius) - 1px);
background-color: var(--profileBg);
z-index: -2;
@@ -72,21 +74,33 @@
}
}
- // Modifiers
-
- &-rounded-t {
+ &.-rounded-t {
border-top-left-radius: $fallback--panelRadius;
border-top-left-radius: var(--panelRadius, $fallback--panelRadius);
border-top-right-radius: $fallback--panelRadius;
border-top-right-radius: var(--panelRadius, $fallback--panelRadius);
+
+ --__roundnessTop: var(--panelRadius);
+ --__roundnessBottom: 0;
}
- &-rounded {
+ &.-rounded {
border-radius: $fallback--panelRadius;
border-radius: var(--panelRadius, $fallback--panelRadius);
+
+ --__roundnessTop: var(--panelRadius);
+ --__roundnessBottom: var(--panelRadius);
}
- &-bordered {
+ &.-popover {
+ border-radius: $fallback--tooltipRadius;
+ border-radius: var(--tooltipRadius, $fallback--tooltipRadius);
+
+ --__roundnessTop: var(--tooltipRadius);
+ --__roundnessBottom: var(--tooltipRadius);
+ }
+
+ &.-bordered {
border-width: 1px;
border-style: solid;
border-color: $fallback--border;
@@ -99,6 +113,15 @@
color: var(--lightText, $fallback--lightText);
padding: 0 26px;
+ a {
+ color: $fallback--lightText;
+ color: var(--lightText, $fallback--lightText);
+
+ &:hover {
+ color: var(--icon);
+ }
+ }
+
.container {
min-width: 0;
padding: 16px 0 6px;
@@ -110,23 +133,27 @@
min-width: 0;
}
+ > a {
+ vertical-align: middle;
+ display: flex;
+ }
+
.Avatar {
--_avatarShadowBox: var(--avatarShadow);
--_avatarShadowFilter: var(--avatarShadowFilter);
--_avatarShadowInset: var(--avatarShadowInset);
- flex: 1 0 100%;
width: 56px;
height: 56px;
object-fit: cover;
}
}
- &-avatar-link {
+ &-avatar {
position: relative;
cursor: pointer;
- &-overlay {
+ &.-overlay {
position: absolute;
left: 0;
top: 0;
@@ -146,7 +173,7 @@
}
}
- &:hover &-overlay {
+ &:hover &.-overlay {
opacity: 1;
}
}
@@ -206,8 +233,6 @@
flex: 0 1 auto;
text-overflow: ellipsis;
overflow: hidden;
- color: $fallback--lightText;
- color: var(--lightText, $fallback--lightText);
}
.dailyAvg {
diff --git a/src/components/user_card/user_card.vue b/src/components/user_card/user_card.vue
index 67837845..b84ff27d 100644
--- a/src/components/user_card/user_card.vue
+++ b/src/components/user_card/user_card.vue
@@ -8,25 +8,32 @@
:style="style"
class="background-image"
/>
-
+
-
+
+
-
+ >
+
+
+
+
+
+
+
+
-
- @{{ user.screen_name_ui }}
-
+ :user="user"
+ />
+
+ {{ at ? '@' : '' }}{{ user.screen_name_ui }}
+
+
+
+
diff --git a/src/components/user_list_menu/user_list_menu.js b/src/components/user_list_menu/user_list_menu.js
new file mode 100644
index 00000000..21996031
--- /dev/null
+++ b/src/components/user_list_menu/user_list_menu.js
@@ -0,0 +1,93 @@
+import { library } from '@fortawesome/fontawesome-svg-core'
+import { faChevronRight } from '@fortawesome/free-solid-svg-icons'
+import { mapState } from 'vuex'
+
+import DialogModal from '../dialog_modal/dialog_modal.vue'
+import Popover from '../popover/popover.vue'
+
+library.add(faChevronRight)
+
+const UserListMenu = {
+ props: [
+ 'user'
+ ],
+ data () {
+ return {}
+ },
+ components: {
+ DialogModal,
+ Popover
+ },
+ created () {
+ this.$store.dispatch('fetchUserInLists', this.user.id)
+ },
+ computed: {
+ ...mapState({
+ allLists: state => state.lists.allLists
+ }),
+ inListsSet () {
+ return new Set(this.user.inLists.map(x => x.id))
+ },
+ lists () {
+ if (!this.user.inLists) return []
+ return this.allLists.map(list => ({
+ ...list,
+ inList: this.inListsSet.has(list.id)
+ }))
+ }
+ },
+ methods: {
+ toggleList (listId) {
+ if (this.inListsSet.has(listId)) {
+ this.$store.dispatch('removeListAccount', { accountId: this.user.id, listId }).then((response) => {
+ if (!response.ok) { return }
+ this.$store.dispatch('fetchUserInLists', this.user.id)
+ })
+ } else {
+ this.$store.dispatch('addListAccount', { accountId: this.user.id, listId }).then((response) => {
+ if (!response.ok) { return }
+ this.$store.dispatch('fetchUserInLists', this.user.id)
+ })
+ }
+ },
+ toggleRight (right) {
+ const store = this.$store
+ if (this.user.rights[right]) {
+ store.state.api.backendInteractor.deleteRight({ user: this.user, right }).then(response => {
+ if (!response.ok) { return }
+ store.commit('updateRight', { user: this.user, right, value: false })
+ })
+ } else {
+ store.state.api.backendInteractor.addRight({ user: this.user, right }).then(response => {
+ if (!response.ok) { return }
+ store.commit('updateRight', { user: this.user, right, value: true })
+ })
+ }
+ },
+ toggleActivationStatus () {
+ this.$store.dispatch('toggleActivationStatus', { user: this.user })
+ },
+ deleteUserDialog (show) {
+ this.showDeleteUserDialog = show
+ },
+ deleteUser () {
+ const store = this.$store
+ const user = this.user
+ const { id, name } = user
+ store.state.api.backendInteractor.deleteUser({ user })
+ .then(e => {
+ this.$store.dispatch('markStatusesAsDeleted', status => user.id === status.user.id)
+ const isProfile = this.$route.name === 'external-user-profile' || this.$route.name === 'user-profile'
+ const isTargetUser = this.$route.params.name === name || this.$route.params.id === id
+ if (isProfile && isTargetUser) {
+ window.history.back()
+ }
+ })
+ },
+ setToggled (value) {
+ this.toggled = value
+ }
+ }
+}
+
+export default UserListMenu
diff --git a/src/components/user_list_menu/user_list_menu.vue b/src/components/user_list_menu/user_list_menu.vue
new file mode 100644
index 00000000..06947ab7
--- /dev/null
+++ b/src/components/user_list_menu/user_list_menu.vue
@@ -0,0 +1,38 @@
+
+
+
+
+
diff --git a/src/components/user_list_popover/user_list_popover.js b/src/components/user_list_popover/user_list_popover.js
index e24eb9f7..046e0abd 100644
--- a/src/components/user_list_popover/user_list_popover.js
+++ b/src/components/user_list_popover/user_list_popover.js
@@ -1,5 +1,6 @@
import { defineAsyncComponent } from 'vue'
import RichContent from 'src/components/rich_content/rich_content.jsx'
+import UnicodeDomainIndicator from '../unicode_domain_indicator/unicode_domain_indicator.vue'
import { library } from '@fortawesome/fontawesome-svg-core'
import { faCircleNotch } from '@fortawesome/free-solid-svg-icons'
@@ -15,6 +16,7 @@ const UserListPopover = {
],
components: {
RichContent,
+ UnicodeDomainIndicator,
Popover: defineAsyncComponent(() => import('../popover/popover.vue')),
UserAvatar: defineAsyncComponent(() => import('../user_avatar/user_avatar.vue'))
},
diff --git a/src/components/user_list_popover/user_list_popover.vue b/src/components/user_list_popover/user_list_popover.vue
index bdc3aa92..635dc7f6 100644
--- a/src/components/user_list_popover/user_list_popover.vue
+++ b/src/components/user_list_popover/user_list_popover.vue
@@ -4,10 +4,10 @@
placement="top"
:offset="{ y: 5 }"
>
-
+
-
+
- {{ user.screen_name_ui }}
+ {{ user.screen_name_ui }}
@@ -45,7 +45,7 @@
-
+
diff --git a/src/components/user_profile/user_profile.js b/src/components/user_profile/user_profile.js
index f779b823..08adaeab 100644
--- a/src/components/user_profile/user_profile.js
+++ b/src/components/user_profile/user_profile.js
@@ -45,7 +45,7 @@ const UserProfile = {
},
created () {
const routeParams = this.$route.params
- this.load(routeParams.name || routeParams.id)
+ this.load({ name: routeParams.name, id: routeParams.id })
this.tab = get(this.$route, 'query.tab', defaultTabKey)
},
unmounted () {
@@ -106,12 +106,17 @@ const UserProfile = {
this.userId = null
this.error = false
+ const maybeId = userNameOrId.id
+ const maybeName = userNameOrId.name
+
// Check if user data is already loaded in store
- const user = this.$store.getters.findUser(userNameOrId)
+ const user = maybeId ? this.$store.getters.findUser(maybeId) : this.$store.getters.findUserByName(maybeName)
if (user) {
loadById(user.id)
} else {
- this.$store.dispatch('fetchUser', userNameOrId)
+ (maybeId
+ ? this.$store.dispatch('fetchUser', maybeId)
+ : this.$store.dispatch('fetchUserByName', maybeName))
.then(({ id }) => loadById(id))
.catch((reason) => {
const errorMessage = get(reason, 'error.error')
@@ -150,12 +155,12 @@ const UserProfile = {
watch: {
'$route.params.id': function (newVal) {
if (newVal) {
- this.switchUser(newVal)
+ this.switchUser({ id: newVal })
}
},
'$route.params.name': function (newVal) {
if (newVal) {
- this.switchUser(newVal)
+ this.switchUser({ name: newVal })
}
},
'$route.query': function (newVal) {
diff --git a/src/components/user_profile/user_profile.vue b/src/components/user_profile/user_profile.vue
index b1a20269..d0da2b5b 100644
--- a/src/components/user_profile/user_profile.vue
+++ b/src/components/user_profile/user_profile.vue
@@ -8,7 +8,7 @@
:user-id="userId"
:switcher="true"
:selected="timeline.viewing"
- :allow-zooming-avatar="true"
+ avatar-action="zoom"
rounded="top"
/>
-
+
@@ -77,7 +77,7 @@
:disabled="!user.followers_count"
>
-
+
-
- {{ $t('user_reporting.title', [user.screen_name_ui]) }}
-
+
+
+
@@ -45,7 +49,7 @@
-
+
{
this.showWhoToFollow(reply)
})
diff --git a/src/components/who_to_follow_panel/who_to_follow_panel.js b/src/components/who_to_follow_panel/who_to_follow_panel.js
index 818e8bd5..f19ba948 100644
--- a/src/components/who_to_follow_panel/who_to_follow_panel.js
+++ b/src/components/who_to_follow_panel/who_to_follow_panel.js
@@ -6,9 +6,9 @@ function showWhoToFollow (panel, reply) {
const shuffled = shuffle(reply)
panel.usersToFollow.forEach((toFollow, index) => {
- let user = shuffled[index]
- let img = user.avatar || this.$store.state.instance.defaultAvatar
- let name = user.acct
+ const user = shuffled[index]
+ const img = user.avatar || this.$store.state.instance.defaultAvatar
+ const name = user.acct
toFollow.img = img
toFollow.name = name
@@ -24,12 +24,12 @@ function showWhoToFollow (panel, reply) {
}
function getWhoToFollow (panel) {
- var credentials = panel.$store.state.users.currentUser.credentials
+ const credentials = panel.$store.state.users.currentUser.credentials
if (credentials) {
panel.usersToFollow.forEach(toFollow => {
toFollow.name = 'Loading...'
})
- apiService.suggestions({ credentials: credentials })
+ apiService.suggestions({ credentials })
.then((reply) => {
showWhoToFollow(panel, reply)
})
diff --git a/src/components/who_to_follow_panel/who_to_follow_panel.vue b/src/components/who_to_follow_panel/who_to_follow_panel.vue
index 518acd97..c1ba6fb1 100644
--- a/src/components/who_to_follow_panel/who_to_follow_panel.vue
+++ b/src/components/who_to_follow_panel/who_to_follow_panel.vue
@@ -27,7 +27,7 @@
-
+