Compare commits

..

7 commits

456 changed files with 11628 additions and 15698 deletions

View file

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

View file

@ -5,9 +5,14 @@ module.exports = {
sourceType: 'module'
},
// https://github.com/feross/standard/blob/master/RULES.md#javascript-standard-style
extends: ['plugin:vue/recommended', 'plugin:prettier/recommended'],
extends: [
'plugin:vue/recommended'
],
// required to lint *.vue files
plugins: ['vue', 'import'],
plugins: [
'vue',
'import'
],
// add your custom rules here
rules: {
// allow paren-less arrow functions

View file

@ -1,49 +0,0 @@
name: "Bug report"
about: "Something isn't working as expected"
title: "[bug] "
body:
- type: markdown
attributes:
value: "Thanks for taking the time to file this bug report! Please try to be as specific and detailed as you can, so we can track down the issue and fix it as soon as possible."
- type: input
id: version
attributes:
label: "Version"
description: "Which version of pleroma-fe are you running? If running develop, specify the commit hash."
placeholder: "e.g. 2022.11, 40e86998e6"
- type: textarea
id: attempt
attributes:
label: "What were you trying to do?"
validations:
required: true
- type: textarea
id: expectation
attributes:
label: "What did you expect to happen?"
validations:
required: true
- type: textarea
id: reality
attributes:
label: "What actually happened?"
validations:
required: true
- type: dropdown
id: severity
attributes:
label: "Severity"
description: "Does this issue prevent you from using the software as normal?"
options:
- "I cannot use the software"
- "I cannot use it as easily as I'd like"
- "I can manage"
validations:
required: true
- type: checkboxes
id: searched
attributes:
label: "Have you searched for this issue?"
description: "Please double-check that your issue is not already being tracked on [the forums](https://meta.akkoma.dev) or [the issue tracker](https://akkoma.dev/AkkomaGang/pleroma-fe/issues)."
options:
- label: "I have double-checked and have not found this issue mentioned anywhere."

View file

@ -1,29 +0,0 @@
name: "Feature request"
about: "I'd like something to be added to pleroma-fe"
title: "[feat] "
body:
- type: markdown
attributes:
value: "Thanks for taking the time to request a new feature! Please be as concise and clear as you can in your proposal, so we could understand what you're going for."
- type: textarea
id: idea
attributes:
label: "The idea"
description: "What do you think you should be able to do in pleroma-fe?"
validations:
required: true
- type: textarea
id: reason
attributes:
label: "The reasoning"
description: "Why would this be a worthwhile feature? Does it solve any problems? Have people talked about wanting it?"
validations:
required: true
- type: checkboxes
id: searched
attributes:
label: "Have you searched for this feature request?"
description: "Please double-check that your issue is not already being tracked on [the forums](https://meta.akkoma.dev), [the issue tracker](https://akkoma.dev/AkkomaGang/pleroma-fe/issues), or the one for [the backend](https://akkoma.dev/AkkomaGang/akkoma/issues)."
options:
- label: "I have double-checked and have not found this feature request mentioned anywhere."
- label: "This feature is related to the pleroma-fe Akkoma frontend specifically, and not the backend."

1
.gitignore vendored
View file

@ -9,4 +9,3 @@ selenium-debug.log
config/local.json
config/local.*.json
docs/site/
.vscode/

View file

@ -1,6 +0,0 @@
{
"trailingComma": "none",
"singleQuote": true,
"semi": false,
"singleAttributePerLine": true
}

View file

@ -1,13 +1,19 @@
{
"extends": [
"stylelint-config-recommended-vue/scss",
"stylelint-rscss/config",
"stylelint-config-recommended",
"stylelint-config-standard"
],
"customSyntax": "postcss-scss",
"rules": {
"declaration-no-important": true,
"selector-class-pattern": null,
"custom-property-pattern": null
"rscss/no-descendant-combinator": false,
"rscss/class-format": [
true,
{
"component": "pascal-case",
"variant": "^-[a-z]\\w+",
"element": "^[a-z]\\w+"
}
]
}
}

View file

@ -7,7 +7,7 @@ pipeline:
commands:
- yarn
- yarn lint
#- yarn stylelint
- yarn stylelint
test:
when:

View file

@ -1,22 +1,22 @@
# Akkoma-FE
# Pleroma-FE
![English OK](https://img.shields.io/badge/English-OK-blueviolet) ![日本語OK](https://img.shields.io/badge/%E6%97%A5%E6%9C%AC%E8%AA%9E-OK-blueviolet)
This is a fork of Akkoma-FE from the Pleroma project, with support for new Akkoma features such as:
This is a fork of Pleroma-FE from the Pleroma project, with support for new Akkoma features such as:
- MFM support via [marked-mfm](https://akkoma.dev/sfr/marked-mfm)
- Custom emoji reactions
# For Translators
The [Weblate UI](https://translate.akkoma.dev/projects/akkoma/pleroma-fe/) is recommended for adding or modifying translations for Akkoma-FE.
The [Weblate UI](https://translate.akkoma.dev/projects/akkoma/pleroma-fe/) is recommended for adding or modifying translations for Pleroma-FE.
Alternatively, edit/create `src/i18n/$LANGUAGE_CODE.json` (where `$LANGUAGE_CODE` is the [ISO 639-1 code](https://en.wikipedia.org/wiki/List_of_ISO_639-1_codes) for your language), then add your language to [src/i18n/messages.js](https://akkoma.dev/AkkomaGang/pleroma-fe/src/branch/develop/src/i18n/messages.js) if it doesn't already exist there.
Akkoma-FE will set your language by your browser locale, but you can temporarily force it in the code by changing the locale in main.js.
Pleroma-FE will set your language by your browser locale, but you can temporarily force it in the code by changing the locale in main.js.
# FOR ADMINS
To use Akkoma-FE in Akkoma, use the [frontend](https://docs.akkoma.dev/stable/administration/CLI_tasks/frontend/) CLI task to install Akkoma-FE, then modify your configuration as described in the [Frontend Management](https://docs.akkoma.dev/stable/configuration/frontend_management/) doc.
To use Pleroma-FE in Akkoma, use the [frontend](https://docs.akkoma.dev/stable/administration/CLI_tasks/frontend/) CLI task to install Pleroma-FE, then modify your configuration as described in the [Frontend Management](https://docs.akkoma.dev/stable/configuration/frontend_management/) doc.
## Build Setup
@ -52,4 +52,4 @@ Edit config.json for configuration.
### Login methods
```loginMethod``` can be set to either ```password``` (the default) or ```token```, which will use the full oauth redirection flow, which is useful for SSO situations.
```loginMethod``` can be set to either ```password``` (the default) or ```token```, which will use the full oauth redirection flow, which is useful for SSO situations.

View file

@ -11,17 +11,14 @@ var webpackConfig = require('./webpack.prod.conf')
console.log(
' Tip:\n' +
' Built files are meant to be served over an HTTP server.\n' +
" Opening index.html over file:// won't work.\n"
' Built files are meant to be served over an HTTP server.\n' +
' Opening index.html over file:// won\'t work.\n'
)
var spinner = ora('building for production...')
spinner.start()
var assetsPath = path.join(
config.build.assetsRoot,
config.build.assetsSubDirectory
)
var assetsPath = path.join(config.build.assetsRoot, config.build.assetsSubDirectory)
rm('-rf', assetsPath)
mkdir('-p', assetsPath)
cp('-R', 'static/*', assetsPath)
@ -29,13 +26,11 @@ cp('-R', 'static/*', assetsPath)
webpack(webpackConfig, function (err, stats) {
spinner.stop()
if (err) throw err
process.stdout.write(
stats.toString({
colors: true,
modules: false,
children: false,
chunks: false,
chunkModules: false
}) + '\n'
)
process.stdout.write(stats.toString({
colors: true,
modules: false,
children: false,
chunks: false,
chunkModules: false
}) + '\n')
})

View file

@ -2,7 +2,8 @@ var semver = require('semver')
var chalk = require('chalk')
var packageConfig = require('../package.json')
var exec = function (cmd) {
return require('child_process').execSync(cmd).toString().trim()
return require('child_process')
.execSync(cmd).toString().trim()
}
var versionRequirements = [
@ -23,23 +24,16 @@ module.exports = function () {
for (var i = 0; i < versionRequirements.length; i++) {
var mod = versionRequirements[i]
if (!semver.satisfies(mod.currentVersion, mod.versionRequirement)) {
warnings.push(
mod.name +
': ' +
chalk.red(mod.currentVersion) +
' should be ' +
chalk.green(mod.versionRequirement)
warnings.push(mod.name + ': ' +
chalk.red(mod.currentVersion) + ' should be ' +
chalk.green(mod.versionRequirement)
)
}
}
if (warnings.length) {
console.log('')
console.log(
chalk.yellow(
'To use this template, you must update following to modules:'
)
)
console.log(chalk.yellow('To use this template, you must update following to modules:'))
console.log()
for (var i = 0; i < warnings.length; i++) {
var warning = warnings[i]

View file

@ -6,10 +6,9 @@ var express = require('express')
var webpack = require('webpack')
var opn = require('opn')
var proxyMiddleware = require('http-proxy-middleware')
var webpackConfig =
process.env.NODE_ENV === 'testing'
? require('./webpack.prod.conf')
: require('./webpack.dev.conf')
var webpackConfig = process.env.NODE_ENV === 'testing'
? require('./webpack.prod.conf')
: require('./webpack.dev.conf')
// default port where dev server listens for incoming traffic
var port = process.env.PORT || config.dev.port
@ -51,10 +50,7 @@ app.use(devMiddleware)
app.use(hotMiddleware)
// serve pure static assets
var staticPath = path.posix.join(
config.dev.assetsPublicPath,
config.dev.assetsSubDirectory
)
var staticPath = path.posix.join(config.dev.assetsPublicPath, config.dev.assetsSubDirectory)
app.use(staticPath, express.static('./static'))
module.exports = app.listen(port, function (err) {

View file

@ -4,8 +4,7 @@ var sass = require('sass')
var MiniCssExtractPlugin = require('mini-css-extract-plugin')
exports.assetsPath = function (_path) {
var assetsSubDirectory =
process.env.NODE_ENV === 'production'
var assetsSubDirectory = process.env.NODE_ENV === 'production'
? config.build.assetsSubDirectory
: config.dev.assetsSubDirectory
return path.posix.join(assetsSubDirectory, _path)
@ -14,7 +13,7 @@ exports.assetsPath = function (_path) {
exports.cssLoaders = function (options) {
options = options || {}
function generateLoaders(loaders) {
function generateLoaders (loaders) {
// Extract CSS when that option is specified
// (which is the case during production build)
if (options.extract) {
@ -28,11 +27,11 @@ exports.cssLoaders = function (options) {
return [
{
test: /\.(post)?css$/,
use: generateLoaders(['css-loader', 'postcss-loader'])
use: generateLoaders(['css-loader', 'postcss-loader']),
},
{
test: /\.less$/,
use: generateLoaders(['css-loader', 'postcss-loader', 'less-loader'])
use: generateLoaders(['css-loader', 'postcss-loader', 'less-loader']),
},
{
test: /\.sass$/,
@ -53,8 +52,8 @@ exports.cssLoaders = function (options) {
},
{
test: /\.styl(us)?$/,
use: generateLoaders(['css-loader', 'postcss-loader', 'stylus-loader'])
}
use: generateLoaders(['css-loader', 'postcss-loader', 'stylus-loader']),
},
]
}

View file

@ -2,13 +2,14 @@ var path = require('path')
var config = require('../config')
var utils = require('./utils')
var projectRoot = path.resolve(__dirname, '../')
const WorkboxPlugin = require('workbox-webpack-plugin');
var { VueLoaderPlugin } = require('vue-loader')
var env = process.env.NODE_ENV
// check env & config/index.js to decide weither to enable CSS Sourcemaps for the
// various preprocessor loaders added to vue-loader at the end of this file
var cssSourceMapDev = env === 'development' && config.dev.cssSourceMap
var cssSourceMapProd = env === 'production' && config.build.productionSourceMap
var cssSourceMapDev = (env === 'development' && config.dev.cssSourceMap)
var cssSourceMapProd = (env === 'production' && config.build.productionSourceMap)
var useCssSourceMap = cssSourceMapDev || cssSourceMapProd
var now = Date.now()
@ -18,12 +19,9 @@ module.exports = {
app: './src/main.js'
},
output: {
hashFunction: 'sha256', // Workaround for builds with OpenSSL 3.
hashFunction: "sha256", // Workaround for builds with OpenSSL 3.
path: config.build.assetsRoot,
publicPath:
process.env.NODE_ENV === 'production'
? config.build.assetsPublicPath
: config.dev.assetsPublicPath,
publicPath: process.env.NODE_ENV === 'production' ? config.build.assetsPublicPath : config.dev.assetsPublicPath,
filename: '[name].js'
},
optimization: {
@ -33,15 +31,17 @@ module.exports = {
},
resolve: {
extensions: ['.js', '.jsx', '.vue', '.mjs'],
modules: [path.join(__dirname, '../node_modules')],
modules: [
path.join(__dirname, '../node_modules')
],
fallback: {
url: require.resolve('url/')
"url": require.resolve("url/"),
},
alias: {
static: path.resolve(__dirname, '../static'),
src: path.resolve(__dirname, '../src'),
assets: path.resolve(__dirname, '../src/assets'),
components: path.resolve(__dirname, '../src/components'),
'static': path.resolve(__dirname, '../static'),
'src': path.resolve(__dirname, '../src'),
'assets': path.resolve(__dirname, '../src/assets'),
'components': path.resolve(__dirname, '../src/components'),
'vue-i18n': 'vue-i18n/dist/vue-i18n.runtime.esm-bundler.js'
}
},
@ -67,15 +67,14 @@ module.exports = {
test: /\.(json5?|ya?ml)$/, // target json, json5, yaml and yml files
type: 'javascript/auto',
loader: '@intlify/vue-i18n-loader',
include: [
// Use `Rule.include` to specify the files of locale messages to be pre-compiled
include: [ // Use `Rule.include` to specify the files of locale messages to be pre-compiled
path.resolve(__dirname, '../src/i18n')
]
},
{
test: /\.mjs$/,
include: /node_modules/,
type: 'javascript/auto'
test: /\.mjs$/,
include: /node_modules/,
type: "javascript/auto"
},
{
test: /\.vue$/,
@ -116,8 +115,15 @@ module.exports = {
name: utils.assetsPath('fonts/[name].[hash:7].[ext]')
}
}
}
},
]
},
plugins: [new VueLoaderPlugin()]
plugins: [
new WorkboxPlugin.InjectManifest({
swSrc: path.join(__dirname, '..', 'src/sw.js'),
swDest: 'sw-pleroma.js',
maximumFileSizeToCacheInBytes: 15 * 1024 * 1024,
}),
new VueLoaderPlugin()
]
}

View file

@ -7,9 +7,7 @@ var HtmlWebpackPlugin = require('html-webpack-plugin')
// add hot-reload related code to entry chunks
Object.keys(baseWebpackConfig.entry).forEach(function (name) {
baseWebpackConfig.entry[name] = ['./build/dev-client'].concat(
baseWebpackConfig.entry[name]
)
baseWebpackConfig.entry[name] = ['./build/dev-client'].concat(baseWebpackConfig.entry[name])
})
module.exports = merge(baseWebpackConfig, {
@ -22,10 +20,10 @@ module.exports = merge(baseWebpackConfig, {
plugins: [
new webpack.DefinePlugin({
'process.env': config.dev.env,
COMMIT_HASH: JSON.stringify('DEV'),
DEV_OVERRIDES: JSON.stringify(config.dev.settings),
__VUE_OPTIONS_API__: true,
__VUE_PROD_DEVTOOLS__: false
'COMMIT_HASH': JSON.stringify('DEV'),
'DEV_OVERRIDES': JSON.stringify(config.dev.settings),
'__VUE_OPTIONS_API__': true,
'__VUE_PROD_DEVTOOLS__': false
}),
// https://github.com/glenjamin/webpack-hot-middleware#installation--usage
new webpack.HotModuleReplacementPlugin(),

View file

@ -2,27 +2,22 @@ var path = require('path')
var config = require('../config')
var utils = require('./utils')
var webpack = require('webpack')
const WorkboxPlugin = require('workbox-webpack-plugin')
var { merge } = require('webpack-merge')
var baseWebpackConfig = require('./webpack.base.conf')
var MiniCssExtractPlugin = require('mini-css-extract-plugin')
var HtmlWebpackPlugin = require('html-webpack-plugin')
var env =
process.env.NODE_ENV === 'testing'
var env = process.env.NODE_ENV === 'testing'
? require('../config/test.env')
: config.build.env
let commitHash = require('child_process')
.execSync('git rev-parse --short HEAD')
.toString()
.execSync('git rev-parse --short HEAD')
.toString();
var webpackConfig = merge(baseWebpackConfig, {
mode: 'production',
module: {
rules: utils.styleLoaders({
sourceMap: config.dev.cssSourceMap,
extract: true
})
rules: utils.styleLoaders({ sourceMap: config.dev.cssSourceMap, extract: true })
},
devtool: 'source-map',
optimization: {
@ -37,18 +32,13 @@ var webpackConfig = merge(baseWebpackConfig, {
chunkFilename: utils.assetsPath('js/[name].[chunkhash].js')
},
plugins: [
new WorkboxPlugin.InjectManifest({
swSrc: path.join(__dirname, '..', 'src/sw.js'),
swDest: 'sw-pleroma.js',
maximumFileSizeToCacheInBytes: 15 * 1024 * 1024
}),
// http://vuejs.github.io/vue-loader/workflow/production.html
new webpack.DefinePlugin({
'process.env': env,
COMMIT_HASH: JSON.stringify(commitHash),
DEV_OVERRIDES: JSON.stringify(undefined),
__VUE_OPTIONS_API__: true,
__VUE_PROD_DEVTOOLS__: false
'COMMIT_HASH': JSON.stringify(commitHash),
'DEV_OVERRIDES': JSON.stringify(undefined),
'__VUE_OPTIONS_API__': true,
'__VUE_PROD_DEVTOOLS__': false
}),
// extract css into its own file
new MiniCssExtractPlugin({
@ -58,8 +48,9 @@ var webpackConfig = merge(baseWebpackConfig, {
// you can customize output by editing /index.html
// see https://github.com/ampedandwired/html-webpack-plugin
new HtmlWebpackPlugin({
filename:
process.env.NODE_ENV === 'testing' ? 'index.html' : config.build.index,
filename: process.env.NODE_ENV === 'testing'
? 'index.html'
: config.build.index,
template: 'index.html',
inject: true,
minify: {
@ -72,7 +63,7 @@ var webpackConfig = merge(baseWebpackConfig, {
},
// necessary to consistently work with multiple chunks via CommonsChunkPlugin
chunksSortMode: 'auto'
})
}),
// split vendor js into its own file
// extract webpack runtime and module manifest to its own file in order to
// prevent vendor hash from being updated whenever app bundle is updated
@ -90,7 +81,9 @@ if (config.build.productionGzip) {
asset: '[path].gz[query]',
algorithm: 'gzip',
test: new RegExp(
'\\.(' + config.build.productionGzipExtensions.join('|') + ')$'
'\\.(' +
config.build.productionGzipExtensions.join('|') +
')$'
),
threshold: 10240,
minRatio: 0.8

View file

@ -38,11 +38,6 @@ module.exports = {
assetsSubDirectory: 'static',
assetsPublicPath: '/',
proxyTable: {
'/manifest.json': {
target,
changeOrigin: true,
cookieDomainRewrite: 'localhost'
},
'/api': {
target,
changeOrigin: true,
@ -59,7 +54,7 @@ module.exports = {
cookieDomainRewrite: 'localhost',
ws: true,
headers: {
Origin: target
'Origin': target
}
},
'/oauth/revoke': {
@ -76,7 +71,7 @@ module.exports = {
target,
changeOrigin: true,
cookieDomainRewrite: 'localhost'
}
},
},
// CSS Sourcemaps off by default because relative paths are "buggy"
// with this option, according to the CSS-Loader README

View file

@ -1,25 +1,21 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1,user-scalable=no" />
<title>Akkoma</title>
<link rel="stylesheet" href="/static/font/tiresias.css">
<link rel="stylesheet" href="/static/font/css/lato.css">
<link rel="stylesheet" href="/static/mfm.css">
<link rel="stylesheet" href="/static/custom.css">
<link rel="stylesheet" href="/static/theme-holder.css" id="theme-holder">
<!--server-generated-meta-->
<link rel="icon" type="image/png" href="/favicon.svg" />
<link rel="manifest" href="/manifest.json" />
</head>
<body class="hidden">
<noscript>To use Akkoma, please enable JavaScript.</noscript>
<div id="app"></div>
<div id="modal"></div>
<!-- built files will be auto injected -->
</body>
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1,user-scalable=no">
<title>Akkoma</title>
<link rel="stylesheet" href="/static/font/css/fontello.css">
<link rel="stylesheet" href="/static/font/css/animation.css">
<link rel="stylesheet" href="/static/font/tiresias.css">
<link rel="stylesheet" href="/static/font/css/lato.css">
<link rel="stylesheet" href="/static/mfm.css">
<!--server-generated-meta-->
<link rel="icon" type="image/png" href="/favicon.png">
</head>
<body class="hidden">
<noscript>To use Akkoma, please enable JavaScript.</noscript>
<div id="app"></div>
<div id="modal"></div>
<!-- built files will be auto injected -->
</body>
</html>

View file

@ -1,6 +1,6 @@
{
"name": "pleroma_fe",
"version": "3.5.0",
"version": "3.2.0",
"description": "A frontend for Akkoma instances",
"author": "Roger Braun <roger@rogerbraun.net>",
"private": true,
@ -11,30 +11,30 @@
"unit:watch": "karma start test/unit/karma.conf.js --single-run=false",
"e2e": "node test/e2e/runner.js",
"test": "npm run unit && npm run e2e",
"stylelint": "stylelint src/**/*.scss",
"stylelint": "npx stylelint src/components/status/status.scss",
"lint": "eslint --ext .js,.vue src test/unit/specs test/e2e/specs",
"lint-fix": "eslint --fix --ext .js,.vue src test/unit/specs test/e2e/specs"
},
"dependencies": {
"@babel/runtime": "7.17.8",
"@chenfengyuan/vue-qrcode": "2.0.0",
"@floatingghost/pinch-zoom-element": "^1.3.1",
"@fortawesome/fontawesome-svg-core": "1.3.0",
"@fortawesome/free-regular-svg-icons": "^6.1.2",
"@fortawesome/free-solid-svg-icons": "^6.2.0",
"@fortawesome/vue-fontawesome": "3.0.1",
"@vuelidate/core": "^2.0.0",
"@vuelidate/validators": "^2.0.0",
"blurhash": "^2.0.4",
"@kazvmoe-infra/pinch-zoom-element": "1.2.0",
"@vuelidate/core": "2.0.0-alpha.42",
"@vuelidate/validators": "2.0.0-alpha.30",
"body-scroll-lock": "2.7.1",
"chromatism": "3.0.0",
"click-outside-vue3": "4.0.1",
"cropperjs": "1.5.12",
"diff": "3.5.0",
"escape-html": "1.0.3",
"iso-639-1": "^2.1.15",
"js-cookie": "^3.0.1",
"localforage": "1.10.0",
"marked": "^4.2.2",
"marked-mfm": "^0.5.0",
"parse-link-header": "^2.0.0",
"phoenix": "1.6.2",
"punycode.js": "2.1.0",
@ -58,7 +58,7 @@
"@vue/babel-plugin-jsx": "1.1.1",
"@vue/compiler-sfc": "^3.1.0",
"@vue/test-utils": "^2.0.2",
"autoprefixer": "^10.4.13",
"autoprefixer": "6.7.7",
"babel-loader": "^9.1.0",
"babel-plugin-lodash": "3.3.4",
"chai": "^4.3.7",
@ -69,13 +69,11 @@
"css-loader": "^6.7.2",
"custom-event-polyfill": "^1.0.7",
"eslint": "^7.32.0",
"eslint-config-prettier": "^8.5.0",
"eslint-config-standard": "^17.0.0",
"eslint-friendly-formatter": "^4.0.1",
"eslint-loader": "^4.0.2",
"eslint-plugin-import": "^2.26.0",
"eslint-plugin-node": "^11.1.0",
"eslint-plugin-prettier": "^4.2.1",
"eslint-plugin-promise": "^6.1.1",
"eslint-plugin-standard": "^5.0.0",
"eslint-plugin-vue": "^9.7.0",
@ -86,6 +84,7 @@
"html-webpack-plugin": "^5.5.0",
"http-proxy-middleware": "0.21.0",
"inject-loader": "2.0.1",
"iso-639-1": "2.1.15",
"isparta-loader": "2.0.0",
"json-loader": "0.5.7",
"karma": "6.3.17",
@ -104,11 +103,7 @@
"nightwatch": "0.9.21",
"opn": "4.0.2",
"ora": "0.4.1",
"postcss": "^8.4.19",
"postcss-html": "^1.5.0",
"postcss-loader": "^7.0.2",
"postcss-sass": "^0.5.0",
"prettier": "2.8.1",
"postcss-loader": "3.0.0",
"raw-loader": "0.5.1",
"sass": "^1.56.0",
"sass-loader": "^13.2.0",
@ -117,11 +112,9 @@
"shelljs": "0.8.5",
"sinon": "2.4.1",
"sinon-chai": "2.14.0",
"stylelint": "^14.15.0",
"stylelint-config-recommended-vue": "^1.4.0",
"stylelint-config-standard": "^29.0.0",
"stylelint-config-standard-scss": "^6.1.0",
"stylelint-rscss": "^0.4.0",
"stylelint": "13.6.1",
"stylelint-config-standard": "20.0.0",
"stylelint-rscss": "0.4.0",
"url-loader": "^4.1.1",
"vue-loader": "^17.0.0",
"vue-style-loader": "^4.1.2",

View file

@ -1,3 +1,5 @@
module.exports = {
plugins: [require('autoprefixer')]
plugins: [
require('autoprefixer')
]
}

View file

@ -1,4 +1,6 @@
{
"$schema": "https://docs.renovatebot.com/renovate-schema.json",
"extends": ["config:base"]
"extends": [
"config:base"
]
}

View file

@ -24,9 +24,7 @@ export default {
components: {
UserPanel,
NavPanel,
Notifications: defineAsyncComponent(() =>
import('./components/notifications/notifications.vue')
),
Notifications: defineAsyncComponent(() => import('./components/notifications/notifications.vue')),
InstanceSpecificPanel,
FeaturesPanel,
WhoToFollowPanel,
@ -46,20 +44,17 @@ export default {
data: () => ({
mobileActivePanel: 'timeline'
}),
created() {
created () {
// Load the locale from the storage
const val = this.$store.getters.mergedConfig.interfaceLanguage
this.$store.dispatch('setOption', {
name: 'interfaceLanguage',
value: val
})
this.$store.dispatch('setOption', { name: 'interfaceLanguage', value: val })
window.addEventListener('resize', this.updateMobileState)
},
unmounted() {
unmounted () {
window.removeEventListener('resize', this.updateMobileState)
},
computed: {
classes() {
classes () {
return [
{
'-reverse': this.reverseLayout,
@ -69,76 +64,48 @@ export default {
'-' + this.layoutType
]
},
currentUser() {
return this.$store.state.users.currentUser
},
userBackground() {
return this.currentUser.background_image
},
instanceBackground() {
currentUser () { return this.$store.state.users.currentUser },
userBackground () { return this.currentUser.background_image },
instanceBackground () {
return this.mergedConfig.hideInstanceWallpaper
? null
: this.$store.state.instance.background
},
background() {
return this.userBackground || this.instanceBackground
},
bgStyle() {
background () { return this.userBackground || this.instanceBackground },
bgStyle () {
if (this.background) {
return {
'--body-background-image': `url(${this.background})`
}
}
},
suggestionsEnabled() {
return this.$store.state.instance.suggestionsEnabled
},
showInstanceSpecificPanel() {
return (
this.$store.state.instance.showInstanceSpecificPanel &&
suggestionsEnabled () { return this.$store.state.instance.suggestionsEnabled },
showInstanceSpecificPanel () {
return this.$store.state.instance.showInstanceSpecificPanel &&
!this.$store.getters.mergedConfig.hideISP &&
this.$store.state.instance.instanceSpecificPanelContent
)
},
newPostButtonShown() {
return (
this.$store.getters.mergedConfig.alwaysShowNewPostButton ||
this.layoutType === 'mobile'
)
newPostButtonShown () {
return this.$store.getters.mergedConfig.alwaysShowNewPostButton || this.layoutType === 'mobile'
},
showFeaturesPanel() {
return this.$store.state.instance.showFeaturesPanel
},
editingAvailable() {
return this.$store.state.instance.editingAvailable
},
layoutType() {
return this.$store.state.interface.layoutType
},
privateMode() {
return this.$store.state.instance.private
},
reverseLayout() {
const { thirdColumnMode, sidebarRight: reverseSetting } =
this.$store.getters.mergedConfig
showFeaturesPanel () { return this.$store.state.instance.showFeaturesPanel },
editingAvailable () { return this.$store.state.instance.editingAvailable },
layoutType () { return this.$store.state.interface.layoutType },
privateMode () { return this.$store.state.instance.private },
reverseLayout () {
const { thirdColumnMode, sidebarRight: reverseSetting } = this.$store.getters.mergedConfig
if (this.layoutType !== 'wide') {
return reverseSetting
} else {
return thirdColumnMode === 'notifications'
? reverseSetting
: !reverseSetting
return thirdColumnMode === 'notifications' ? reverseSetting : !reverseSetting
}
},
noSticky() {
return this.$store.getters.mergedConfig.disableStickyHeaders
},
showScrollbars() {
return this.$store.getters.mergedConfig.showScrollbars
},
noSticky () { return this.$store.getters.mergedConfig.disableStickyHeaders },
showScrollbars () { return this.$store.getters.mergedConfig.showScrollbars },
...mapGetters(['mergedConfig'])
},
methods: {
updateMobileState() {
updateMobileState () {
this.$store.dispatch('setLayoutWidth', windowWidth())
this.$store.dispatch('setLayoutHeight', windowHeight())
}

View file

@ -1,7 +1,6 @@
// stylelint-disable rscss/class-format
@import './_variables.scss';
@import '@fortawesome/fontawesome-svg-core/styles.css';
@import '@floatingghost/pinch-zoom-element/dist/pinch-zoom.css';
:root {
--navbar-height: 3.5rem;
--post-line-height: 1.4;
@ -13,8 +12,8 @@ html {
}
body {
font-family: $system-sans-serif;
font-family: var(--interfaceFont, $system-sans-serif);
font-family: sans-serif;
font-family: var(--interfaceFont, sans-serif);
margin: 0;
color: $fallback--text;
color: var(--text, $fallback--text);
@ -23,13 +22,84 @@ body {
overscroll-behavior-y: none;
overflow-x: clip;
overflow-y: scroll;
background: var(--bg);
&.hidden {
display: none;
}
}
// ## Custom scrollbars
// Only show custom scrollbars on devices which
// have a cursor/pointer to operate them
@media (any-pointer: fine) {
* {
scrollbar-color: var(--btn) transparent;
&::-webkit-scrollbar {
background: transparent;
}
&::-webkit-scrollbar-button,
&::-webkit-scrollbar-thumb {
background-color: var(--btn);
box-shadow: var(--buttonShadow);
border-radius: var(--btnRadius);
}
// horizontal/vertical/increment/decrement are webkit-specific stuff
// that indicates whether we're affecting vertical scrollbar, increase button etc
// stylelint-disable selector-pseudo-class-no-unknown
&::-webkit-scrollbar-button {
--___bgPadding: 2px;
color: var(--btnText);
background-repeat: no-repeat, no-repeat;
&:horizontal {
background-size: 50% calc(50% - var(--___bgPadding)), 50% calc(50% - var(--___bgPadding));
&:increment {
background-image:
linear-gradient(45deg, var(--btnText) 50%, transparent 51%),
linear-gradient(-45deg, transparent 50%, var(--btnText) 51%);
background-position: top var(--___bgPadding) left 50%, right 50% bottom var(--___bgPadding);
}
&:decrement {
background-image:
linear-gradient(45deg, transparent 50%, var(--btnText) 51%),
linear-gradient(-45deg, var(--btnText) 50%, transparent 51%);
background-position: bottom var(--___bgPadding) right 50%, left 50% top var(--___bgPadding);
}
}
&:vertical {
background-size: calc(50% - var(--___bgPadding)) 50%, calc(50% - var(--___bgPadding)) 50%;
&:increment {
background-image:
linear-gradient(-45deg, transparent 50%, var(--btnText) 51%),
linear-gradient(45deg, transparent 50%, var(--btnText) 51%);
background-position: right var(--___bgPadding) top 50%, left var(--___bgPadding) top 50%;
}
&:decrement {
background-image:
linear-gradient(-45deg, var(--btnText) 50%, transparent 51%),
linear-gradient(45deg, var(--btnText) 50%, transparent 51%);
background-position: left var(--___bgPadding) top 50%, right var(--___bgPadding) top 50%;
}
}
}
// stylelint-enable selector-pseudo-class-no-unknown
}
// Body should have background to scrollbar otherwise it will use white (body color?)
html {
scrollbar-color: var(--selectedMenu) var(--wallpaper);
background: var(--wallpaper);
}
}
a {
text-decoration: none;
color: $fallback--link;
@ -40,7 +110,7 @@ h4 {
margin: 0;
}
i[class*='icon-'],
i[class*=icon-],
.svg-inline--fa {
color: $fallback--icon;
color: var(--icon, $fallback--icon);
@ -58,7 +128,6 @@ nav {
box-sizing: border-box;
height: var(--navbar-height);
position: fixed;
backdrop-filter: blur(12px) saturate(1.2);
}
#sidebar {
@ -113,7 +182,7 @@ nav {
position: relative;
display: grid;
grid-template-columns: var(--miniColumn) var(--maxiColumn);
grid-template-areas: 'sidebar content';
grid-template-areas: "sidebar content";
grid-template-rows: 1fr;
box-sizing: border-box;
margin: 0 auto;
@ -122,7 +191,6 @@ nav {
justify-content: center;
min-height: 100vh;
overflow-x: clip;
padding: 0 calc(var(--columnGap) / 2);
.column {
--___columnMargin: var(--columnGap);
@ -160,9 +228,7 @@ nav {
overflow-y: auto;
overflow-x: hidden;
margin-left: calc(var(--___paddingIncrease) * -1);
padding-left: calc(
var(--___paddingIncrease) + var(--___columnMargin) / 2
);
padding-left: calc(var(--___paddingIncrease) + var(--___columnMargin) / 2);
// On browsers that don't support hiding scrollbars we enforce "show scrolbars" mode
// might implement old style of hiding scrollbars later if there's demand
@ -170,9 +236,7 @@ nav {
&:not(.-show-scrollbar) {
scrollbar-width: none;
margin-right: calc(var(--___paddingIncrease) * -1);
padding-right: calc(
var(--___paddingIncrease) + var(--___columnMargin) / 2
);
padding-right: calc(var(--___paddingIncrease) + var(--___columnMargin) / 2);
&::-webkit-scrollbar {
display: block;
@ -212,21 +276,21 @@ nav {
&.-reverse:not(.-wide):not(.-mobile) {
grid-template-columns: var(--maxiColumn) var(--miniColumn);
grid-template-areas: 'content sidebar';
grid-template-areas: "content sidebar";
}
&.-wide {
grid-template-columns: var(--miniColumn) var(--maxiColumn) var(--miniColumn);
grid-template-areas: 'sidebar content notifs';
grid-template-areas: "sidebar content notifs";
&.-reverse {
grid-template-areas: 'notifs content sidebar';
grid-template-areas: "notifs content sidebar";
}
}
&.-mobile {
grid-template-columns: 100vw;
grid-template-areas: 'content';
grid-template-areas: "content";
padding: 0;
.column {
@ -283,7 +347,7 @@ nav {
background: transparent;
}
i[class*='icon-'],
i[class*=icon-],
.svg-inline--fa {
color: $fallback--text;
color: var(--btnText, $fallback--text);
@ -299,9 +363,7 @@ nav {
}
&:active {
box-shadow: 0 0 4px 0 rgba(255, 255, 255, 0.3),
0 1px 0 0 rgba(0, 0, 0, 0.2) inset,
0 -1px 0 0 rgba(255, 255, 255, 0.2) inset;
box-shadow: 0 0 4px 0 rgba(255, 255, 255, 0.3), 0 1px 0 0 rgba(0, 0, 0, 0.2) inset, 0 -1px 0 0 rgba(255, 255, 255, 0.2) inset;
box-shadow: var(--buttonPressedShadow);
color: $fallback--text;
color: var(--btnPressedText, $fallback--text);
@ -334,9 +396,7 @@ nav {
color: var(--btnToggledText, $fallback--text);
background-color: $fallback--fg;
background-color: var(--btnToggled, $fallback--fg);
box-shadow: 0 0 4px 0 rgba(255, 255, 255, 0.3),
0 1px 0 0 rgba(0, 0, 0, 0.2) inset,
0 -1px 0 0 rgba(255, 255, 255, 0.2) inset;
box-shadow: 0 0 4px 0 rgba(255, 255, 255, 0.3), 0 1px 0 0 rgba(0, 0, 0, 0.2) inset, 0 -1px 0 0 rgba(255, 255, 255, 0.2) inset;
box-shadow: var(--buttonPressedShadow);
svg,
@ -401,15 +461,14 @@ textarea,
border: none;
border-radius: $fallback--inputRadius;
border-radius: var(--inputRadius, $fallback--inputRadius);
box-shadow: 0 1px 0 0 rgba(0, 0, 0, 0.2) inset,
0 -1px 0 0 rgba(255, 255, 255, 0.2) inset, 0 0 2px 0 rgba(0, 0, 0, 1) inset;
box-shadow: 0 1px 0 0 rgba(0, 0, 0, 0.2) inset, 0 -1px 0 0 rgba(255, 255, 255, 0.2) inset, 0 0 2px 0 rgba(0, 0, 0, 1) inset;
box-shadow: var(--inputShadow);
background-color: $fallback--fg;
background-color: var(--input, $fallback--fg);
color: $fallback--lightText;
color: var(--inputText, $fallback--lightText);
font-family: sans-serif;
font-family: var(--interfaceFont, sans-serif);
font-family: var(--inputFont, sans-serif);
font-size: 1em;
margin: 0;
box-sizing: border-box;
@ -420,13 +479,13 @@ textarea,
padding: 0 var(--_padding);
&:disabled,
&[disabled='disabled'],
&[disabled=disabled],
&.disabled {
cursor: not-allowed;
opacity: 0.5;
}
&[type='range'] {
&[type=range] {
background: none;
border: none;
margin: 0;
@ -434,13 +493,12 @@ textarea,
flex: 1;
}
&[type='radio'] {
&[type=radio] {
display: none;
&:checked + label::before {
box-shadow: 0 0 2px black inset, 0 0 0 4px $fallback--fg inset;
box-shadow: 0 0 0 1px var(--icon, $fallback--icon) inset,
0 0 0 4px var(--fg, $fallback--fg) inset;
box-shadow: var(--inputShadow), 0 0 0 4px var(--fg, $fallback--fg) inset;
background-color: var(--accent, $fallback--link);
}
@ -461,7 +519,7 @@ textarea,
height: 1.1em;
border-radius: 100%; // Radio buttons should always be circle
box-shadow: 0 0 2px black inset;
box-shadow: 0 0 0 1px var(--icon, $fallback--icon) inset;
box-shadow: var(--inputShadow);
margin-right: 0.5em;
background-color: $fallback--fg;
background-color: var(--input, $fallback--fg);
@ -475,7 +533,7 @@ textarea,
}
}
&[type='checkbox'] {
&[type=checkbox] {
display: none;
&:checked + label::before {
@ -501,7 +559,7 @@ textarea,
border-radius: $fallback--checkboxRadius;
border-radius: var(--checkboxRadius, $fallback--checkboxRadius);
box-shadow: 0 0 2px black inset;
box-shadow: 0 0 0 1px var(--icon, $fallback--icon) inset;
box-shadow: var(--inputShadow);
margin-right: 0.5em;
background-color: $fallback--fg;
background-color: var(--input, $fallback--fg);
@ -536,8 +594,8 @@ option {
.hide-number-spinner {
-moz-appearance: textfield;
&[type='number']::-webkit-inner-spin-button,
&[type='number']::-webkit-outer-spin-button {
&[type=number]::-webkit-inner-spin-button,
&[type=number]::-webkit-outer-spin-button {
opacity: 0;
display: none;
}

View file

@ -43,7 +43,7 @@
:to="{ name: 'login' }"
class="panel-body"
>
{{ $t('login.hint') }}
{{ $t("login.hint") }}
</router-link>
</div>
<router-view />

View file

@ -4,7 +4,7 @@ $darkened-background: whitesmoke;
$fallback--bg: #121a24;
$fallback--fg: #182230;
$fallback--faint: rgba(185, 185, 186, 0.5);
$fallback--faint: rgba(185, 185, 186, .5);
$fallback--text: #b9b9ba;
$fallback--link: #d8a070;
$fallback--icon: #666;
@ -16,8 +16,8 @@ $fallback--cBlue: #0095ff;
$fallback--cGreen: #0fa00f;
$fallback--cOrange: orange;
$fallback--alertError: rgba(211, 16, 20, 0.5);
$fallback--alertWarning: rgba(111, 111, 20, 0.5);
$fallback--alertError: rgba(211,16,20,.5);
$fallback--alertWarning: rgba(111,111,20,.5);
$fallback--panelRadius: 10px;
$fallback--checkboxRadius: 2px;
@ -28,14 +28,6 @@ $fallback--avatarRadius: 4px;
$fallback--avatarAltRadius: 10px;
$fallback--attachmentRadius: 10px;
$fallback--buttonShadow: 0px 0px 2px 0px rgba(0, 0, 0, 1),
0px 1px 0px 0px rgba(255, 255, 255, 0.2) inset,
0px -1px 0px 0px rgba(0, 0, 0, 0.2) inset;
$fallback--buttonShadow: 0px 0px 2px 0px rgba(0, 0, 0, 1), 0px 1px 0px 0px rgba(255, 255, 255, 0.2) inset, 0px -1px 0px 0px rgba(0, 0, 0, 0.2) inset;
$status-margin: 0.75em;
$system-sans-serif: -apple-system, BlinkMacSystemFont, avenir next, avenir,
segoe ui, helvetica neue, helvetica, Cantarell, Ubuntu, roboto, noto, arial,
sans-serif;
$system-mono: Menlo, Consolas, Monaco, Liberation Mono, Lucida Console,
monospace;

View file

@ -3,21 +3,13 @@ import { createApp } from 'vue'
import { createRouter, createWebHistory } from 'vue-router'
import vClickOutside from 'click-outside-vue3'
import {
FontAwesomeIcon,
FontAwesomeLayers
} from '@fortawesome/vue-fontawesome'
import { config } from '@fortawesome/fontawesome-svg-core'
config.autoAddCss = false
import { FontAwesomeIcon, FontAwesomeLayers } from '@fortawesome/vue-fontawesome'
import App from '../App.vue'
import routes from './routes'
import VBodyScrollLock from 'src/directives/body_scroll_lock'
import {
windowWidth,
windowHeight
} from '../services/window_utils/window_utils'
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'
@ -31,9 +23,7 @@ const parsedInitialResults = () => {
return null
}
if (!staticInitialResults) {
staticInitialResults = JSON.parse(
document.getElementById('initial-results').textContent
)
staticInitialResults = JSON.parse(document.getElementById('initial-results').textContent)
}
return staticInitialResults
}
@ -81,30 +71,18 @@ const getInstanceConfig = async ({ store }) => {
const textlimit = data.max_toot_chars
const vapidPublicKey = data.pleroma.vapid_public_key
store.dispatch('setInstanceOption', {
name: 'textlimit',
value: textlimit
})
store.dispatch('setInstanceOption', {
name: 'accountApprovalRequired',
value: data.approval_required
})
store.dispatch('setInstanceOption', { name: 'textlimit', value: textlimit })
store.dispatch('setInstanceOption', { name: 'accountApprovalRequired', value: data.approval_required })
// don't override cookie if set
if (!Cookies.get('userLanguage')) {
store.dispatch('setOption', {
name: 'interfaceLanguage',
value: resolveLanguage(data.languages)
})
store.dispatch('setOption', { name: 'interfaceLanguage', value: resolveLanguage(data.languages) })
}
if (vapidPublicKey) {
store.dispatch('setInstanceOption', {
name: 'vapidPublicKey',
value: vapidPublicKey
})
store.dispatch('setInstanceOption', { name: 'vapidPublicKey', value: vapidPublicKey })
}
} else {
throw res
throw (res)
}
} catch (error) {
console.error('Could not load instance config, potentially fatal')
@ -119,12 +97,10 @@ const getBackendProvidedConfig = async ({ store }) => {
const data = await res.json()
return data.pleroma_fe
} else {
throw res
throw (res)
}
} catch (error) {
console.error(
'Could not load backend-provided frontend config, potentially fatal'
)
console.error('Could not load backend-provided frontend config, potentially fatal')
console.error(error)
}
}
@ -135,7 +111,7 @@ const getStaticConfig = async () => {
if (res.ok) {
return res.json()
} else {
throw res
throw (res)
}
} catch (error) {
console.warn('Failed to load static/config.json, continuing without it.')
@ -174,16 +150,19 @@ const setSettings = async ({ apiConfig, staticConfig, store }) => {
copyInstanceOption('showPanelNavShortcuts')
copyInstanceOption('stopGifs')
copyInstanceOption('logo')
copyInstanceOption('conversationDisplay')
store.dispatch('setInstanceOption', {
name: 'logoMask',
value: typeof config.logoMask === 'undefined' ? true : config.logoMask
value: typeof config.logoMask === 'undefined'
? true
: config.logoMask
})
store.dispatch('setInstanceOption', {
name: 'logoMargin',
value: typeof config.logoMargin === 'undefined' ? 0 : config.logoMargin
value: typeof config.logoMargin === 'undefined'
? 0
: config.logoMargin
})
copyInstanceOption('logoLeft')
store.commit('authFlow/setInitialStrategy', config.loginMethod)
@ -211,7 +190,7 @@ const getTOS = async ({ store }) => {
const html = await res.text()
store.dispatch('setInstanceOption', { name: 'tos', value: html })
} else {
throw res
throw (res)
}
} catch (e) {
console.warn("Can't load TOS")
@ -224,12 +203,9 @@ const getInstancePanel = async ({ store }) => {
const res = await preloadFetch('/instance/panel.html')
if (res.ok) {
const html = await res.text()
store.dispatch('setInstanceOption', {
name: 'instanceSpecificPanelContent',
value: html
})
store.dispatch('setInstanceOption', { name: 'instanceSpecificPanelContent', value: html })
} else {
throw res
throw (res)
}
} catch (e) {
console.warn("Can't load instance panel")
@ -242,30 +218,25 @@ const getStickers = async ({ store }) => {
const res = await window.fetch('/static/stickers.json')
if (res.ok) {
const values = await res.json()
const stickers = (
await Promise.all(
Object.entries(values).map(async ([name, path]) => {
const resPack = await window.fetch(path + 'pack.json')
var meta = {}
if (resPack.ok) {
meta = await resPack.json()
}
return {
pack: name,
path,
meta
}
})
)
).sort((a, b) => {
const stickers = (await Promise.all(
Object.entries(values).map(async ([name, path]) => {
const resPack = await window.fetch(path + 'pack.json')
var meta = {}
if (resPack.ok) {
meta = await resPack.json()
}
return {
pack: name,
path,
meta
}
})
)).sort((a, b) => {
return a.meta.title.localeCompare(b.meta.title)
})
store.dispatch('setInstanceOption', {
name: 'stickers',
value: stickers
})
store.dispatch('setInstanceOption', { name: 'stickers', value: stickers })
} else {
throw res
throw (res)
}
} catch (e) {
console.warn("Can't load stickers")
@ -280,19 +251,13 @@ const getAppSecret = async ({ store }) => {
.then((app) => getClientToken({ ...app, instance: instance.server }))
.then((token) => {
commit('setAppToken', token.access_token)
commit(
'setBackendInteractor',
backendInteractorService(store.getters.getToken())
)
commit('setBackendInteractor', backendInteractorService(store.getters.getToken()))
})
}
const resolveStaffAccounts = ({ store, accounts }) => {
const nicknames = accounts.map((uri) => uri.split('/').pop())
store.dispatch('setInstanceOption', {
name: 'staffAccounts',
value: nicknames
})
const nicknames = accounts.map(uri => uri.split('/').pop())
store.dispatch('setInstanceOption', { name: 'staffAccounts', value: nicknames })
}
const getNodeInfo = async ({ store }) => {
@ -302,146 +267,65 @@ const getNodeInfo = async ({ store }) => {
const data = await res.json()
const metadata = data.metadata
const features = metadata.features
store.dispatch('setInstanceOption', {
name: 'name',
value: metadata.nodeName
})
store.dispatch('setInstanceOption', {
name: 'registrationOpen',
value: data.openRegistrations
})
store.dispatch('setInstanceOption', {
name: 'mediaProxyAvailable',
value: features.includes('media_proxy')
})
store.dispatch('setInstanceOption', {
name: 'safeDM',
value: features.includes('safe_dm_mentions')
})
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
})
store.dispatch('setInstanceOption', {
name: 'translationEnabled',
value: features.includes('akkoma:machine_translation')
})
store.dispatch('setInstanceOption', { name: 'name', value: metadata.nodeName })
store.dispatch('setInstanceOption', { name: 'registrationOpen', value: data.openRegistrations })
store.dispatch('setInstanceOption', { name: 'mediaProxyAvailable', value: features.includes('media_proxy') })
store.dispatch('setInstanceOption', { name: 'safeDM', value: features.includes('safe_dm_mentions') })
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 })
store.dispatch('setInstanceOption', { name: 'translationEnabled', value: features.includes('akkoma:machine_translation') })
const uploadLimits = metadata.uploadLimits
store.dispatch('setInstanceOption', {
name: 'uploadlimit',
value: parseInt(uploadLimits.general)
})
store.dispatch('setInstanceOption', {
name: 'avatarlimit',
value: parseInt(uploadLimits.avatar)
})
store.dispatch('setInstanceOption', {
name: 'backgroundlimit',
value: parseInt(uploadLimits.background)
})
store.dispatch('setInstanceOption', {
name: 'bannerlimit',
value: parseInt(uploadLimits.banner)
})
store.dispatch('setInstanceOption', {
name: 'fieldsLimits',
value: metadata.fieldsLimits
})
store.dispatch('setInstanceOption', { name: 'uploadlimit', value: parseInt(uploadLimits.general) })
store.dispatch('setInstanceOption', { name: 'avatarlimit', value: parseInt(uploadLimits.avatar) })
store.dispatch('setInstanceOption', { name: 'backgroundlimit', value: parseInt(uploadLimits.background) })
store.dispatch('setInstanceOption', { name: 'bannerlimit', value: parseInt(uploadLimits.banner) })
store.dispatch('setInstanceOption', { name: 'fieldsLimits', value: metadata.fieldsLimits })
store.dispatch('setInstanceOption', {
name: 'restrictedNicknames',
value: metadata.restrictedNicknames
})
store.dispatch('setInstanceOption', {
name: 'postFormats',
value: metadata.postFormats
})
store.dispatch('setInstanceOption', { name: 'restrictedNicknames', value: metadata.restrictedNicknames })
store.dispatch('setInstanceOption', { name: 'postFormats', value: metadata.postFormats })
const suggestions = metadata.suggestions
store.dispatch('setInstanceOption', {
name: 'suggestionsEnabled',
value: suggestions.enabled
})
store.dispatch('setInstanceOption', {
name: 'suggestionsWeb',
value: suggestions.web
})
store.dispatch('setInstanceOption', { name: 'suggestionsEnabled', value: suggestions.enabled })
store.dispatch('setInstanceOption', { name: 'suggestionsWeb', value: suggestions.web })
const software = data.software
store.dispatch('setInstanceOption', {
name: 'backendVersion',
value: software.version
})
store.dispatch('setInstanceOption', {
name: 'pleromaBackend',
value: software.name === 'pleroma'
})
store.dispatch('setInstanceOption', { name: 'backendVersion', value: software.version })
store.dispatch('setInstanceOption', { name: 'pleromaBackend', value: software.name === 'pleroma' })
const priv = metadata.private
store.dispatch('setInstanceOption', { name: 'private', value: priv })
const frontendVersion = window.___pleromafe_commit_hash
store.dispatch('setInstanceOption', {
name: 'frontendVersion',
value: frontendVersion
})
store.dispatch('setInstanceOption', { name: 'frontendVersion', value: frontendVersion })
const federation = metadata.federation
store.dispatch('setInstanceOption', {
name: 'tagPolicyAvailable',
value:
typeof federation.mrf_policies === 'undefined'
? false
: metadata.federation.mrf_policies.includes('TagPolicy')
value: typeof federation.mrf_policies === 'undefined'
? false
: metadata.federation.mrf_policies.includes('TagPolicy')
})
store.dispatch('setInstanceOption', {
name: 'federationPolicy',
value: federation
})
store.dispatch('setInstanceOption', {
name: 'localBubbleInstances',
value: metadata.localBubbleInstances
})
store.dispatch('setInstanceOption', { name: 'federationPolicy', value: federation })
store.dispatch('setInstanceOption', { name: 'localBubbleInstances', value: metadata.localBubbleInstances })
store.dispatch('setInstanceOption', {
name: 'federating',
value:
typeof federation.enabled === 'undefined' ? true : federation.enabled
})
store.dispatch('setInstanceOption', {
name: 'publicTimelineVisibility',
value: metadata.publicTimelineVisibility
})
store.dispatch('setInstanceOption', {
name: 'federatedTimelineAvailable',
value: metadata.federatedTimelineAvailable
value: typeof federation.enabled === 'undefined'
? true
: federation.enabled
})
const accountActivationRequired = metadata.accountActivationRequired
store.dispatch('setInstanceOption', {
name: 'accountActivationRequired',
value: accountActivationRequired
})
store.dispatch('setInstanceOption', { name: 'accountActivationRequired', value: accountActivationRequired })
const accounts = metadata.staffAccounts
resolveStaffAccounts({ store, accounts })
} else {
throw res
throw (res)
}
} catch (e) {
console.warn('Could not load nodeinfo')
@ -451,16 +335,11 @@ const getNodeInfo = async ({ store }) => {
const setConfig = async ({ store }) => {
// apiConfig, staticConfig
const configInfos = await Promise.all([
getBackendProvidedConfig({ store }),
getStaticConfig()
])
const configInfos = await Promise.all([getBackendProvidedConfig({ store }), getStaticConfig()])
const apiConfig = configInfos[0]
const staticConfig = configInfos[1]
await setSettings({ store, apiConfig, staticConfig }).then(
getAppSecret({ store })
)
await setSettings({ store, apiConfig, staticConfig }).then(getAppSecret({ store }))
}
const checkOAuthToken = async ({ store }) => {
@ -483,10 +362,7 @@ const afterStoreSetup = async ({ store, i18n }) => {
FaviconService.initFaviconService()
const overrides = window.___pleromafe_dev_overrides || {}
const server =
typeof overrides.target !== 'undefined'
? overrides.target
: window.location.origin
const server = (typeof overrides.target !== 'undefined') ? overrides.target : window.location.origin
store.dispatch('setInstanceOption', { name: 'server', value: server })
await setConfig({ store })
@ -496,10 +372,7 @@ const afterStoreSetup = async ({ store, i18n }) => {
const customThemePresent = customThemeSource || customTheme
if (customThemePresent) {
if (
customThemeSource &&
customThemeSource.themeEngineVersion === CURRENT_VERSION
) {
if (customThemeSource && customThemeSource.themeEngineVersion === CURRENT_VERSION) {
applyTheme(customThemeSource)
} else {
applyTheme(customTheme)
@ -520,6 +393,9 @@ const afterStoreSetup = async ({ store, i18n }) => {
])
// Start fetching things that don't need to block the UI
store.dispatch('fetchMutes')
store.dispatch('startFetchingAnnouncements')
store.dispatch('startFetchingReports')
getTOS({ store })
getStickers({ store })
@ -527,7 +403,7 @@ const afterStoreSetup = async ({ store, i18n }) => {
history: createWebHistory(),
routes: routes(store),
scrollBehavior: (to, _from, savedPosition) => {
if (to.matched.some((m) => m.meta.dontScroll)) {
if (to.matched.some(m => m.meta.dontScroll)) {
return {}
}

View file

@ -22,8 +22,6 @@ import Lists from 'components/lists/lists.vue'
import ListTimeline from 'components/list_timeline/list_timeline.vue'
import ListEdit from 'components/list_edit/list_edit.vue'
import AnnouncementsPage from 'components/announcements_page/announcements_page.vue'
import RegistrationRequestSent from 'components/registration_request_sent/registration_request_sent.vue'
import AwaitingEmailConfirmation from 'components/awaiting_email_confirmation/awaiting_email_confirmation.vue'
export default (store) => {
const validateAuthenticatedRoute = (to, from, next) => {
@ -35,145 +33,49 @@ export default (store) => {
}
let routes = [
{
name: 'root',
{ name: 'root',
path: '/',
redirect: (_to) => {
return (
(store.state.users.currentUser
? store.state.instance.redirectRootLogin
: store.state.instance.redirectRootNoLogin) || '/main/all'
)
redirect: _to => {
return (store.state.users.currentUser
? store.state.instance.redirectRootLogin
: store.state.instance.redirectRootNoLogin) || '/main/all'
}
},
{
name: 'public-external-timeline',
path: '/main/all',
component: PublicAndExternalTimeline
},
{
name: 'public-timeline',
path: '/main/public',
component: PublicTimeline
},
{
name: 'bubble-timeline',
path: '/main/bubble',
component: BubbleTimeline
},
{
name: 'friends',
path: '/main/friends',
component: FriendsTimeline,
beforeEnter: validateAuthenticatedRoute
},
{ name: 'public-external-timeline', path: '/main/all', component: PublicAndExternalTimeline },
{ name: 'public-timeline', path: '/main/public', component: PublicTimeline },
{ name: 'bubble-timeline', path: '/main/bubble', component: BubbleTimeline },
{ name: 'friends', path: '/main/friends', component: FriendsTimeline, beforeEnter: validateAuthenticatedRoute },
{ 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: 'conversation', path: '/notice/:id', component: ConversationPage, meta: { dontScroll: true } },
{ 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,
meta: { dontScroll: true }
},
{
name: 'interactions',
path: '/users/:username/interactions',
component: Interactions,
beforeEnter: validateAuthenticatedRoute
},
{
name: 'dms',
path: '/users/:username/dms',
component: DMs,
beforeEnter: validateAuthenticatedRoute
},
{ name: 'external-user-profile', path: '/users/:id', component: UserProfile, meta: { dontScroll: true } },
{ 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 },
{
name: 'registration-request-sent',
path: '/registration-request-sent',
component: RegistrationRequestSent
},
{
name: 'awaiting-email-confirmation',
path: '/awaiting-email-confirmation',
component: AwaitingEmailConfirmation
},
{
name: 'password-reset',
path: '/password-reset',
component: PasswordReset,
props: true
},
{
name: 'registration-token',
path: '/registration/:token',
component: Registration
},
{
name: 'friend-requests',
path: '/friend-requests',
component: FollowRequests,
beforeEnter: validateAuthenticatedRoute
},
{
name: 'notifications',
path: '/:username/notifications',
component: Notifications,
props: () => ({ disableTeleport: true }),
beforeEnter: validateAuthenticatedRoute
},
{ name: 'password-reset', path: '/password-reset', component: PasswordReset, props: true },
{ name: 'registration-token', path: '/registration/:token', component: Registration },
{ name: 'friend-requests', path: '/friend-requests', component: FollowRequests, beforeEnter: validateAuthenticatedRoute },
{ name: 'notifications', path: '/:username/notifications', component: Notifications, props: () => ({ disableTeleport: true }), beforeEnter: validateAuthenticatedRoute },
{ name: 'login', path: '/login', component: AuthForm },
{
name: 'oauth-callback',
path: '/oauth-callback',
component: OAuthCallback,
props: (route) => ({ code: route.query.code })
},
{
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: 'oauth-callback', path: '/oauth-callback', component: OAuthCallback, props: (route) => ({ code: route.query.code }) },
{ 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: 'lists', path: '/lists', component: Lists },
{ name: 'list-timeline', path: '/lists/:id', component: ListTimeline },
{ name: 'list-edit', path: '/lists/:id/edit', component: ListEdit },
{
name: 'announcements',
path: '/announcements',
component: AnnouncementsPage
},
{
name: 'user-profile',
path: '/:_(users)?/:name',
component: UserProfile,
meta: { dontScroll: true }
}
{ name: 'announcements', path: '/announcements', component: AnnouncementsPage },
{ name: 'user-profile', path: '/:_(users)?/:name', component: UserProfile, meta: { dontScroll: true } }
]
return routes

View file

@ -15,17 +15,13 @@ const About = {
LocalBubblePanel
},
computed: {
showFeaturesPanel() {
return this.$store.state.instance.showFeaturesPanel
},
showInstanceSpecificPanel() {
return (
this.$store.state.instance.showInstanceSpecificPanel &&
showFeaturesPanel () { return this.$store.state.instance.showFeaturesPanel },
showInstanceSpecificPanel () {
return this.$store.state.instance.showInstanceSpecificPanel &&
!this.$store.getters.mergedConfig.hideISP &&
this.$store.state.instance.instanceSpecificPanelContent
)
},
showLocalBubblePanel() {
showLocalBubblePanel () {
return this.$store.state.instance.localBubbleInstances.length > 0
}
}

View file

@ -9,6 +9,7 @@
</div>
</template>
<script src="./about.js"></script>
<script src="./about.js" ></script>
<style lang="scss"></style>
<style lang="scss">
</style>

View file

@ -3,13 +3,19 @@ import Popover from '../popover/popover.vue'
import ConfirmModal from '../confirm_modal/confirm_modal.vue'
import { library } from '@fortawesome/fontawesome-svg-core'
import { mapState } from 'vuex'
import { faEllipsisV } from '@fortawesome/free-solid-svg-icons'
import {
faEllipsisV
} from '@fortawesome/free-solid-svg-icons'
library.add(faEllipsisV)
library.add(
faEllipsisV
)
const AccountActions = {
props: ['user', 'relationship'],
data() {
props: [
'user', 'relationship'
],
data () {
return {
showingConfirmBlock: false
}
@ -20,59 +26,45 @@ const AccountActions = {
ConfirmModal
},
methods: {
refetchRelationship() {
return this.$store.dispatch('fetchUserRelationship', this.user.id)
},
showConfirmBlock() {
showConfirmBlock () {
this.showingConfirmBlock = true
},
hideConfirmBlock() {
hideConfirmBlock () {
this.showingConfirmBlock = false
},
showRepeats() {
showRepeats () {
this.$store.dispatch('showReblogs', this.user.id)
},
hideRepeats() {
hideRepeats () {
this.$store.dispatch('hideReblogs', this.user.id)
},
blockUser() {
blockUser () {
if (!this.shouldConfirmBlock) {
this.doBlockUser()
} else {
this.showConfirmBlock()
}
},
doBlockUser() {
doBlockUser () {
this.$store.dispatch('blockUser', this.user.id)
this.hideConfirmBlock()
},
unblockUser() {
unblockUser () {
this.$store.dispatch('unblockUser', this.user.id)
},
removeUserFromFollowers() {
removeUserFromFollowers () {
this.$store.dispatch('removeUserFromFollowers', this.user.id)
},
reportUser() {
reportUser () {
this.$store.dispatch('openUserReportingModal', { userId: this.user.id })
},
muteDomain() {
this.$store
.dispatch('muteDomain', this.user.screen_name.split('@')[1])
.then(() => this.refetchRelationship())
},
unmuteDomain() {
this.$store
.dispatch('unmuteDomain', this.user.screen_name.split('@')[1])
.then(() => this.refetchRelationship())
}
},
computed: {
shouldConfirmBlock() {
shouldConfirmBlock () {
return this.$store.getters.mergedConfig.modalOnBlock
},
...mapState({
pleromaChatMessagesAvailable: (state) =>
state.instance.pleromaChatMessagesAvailable
pleromaChatMessagesAvailable: state => state.instance.pleromaChatMessagesAvailable
})
}
}

View file

@ -6,7 +6,7 @@
:bound-to="{ x: 'container' }"
remove-padding
>
<template #content>
<template v-slot:content>
<div class="dropdown-menu">
<template v-if="relationship.following">
<button
@ -55,23 +55,9 @@
>
{{ $t('user_card.report') }}
</button>
<button
v-if="relationship.domain_blocking"
class="btn button-default btn-block dropdown-item"
@click="unmuteDomain"
>
{{ $t('user_card.domain_muted') }}
</button>
<button
v-else-if="!user.is_local"
class="btn button-default btn-block dropdown-item"
@click="muteDomain"
>
{{ $t('user_card.mute_domain') }}
</button>
</div>
</template>
<template #trigger>
<template v-slot:trigger>
<button class="button-unstyled ellipsis-button">
<FAIcon
class="icon"
@ -93,8 +79,10 @@
keypath="user_card.block_confirm"
tag="span"
>
<template #user>
<span v-text="user.screen_name_ui" />
<template v-slot:user>
<span
v-text="user.screen_name_ui"
/>
</template>
</i18n-t>
</confirm-modal>

View file

@ -8,7 +8,7 @@ const Announcement = {
AnnouncementEditor,
RichContent
},
data() {
data () {
return {
editing: false,
editedAnnouncement: {
@ -25,93 +25,78 @@ const Announcement = {
},
computed: {
...mapState({
currentUser: (state) => state.users.currentUser
currentUser: state => state.users.currentUser
}),
content() {
content () {
return this.announcement.content
},
isRead() {
isRead () {
return this.announcement.read
},
publishedAt() {
publishedAt () {
const time = this.announcement['published_at']
if (!time) {
return
}
return this.formatTimeOrDate(
time,
localeService.internalToBrowserLocale(this.$i18n.locale)
)
return this.formatTimeOrDate(time, localeService.internalToBrowserLocale(this.$i18n.locale))
},
startsAt() {
startsAt () {
const time = this.announcement['starts_at']
if (!time) {
return
}
return this.formatTimeOrDate(
time,
localeService.internalToBrowserLocale(this.$i18n.locale)
)
return this.formatTimeOrDate(time, localeService.internalToBrowserLocale(this.$i18n.locale))
},
endsAt() {
endsAt () {
const time = this.announcement['ends_at']
if (!time) {
return
}
return this.formatTimeOrDate(
time,
localeService.internalToBrowserLocale(this.$i18n.locale)
)
return this.formatTimeOrDate(time, localeService.internalToBrowserLocale(this.$i18n.locale))
},
inactive() {
inactive () {
return this.announcement.inactive
}
},
methods: {
markAsRead() {
markAsRead () {
if (!this.isRead) {
return this.$store.dispatch(
'markAnnouncementAsRead',
this.announcement.id
)
return this.$store.dispatch('markAnnouncementAsRead', this.announcement.id)
}
},
deleteAnnouncement() {
deleteAnnouncement () {
return this.$store.dispatch('deleteAnnouncement', this.announcement.id)
},
formatTimeOrDate(time, locale) {
formatTimeOrDate (time, locale) {
const d = new Date(time)
return this.announcement['all_day']
? d.toLocaleDateString(locale)
: d.toLocaleString(locale)
return this.announcement['all_day'] ? d.toLocaleDateString(locale) : d.toLocaleString(locale)
},
enterEditMode() {
enterEditMode () {
this.editedAnnouncement.content = this.announcement.pleroma['raw_content']
this.editedAnnouncement.startsAt = this.announcement['starts_at']
this.editedAnnouncement.endsAt = this.announcement['ends_at']
this.editedAnnouncement.allDay = this.announcement['all_day']
this.editing = true
},
submitEdit() {
this.$store
.dispatch('editAnnouncement', {
id: this.announcement.id,
...this.editedAnnouncement
})
submitEdit () {
this.$store.dispatch('editAnnouncement', {
id: this.announcement.id,
...this.editedAnnouncement
})
.then(() => {
this.editing = false
})
.catch((error) => {
.catch(error => {
this.editError = error.error
})
},
cancelEdit() {
cancelEdit () {
this.editing = false
},
clearError() {
clearError () {
this.editError = undefined
}
}

View file

@ -21,9 +21,7 @@
class="times"
>
<span v-if="publishedAt">
{{
$t('announcements.published_time_display', { time: publishedAt })
}}
{{ $t('announcements.published_time_display', { time: publishedAt }) }}
</span>
<span v-if="startsAt">
{{ $t('announcements.start_time_display', { time: startsAt }) }}
@ -101,7 +99,7 @@
<script src="./announcement.js"></script>
<style lang="scss">
@import '../../variables';
@import "../../variables";
.announcement {
border-bottom-width: 1px;
@ -110,8 +108,7 @@
border-radius: 0;
padding: var(--status-margin, $status-margin);
.heading,
.body {
.heading, .body {
margin-bottom: var(--status-margin, $status-margin);
}

View file

@ -10,26 +10,22 @@
:disabled="disabled"
/>
<span class="announcement-metadata">
<label for="announcement-start-time">{{
$t('announcements.start_time_prompt')
}}</label>
<label for="announcement-start-time">{{ $t('announcements.start_time_prompt') }}</label>
<input
id="announcement-start-time"
v-model="announcement.startsAt"
:type="announcement.allDay ? 'date' : 'datetime-local'"
:disabled="disabled"
/>
>
</span>
<span class="announcement-metadata">
<label for="announcement-end-time">{{
$t('announcements.end_time_prompt')
}}</label>
<label for="announcement-end-time">{{ $t('announcements.end_time_prompt') }}</label>
<input
id="announcement-end-time"
v-model="announcement.endsAt"
:type="announcement.allDay ? 'date' : 'datetime-local'"
:disabled="disabled"
/>
>
</span>
<span class="announcement-metadata">
<Checkbox
@ -37,9 +33,7 @@
v-model="announcement.allDay"
:disabled="disabled"
/>
<label for="announcement-all-day">{{
$t('announcements.all_day_prompt')
}}</label>
<label for="announcement-all-day">{{ $t('announcements.all_day_prompt') }}</label>
</span>
</div>
</template>

View file

@ -7,7 +7,7 @@ const AnnouncementsPage = {
Announcement,
AnnouncementEditor
},
data() {
data () {
return {
newAnnouncement: {
content: '',
@ -19,35 +19,34 @@ const AnnouncementsPage = {
error: undefined
}
},
mounted() {
mounted () {
this.$store.dispatch('fetchAnnouncements')
},
computed: {
...mapState({
currentUser: (state) => state.users.currentUser
currentUser: state => state.users.currentUser
}),
announcements() {
announcements () {
return this.$store.state.announcements.announcements
}
},
methods: {
postAnnouncement() {
postAnnouncement () {
this.posting = true
this.$store
.dispatch('postAnnouncement', this.newAnnouncement)
this.$store.dispatch('postAnnouncement', this.newAnnouncement)
.then(() => {
this.newAnnouncement.content = ''
this.startsAt = undefined
this.endsAt = undefined
})
.catch((error) => {
.catch(error => {
this.error = error.error
})
.finally(() => {
this.posting = false
})
},
clearError() {
clearError () {
this.error = undefined
}
}

View file

@ -6,7 +6,9 @@
</div>
</div>
<div class="panel-body">
<section v-if="currentUser && currentUser.role === 'admin'">
<section
v-if="currentUser && currentUser.role === 'admin'"
>
<div class="post-form">
<div class="heading">
<h4>{{ $t('announcements.post_form_header') }}</h4>
@ -48,7 +50,9 @@
v-for="announcement in announcements"
:key="announcement.id"
>
<announcement :announcement="announcement" />
<announcement
:announcement="announcement"
/>
</section>
</div>
</div>
@ -57,14 +61,13 @@
<script src="./announcements_page.js"></script>
<style lang="scss">
@import '../../variables';
@import "../../variables";
.announcements-page {
.post-form {
padding: var(--status-margin, $status-margin);
.heading,
.body {
.heading, .body {
margin-bottom: var(--status-margin, $status-margin);
}

View file

@ -21,7 +21,7 @@
export default {
emits: ['resetAsyncComponent'],
methods: {
retry() {
retry () {
this.$emit('resetAsyncComponent')
}
}
@ -35,8 +35,8 @@ export default {
align-items: center;
justify-content: center;
.btn {
margin: 0.5em;
padding: 0.5em 2em;
margin: .5em;
padding: .5em 2em;
}
}
</style>

View file

@ -18,7 +18,6 @@ import {
faPencilAlt,
faAlignRight
} from '@fortawesome/free-solid-svg-icons'
import Blurhash from '../blurhash/Blurhash.vue'
library.add(
faFile,
@ -47,16 +46,14 @@ const Attachment = {
'shiftDn',
'edit'
],
data() {
data () {
return {
localDescription: this.description || this.attachment.description,
nsfwImage: this.$store.state.instance.nsfwCensorImage || nsfwImage,
hideNsfwLocal: this.$store.getters.mergedConfig.hideNsfw,
preloadImage: this.$store.getters.mergedConfig.preloadImage,
loading: false,
img:
fileTypeService.fileType(this.attachment.mimetype) === 'image' &&
document.createElement('img'),
img: fileTypeService.fileType(this.attachment.mimetype) === 'image' && document.createElement('img'),
modalOpen: false,
showHidden: false,
flashLoaded: false,
@ -66,11 +63,10 @@ const Attachment = {
components: {
Flash,
StillImage,
VideoAttachment,
Blurhash
VideoAttachment
},
computed: {
classNames() {
classNames () {
return [
{
'-loading': this.loading,
@ -82,40 +78,37 @@ const Attachment = {
`-${this.useContainFit ? 'contain' : 'cover'}-fit`
]
},
usePlaceholder() {
usePlaceholder () {
return this.size === 'hide'
},
useContainFit() {
useContainFit () {
return this.$store.getters.mergedConfig.useContainFit
},
useBlurhash() {
return this.$store.getters.mergedConfig.useBlurhash
},
placeholderName() {
placeholderName () {
if (this.attachment.description === '' || !this.attachment.description) {
return this.type.toUpperCase()
}
return this.attachment.description
},
placeholderIconClass() {
placeholderIconClass () {
if (this.type === 'image') return 'image'
if (this.type === 'video') return 'video'
if (this.type === 'audio') return 'music'
return 'file'
},
referrerpolicy() {
referrerpolicy () {
return this.$store.state.instance.mediaProxyAvailable ? '' : 'no-referrer'
},
type() {
type () {
return fileTypeService.fileType(this.attachment.mimetype)
},
hidden() {
hidden () {
return this.nsfw && this.hideNsfwLocal && !this.showHidden
},
isEmpty() {
return this.type === 'html' && !this.attachment.oembed
isEmpty () {
return (this.type === 'html' && !this.attachment.oembed)
},
useModal() {
useModal () {
let modalTypes = []
switch (this.size) {
case 'hide':
@ -130,29 +123,29 @@ const Attachment = {
}
return modalTypes.includes(this.type)
},
videoTag() {
videoTag () {
return this.useModal ? 'button' : 'span'
},
statusForm() {
statusForm () {
return this.$parent.$parent
},
...mapGetters(['mergedConfig'])
},
watch: {
'attachment.description'(newVal) {
'attachment.description' (newVal) {
this.localDescription = newVal
},
localDescription(newVal) {
localDescription (newVal) {
this.onEdit(newVal)
}
},
methods: {
linkClicked({ target }) {
linkClicked ({ target }) {
if (target.tagName === 'A') {
window.open(target.href, '_blank')
}
},
openModal(event) {
openModal (event) {
if (this.useModal) {
this.$emit('setMedia')
this.$store.dispatch('setCurrentMedia', this.attachment)
@ -160,35 +153,34 @@ const Attachment = {
window.open(this.attachment.url)
}
},
openModalForce(event) {
openModalForce (event) {
this.$emit('setMedia')
this.$store.dispatch('setCurrentMedia', this.attachment)
},
onEdit(event) {
onEdit (event) {
this.edit && this.edit(this.attachment, event)
},
onRemove() {
onRemove () {
this.remove && this.remove(this.attachment)
},
onShiftUp() {
onShiftUp () {
this.shiftUp && this.shiftUp(this.attachment)
},
onShiftDn() {
onShiftDn () {
this.shiftDn && this.shiftDn(this.attachment)
},
stopFlash() {
stopFlash () {
this.$refs.flash.closePlayer()
},
setFlashLoaded(event) {
setFlashLoaded (event) {
this.flashLoaded = event
},
toggleDescription() {
toggleDescription () {
this.showDescription = !this.showDescription
},
toggleHidden(event) {
toggleHidden (event) {
if (
this.mergedConfig.useOneClickNsfw &&
!this.showHidden &&
(this.mergedConfig.useOneClickNsfw && !this.showHidden) &&
(this.type !== 'video' || this.mergedConfig.playVideosInModal)
) {
this.openModal(event)
@ -209,16 +201,14 @@ const Attachment = {
this.showHidden = !this.showHidden
}
},
onImageLoad(image) {
onImageLoad (image) {
const width = image.naturalWidth
const height = image.naturalHeight
this.$emit('naturalSizeLoad', { id: this.attachment.id, width, height })
},
resize(e) {
resize (e) {
const target = e.target || e
if (!(target instanceof window.Element)) {
return
}
if (!(target instanceof window.Element)) { return }
// Reset to default height for empty form, nothing else to do here.
if (target.value === '') {
@ -229,16 +219,14 @@ const Attachment = {
const paddingString = getComputedStyle(target)['padding']
// remove -px suffix
const padding = Number(
paddingString.substring(0, paddingString.length - 2)
)
const padding = Number(paddingString.substring(0, paddingString.length - 2))
target.style.height = 'auto'
const newHeight = Math.floor(target.scrollHeight - padding * 2)
target.style.height = `${newHeight}px`
this.$emit('resize', newHeight)
},
postStatus(event) {
postStatus (event) {
this.statusForm.postStatus(event, this.statusForm.newStatus)
}
}

View file

@ -117,6 +117,7 @@
padding-top: 0.5em;
}
.play-icon {
position: absolute;
font-size: 64px;

View file

@ -15,8 +15,7 @@
@click.prevent
>
<FAIcon :icon="placeholderIconClass" />
<b>{{ nsfw ? 'NSFW / ' : '' }}</b
>{{ edit ? '' : placeholderName }}
<b>{{ nsfw ? "NSFW / " : "" }}</b>{{ edit ? '' : placeholderName }}
</a>
<div
v-if="edit || remove"
@ -31,11 +30,7 @@
</button>
</div>
<div
v-if="
size !== 'hide' &&
!hideDescription &&
(edit || localDescription || showDescription)
"
v-if="size !== 'hide' && !hideDescription && (edit || localDescription || showDescription)"
class="description-container"
:class="{ '-static': !edit }"
>
@ -46,7 +41,7 @@
class="description-field"
:placeholder="$t('post_status.media_description')"
@keydown.enter.prevent=""
/>
>
<p v-else>
{{ localDescription }}
</p>
@ -69,19 +64,11 @@
:title="attachment.description"
@click.prevent.stop="toggleHidden"
>
<Blurhash
v-if="useBlurhash && attachment.blurhash"
:height="512"
:width="1024"
:hash="attachment.blurhash"
:punch="1"
/>
<img
v-else
:key="nsfwImage"
class="nsfw"
:src="nsfwImage"
/>
>
<FAIcon
v-if="type === 'video'"
class="play-icon"
@ -101,12 +88,7 @@
<FAIcon icon="stop" />
</button>
<button
v-if="
attachment.description &&
size !== 'small' &&
!edit &&
type !== 'unknown'
"
v-if="attachment.description && size !== 'small' && !edit && type !== 'unknown'"
class="button-unstyled attachment-button"
:title="$t('status.show_attachment_description')"
@click.prevent="toggleDescription"
@ -158,7 +140,7 @@
<a
v-if="type === 'image' && (!hidden || preloadImage)"
class="image-container"
:class="{ '-hidden': hidden && preloadImage }"
:class="{'-hidden': hidden && preloadImage }"
:href="attachment.url"
target="_blank"
@click.stop.prevent="openModal"
@ -236,13 +218,11 @@
v-if="attachment.thumb_url"
class="image"
>
<img :src="attachment.thumb_url" />
<img :src="attachment.thumb_url">
</div>
<div class="text">
<!-- eslint-disable vue/no-v-html -->
<h1>
<a :href="attachment.url">{{ attachment.oembed.title }}</a>
</h1>
<h1><a :href="attachment.url">{{ attachment.oembed.title }}</a></h1>
<div v-html="attachment.oembed.oembedHTML" />
<!-- eslint-enable vue/no-v-html -->
</div>
@ -264,11 +244,7 @@
</span>
</div>
<div
v-if="
size !== 'hide' &&
!hideDescription &&
(edit || (localDescription && showDescription))
"
v-if="size !== 'hide' && !hideDescription && (edit || (localDescription && showDescription))"
class="description-container"
:class="{ '-static': !edit }"
>

View file

@ -6,17 +6,13 @@ import { mapGetters } from 'vuex'
const AuthForm = {
name: 'AuthForm',
render() {
render () {
return h(resolveComponent(this.authForm))
},
computed: {
authForm() {
if (this.requiredTOTP) {
return 'MFATOTPForm'
}
if (this.requiredRecovery) {
return 'MFARecoveryForm'
}
authForm () {
if (this.requiredTOTP) { return 'MFATOTPForm' }
if (this.requiredRecovery) { return 'MFARecoveryForm' }
return 'LoginForm'
},
...mapGetters('authFlow', ['requiredTOTP', 'requiredRecovery'])

View file

@ -2,13 +2,11 @@ const debounceMilliseconds = 500
export default {
props: {
query: {
// function to query results and return a promise
query: { // function to query results and return a promise
type: Function,
required: true
},
filter: {
// function to filter results in real time
filter: { // function to filter results in real time
type: Function
},
placeholder: {
@ -16,7 +14,7 @@ export default {
default: 'Search...'
}
},
data() {
data () {
return {
term: '',
timeout: null,
@ -25,31 +23,29 @@ export default {
}
},
computed: {
filtered() {
filtered () {
return this.filter ? this.filter(this.results) : this.results
}
},
watch: {
term(val) {
term (val) {
this.fetchResults(val)
}
},
methods: {
fetchResults(term) {
fetchResults (term) {
clearTimeout(this.timeout)
this.timeout = setTimeout(() => {
this.results = []
if (term) {
this.query(term).then((results) => {
this.results = results
})
this.query(term).then((results) => { this.results = results })
}
}, debounceMilliseconds)
},
onInputClick() {
onInputClick () {
this.resultsVisible = true
},
onClickOutside() {
onClickOutside () {
this.resultsVisible = false
}
}

View file

@ -8,7 +8,7 @@
:placeholder="placeholder"
class="autosuggest-input"
@click="onInputClick"
/>
>
<div
v-if="resultsVisible && filtered.length > 0"
class="autosuggest-results"

View file

@ -4,7 +4,7 @@ import generateProfileLink from 'src/services/user_profile_link_generator/user_p
const AvatarList = {
props: ['users'],
computed: {
slicedUsers() {
slicedUsers () {
return this.users ? this.users.slice(0, 15) : []
}
},
@ -12,12 +12,8 @@ const AvatarList = {
UserAvatar
},
methods: {
userProfileLink(user) {
return generateProfileLink(
user.id,
user.screen_name,
this.$store.state.instance.restrictedNicknames
)
userProfileLink (user) {
return generateProfileLink(user.id, user.screen_name, this.$store.state.instance.restrictedNicknames)
}
}
}

View file

@ -14,7 +14,7 @@
</div>
</template>
<script src="./avatar_list.js"></script>
<script src="./avatar_list.js" ></script>
<style lang="scss">
@import '../../_variables.scss';

View file

@ -1,3 +0,0 @@
export default {
computed: {}
}

View file

@ -1,12 +0,0 @@
<template>
<div class="panel panel-default">
<div class="panel-heading">
<h4>{{ $t('registration.awaiting_email_confirmation_title') }}</h4>
</div>
<div class="panel-body">
<p>{{ $t('registration.awaiting_email_confirmation') }}</p>
</div>
</div>
</template>
<script src="./awaiting_email_confirmation.js"></script>

View file

@ -4,8 +4,10 @@ import RichContent from 'src/components/rich_content/rich_content.jsx'
import generateProfileLink from 'src/services/user_profile_link_generator/user_profile_link_generator'
const BasicUserCard = {
props: ['user'],
data() {
props: [
'user'
],
data () {
return {
userExpanded: false
}
@ -16,15 +18,11 @@ const BasicUserCard = {
RichContent
},
methods: {
toggleUserExpanded() {
toggleUserExpanded () {
this.userExpanded = !this.userExpanded
},
userProfileLink(user) {
return generateProfileLink(
user.id,
user.screen_name,
this.$store.state.instance.restrictedNicknames
)
userProfileLink (user) {
return generateProfileLink(user.id, user.screen_name, this.$store.state.instance.restrictedNicknames)
}
}
}

View file

@ -2,19 +2,19 @@ import BasicUserCard from '../basic_user_card/basic_user_card.vue'
const BlockCard = {
props: ['userId'],
data() {
data () {
return {
progress: false
}
},
computed: {
user() {
user () {
return this.$store.getters.findUser(this.userId)
},
relationship() {
relationship () {
return this.$store.getters.relationship(this.userId)
},
blocked() {
blocked () {
return this.relationship.blocking
}
},
@ -22,13 +22,13 @@ const BlockCard = {
BasicUserCard
},
methods: {
unblockUser() {
unblockUser () {
this.progress = true
this.$store.dispatch('unblockUser', this.user.id).then(() => {
this.progress = false
})
},
blockUser() {
blockUser () {
this.progress = true
this.$store.dispatch('blockUser', this.user.id).then(() => {
this.progress = false

View file

@ -1,64 +0,0 @@
<template>
<canvas
ref="canvas"
class="blurhash"
/>
</template>
<script>
import { decode } from 'blurhash'
export default {
name: 'Blurhash',
props: {
hash: {
type: String,
required: true
},
width: {
type: Number,
required: true
},
height: {
type: Number,
required: true
},
punch: {
type: Number,
default: null
}
},
data() {
return {
canvas: null,
ctx: null
}
},
mounted() {
this.canvas = this.$refs.canvas
this.ctx = this.canvas.getContext('2d')
this.canvas.width = 1024
this.canvas.height = 512
this.draw()
},
methods: {
draw() {
const pixels = decode(this.hash, this.width, this.height, this.punch)
const imageData = this.ctx.createImageData(this.width, this.height)
imageData.data.set(pixels)
this.ctx.putImageData(imageData, 0, 0)
fetch('/static/blurhash-overlay.png')
.then((response) => response.blob())
.then((blob) => {
const img = new Image()
img.src = URL.createObjectURL(blob)
img.onload = () => {
this.ctx.drawImage(img, 0, 0, this.width, this.height)
}
})
}
}
}
</script>
<style scoped></style>

View file

@ -2,14 +2,14 @@ import Timeline from '../timeline/timeline.vue'
const Bookmarks = {
computed: {
timeline() {
timeline () {
return this.$store.state.statuses.timelines.bookmarks
}
},
components: {
Timeline
},
unmounted() {
unmounted () {
this.$store.commit('clearTimeline', { timeline: 'bookmarks' })
}
}

View file

@ -4,16 +4,15 @@ const PublicTimeline = {
Timeline
},
computed: {
timeline() {
return this.$store.state.statuses.timelines.bubble
}
timeline () { return this.$store.state.statuses.timelines.bubble }
},
created() {
created () {
this.$store.dispatch('startFetchingTimeline', { timeline: 'bubble' })
},
unmounted() {
unmounted () {
this.$store.dispatch('stopFetchingTimeline', 'bubble')
}
}
export default PublicTimeline

View file

@ -11,17 +11,14 @@ export default {
name: 'Timeago',
props: ['date'],
computed: {
displayDate() {
displayDate () {
const today = new Date()
today.setHours(0, 0, 0, 0)
if (this.date.getTime() === today.getTime()) {
return this.$t('display_date.today')
} else {
return this.date.toLocaleDateString(
localeService.internalToBrowserLocale(this.$i18n.locale),
{ day: 'numeric', month: 'long' }
)
return this.date.toLocaleDateString(localeService.internalToBrowserLocale(this.$i18n.locale), { day: 'numeric', month: 'long' })
}
}
}

View file

@ -9,7 +9,7 @@
:checked="modelValue"
:indeterminate="indeterminate"
@change="$emit('update:modelValue', $event.target.checked)"
/>
>
<i class="checkbox-indicator" />
<span
v-if="!!$slots.default"
@ -22,8 +22,12 @@
<script>
export default {
props: ['modelValue', 'indeterminate', 'disabled'],
emits: ['update:modelValue']
emits: ['update:modelValue'],
props: [
'modelValue',
'indeterminate',
'disabled'
]
}
</script>
@ -52,7 +56,7 @@ export default {
border-radius: $fallback--checkboxRadius;
border-radius: var(--checkboxRadius, $fallback--checkboxRadius);
box-shadow: 0px 0px 2px black inset;
box-shadow: 0 0 0 1px var(--icon, $fallback--icon) inset;
box-shadow: var(--inputShadow);
background-color: $fallback--fg;
background-color: var(--input, $fallback--fg);
vertical-align: top;
@ -67,7 +71,7 @@ export default {
&.disabled {
.checkbox-indicator::before,
.label {
opacity: 0.5;
opacity: .5;
}
.label {
color: $fallback--faint;
@ -75,7 +79,7 @@ export default {
}
}
input[type='checkbox'] {
input[type=checkbox] {
display: none;
&:checked + .checkbox-indicator::before {
@ -88,10 +92,11 @@ export default {
color: $fallback--text;
color: var(--inputText, $fallback--text);
}
}
& > span {
margin-left: 0.5em;
margin-left: .5em;
}
}
</style>

View file

@ -8,7 +8,7 @@
flex: 0 0 0;
max-width: 9em;
align-items: stretch;
padding: 0.2em 8px;
padding: .2em 8px;
input {
background: none;
@ -40,10 +40,9 @@
}
.transparentIndicator {
// forgot to install counter-strike source, ooops
background-color: #ff00ff;
background-color: #FF00FF;
position: relative;
&::before,
&::after {
&::before, &::after {
display: block;
content: '';
background-color: #000000;
@ -65,4 +64,5 @@
.label {
flex: 1 1 auto;
}
}

View file

@ -14,12 +14,7 @@
:model-value="present"
:disabled="disabled"
class="opt"
@update:modelValue="
$emit(
'update:modelValue',
typeof modelValue === 'undefined' ? fallback : undefined
)
"
@update:modelValue="$emit('update:modelValue', typeof modelValue === 'undefined' ? fallback : undefined)"
/>
<div class="input color-input-field">
<input
@ -29,7 +24,7 @@
:value="modelValue || fallback"
:disabled="!present || disabled"
@input="$emit('update:modelValue', $event.target.value)"
/>
>
<input
v-if="validColor"
:id="name"
@ -38,7 +33,7 @@
:value="modelValue || fallback"
:disabled="!present || disabled"
@input="$emit('update:modelValue', $event.target.value)"
/>
>
<div
v-if="transparentColor"
class="transparentIndicator"
@ -46,11 +41,12 @@
<div
v-if="computedColor"
class="computedIndicator"
:style="{ backgroundColor: fallback }"
:style="{backgroundColor: fallback}"
/>
</div>
</div>
</template>
<style lang="scss" src="./color_input.scss"></style>
<script>
import Checkbox from '../checkbox/checkbox.vue'
import { hex2rgb } from '../../services/color_convert/color_convert.js'
@ -97,22 +93,21 @@ export default {
},
emits: ['update:modelValue'],
computed: {
present() {
present () {
return typeof this.modelValue !== 'undefined'
},
validColor() {
validColor () {
return hex2rgb(this.modelValue || this.fallback)
},
transparentColor() {
transparentColor () {
return this.modelValue === 'transparent'
},
computedColor() {
computedColor () {
return this.modelValue && this.modelValue.startsWith('--')
}
}
}
</script>
<style lang="scss" src="./color_input.scss"></style>
<style lang="scss">
.color-control {

View file

@ -22,12 +22,13 @@ const ConfirmModal = {
type: String
}
},
computed: {},
computed: {
},
methods: {
onCancel() {
onCancel () {
this.$emit('cancelled')
},
onAccept() {
onAccept () {
this.$emit('accepted')
}
}

View file

@ -25,8 +25,6 @@
</dialog-modal>
</template>
<script src="./confirm_modal.js"></script>
<style lang="scss" scoped>
@import '../../_variables';
@ -37,3 +35,5 @@
}
}
</style>
<script src="./confirm_modal.js"></script>

View file

@ -43,7 +43,11 @@ import {
faThumbsUp
} from '@fortawesome/free-solid-svg-icons'
library.add(faAdjust, faExclamationTriangle, faThumbsUp)
library.add(
faAdjust,
faExclamationTriangle,
faThumbsUp
)
export default {
props: {
@ -61,35 +65,19 @@ export default {
}
},
computed: {
hint() {
const levelVal = this.contrast.aaa
? 'aaa'
: this.contrast.aa
? 'aa'
: 'bad'
hint () {
const levelVal = this.contrast.aaa ? 'aaa' : (this.contrast.aa ? 'aa' : 'bad')
const level = this.$t(`settings.style.common.contrast.level.${levelVal}`)
const context = this.$t('settings.style.common.contrast.context.text')
const ratio = this.contrast.text
return this.$t('settings.style.common.contrast.hint', {
level,
context,
ratio
})
return this.$t('settings.style.common.contrast.hint', { level, context, ratio })
},
hint_18pt() {
const levelVal = this.contrast.laaa
? 'aaa'
: this.contrast.laa
? 'aa'
: 'bad'
hint_18pt () {
const levelVal = this.contrast.laaa ? 'aaa' : (this.contrast.laa ? 'aa' : 'bad')
const level = this.$t(`settings.style.common.contrast.level.${levelVal}`)
const context = this.$t('settings.style.common.contrast.context.18pt')
const ratio = this.contrast.text
return this.$t('settings.style.common.contrast.hint', {
level,
context,
ratio
})
return this.$t('settings.style.common.contrast.hint', { level, context, ratio })
}
}
}

View file

@ -5,7 +5,7 @@ const conversationPage = {
Conversation
},
computed: {
statusId() {
statusId () {
return this.$route.params.id
}
}

View file

@ -11,7 +11,11 @@ import {
faChevronLeft
} from '@fortawesome/free-solid-svg-icons'
library.add(faAngleDoubleDown, faAngleDoubleLeft, faChevronLeft)
library.add(
faAngleDoubleDown,
faAngleDoubleLeft,
faChevronLeft
)
const sortById = (a, b) => {
const idA = a.type === 'retweet' ? a.retweeted_status.id : a.id
@ -35,17 +39,16 @@ const sortAndFilterConversation = (conversation, statusoid) => {
if (statusoid.type === 'retweet') {
conversation = filter(
conversation,
(status) =>
status.type === 'retweet' || status.id !== statusoid.retweeted_status.id
(status) => (status.type === 'retweet' || status.id !== statusoid.retweeted_status.id)
)
} else {
conversation = filter(conversation, (status) => status.type !== 'retweet')
}
return conversation.filter((_) => _).sort(sortById)
return conversation.filter(_ => _).sort(sortById)
}
const conversation = {
data() {
data () {
return {
highlight: null,
expanded: false,
@ -63,78 +66,74 @@ const conversation = {
'profileUserId',
'virtualHidden'
],
created() {
created () {
if (this.isPage) {
this.fetchConversation()
}
},
computed: {
maxDepthToShowByDefault() {
maxDepthToShowByDefault () {
// maxDepthInThread = max number of depths that is *visible*
// since our depth starts with 0 and "showing" means "showing children"
// there is a -2 here
const maxDepth = this.$store.getters.mergedConfig.maxDepthInThread - 2
return maxDepth >= 1 ? maxDepth : 1
},
streamingEnabled() {
return (
this.mergedConfig.useStreamingApi &&
this.mastoUserSocketStatus === WSConnectionStatus.JOINED
)
streamingEnabled () {
return this.mergedConfig.useStreamingApi && this.mastoUserSocketStatus === WSConnectionStatus.JOINED
},
displayStyle() {
displayStyle () {
return this.$store.getters.mergedConfig.conversationDisplay
},
isTreeView() {
isTreeView () {
return !this.isLinearView
},
treeViewIsSimple() {
treeViewIsSimple () {
return !this.$store.getters.mergedConfig.conversationTreeAdvanced
},
isLinearView() {
isLinearView () {
return this.displayStyle === 'linear'
},
shouldFadeAncestors() {
shouldFadeAncestors () {
return this.$store.getters.mergedConfig.conversationTreeFadeAncestors
},
otherRepliesButtonPosition() {
otherRepliesButtonPosition () {
return this.$store.getters.mergedConfig.conversationOtherRepliesButton
},
showOtherRepliesButtonBelowStatus() {
showOtherRepliesButtonBelowStatus () {
return this.otherRepliesButtonPosition === 'below'
},
showOtherRepliesButtonInsideStatus() {
showOtherRepliesButtonInsideStatus () {
return this.otherRepliesButtonPosition === 'inside'
},
suspendable() {
suspendable () {
if (this.isTreeView) {
return Object.entries(this.statusContentProperties).every(
([k, prop]) => !prop.replying && prop.mediaPlaying.length === 0
)
return Object.entries(this.statusContentProperties)
.every(([k, prop]) => !prop.replying && prop.mediaPlaying.length === 0)
}
if (this.$refs.statusComponent && this.$refs.statusComponent[0]) {
return this.$refs.statusComponent.every((s) => s.suspendable)
return this.$refs.statusComponent.every(s => s.suspendable)
} else {
return true
}
},
hideStatus() {
hideStatus () {
return this.virtualHidden && this.suspendable
},
status() {
status () {
return this.$store.state.statuses.allStatusesObject[this.statusId]
},
originalStatusId() {
originalStatusId () {
if (this.status.retweeted_status) {
return this.status.retweeted_status.id
} else {
return this.statusId
}
},
conversationId() {
conversationId () {
return this.getConversationId(this.statusId)
},
conversation() {
conversation () {
if (!this.status) {
return []
}
@ -143,203 +142,155 @@ const conversation = {
return [this.status]
}
const conversation = clone(
this.$store.state.statuses.conversationsObject[this.conversationId]
)
const statusIndex = findIndex(conversation, {
id: this.originalStatusId
})
const conversation = clone(this.$store.state.statuses.conversationsObject[this.conversationId])
const statusIndex = findIndex(conversation, { id: this.originalStatusId })
if (statusIndex !== -1) {
conversation[statusIndex] = this.status
}
return sortAndFilterConversation(conversation, this.status)
},
statusMap() {
statusMap () {
return this.conversation.reduce((res, s) => {
res[s.id] = s
return res
}, {})
},
threadTree() {
const reverseLookupTable = this.conversation.reduce(
(table, status, index) => {
table[status.id] = index
return table
},
{}
)
threadTree () {
const reverseLookupTable = this.conversation.reduce((table, status, index) => {
table[status.id] = index
return table
}, {})
const threads = this.conversation.reduce(
(a, cur) => {
const id = cur.id
a.forest[id] = this.getReplies(id).map((s) => s.id)
const threads = this.conversation.reduce((a, cur) => {
const id = cur.id
a.forest[id] = this.getReplies(id)
.map(s => s.id)
return a
},
{
forest: {}
return a
}, {
forest: {}
})
const walk = (forest, topLevel, depth = 0, processed = {}) => topLevel.map(id => {
if (processed[id]) {
return []
}
)
const walk = (forest, topLevel, depth = 0, processed = {}) =>
topLevel
.map((id) => {
if (processed[id]) {
return []
}
processed[id] = true
return [{
status: this.conversation[reverseLookupTable[id]],
id,
depth
}, walk(forest, forest[id], depth + 1, processed)].reduce((a, b) => a.concat(b), [])
}).reduce((a, b) => a.concat(b), [])
processed[id] = true
return [
{
status: this.conversation[reverseLookupTable[id]],
id,
depth
},
walk(forest, forest[id], depth + 1, processed)
].reduce((a, b) => a.concat(b), [])
})
.reduce((a, b) => a.concat(b), [])
const linearized = walk(
threads.forest,
this.topLevel.map((k) => k.id)
)
const linearized = walk(threads.forest, this.topLevel.map(k => k.id))
return linearized
},
replyIds() {
return this.conversation
.map((k) => k.id)
replyIds () {
return this.conversation.map(k => k.id)
.reduce((res, id) => {
res[id] = (this.replies[id] || []).map((k) => k.id)
res[id] = (this.replies[id] || []).map(k => k.id)
return res
}, {})
},
totalReplyCount() {
totalReplyCount () {
const sizes = {}
const subTreeSizeFor = (id) => {
if (sizes[id]) {
return sizes[id]
}
sizes[id] =
1 +
this.replyIds[id]
.map((cid) => subTreeSizeFor(cid))
.reduce((a, b) => a + b, 0)
sizes[id] = 1 + this.replyIds[id].map(cid => subTreeSizeFor(cid)).reduce((a, b) => a + b, 0)
return sizes[id]
}
this.conversation.map((k) => k.id).map(subTreeSizeFor)
this.conversation.map(k => k.id).map(subTreeSizeFor)
return Object.keys(sizes).reduce((res, id) => {
res[id] = sizes[id] - 1 // exclude itself
return res
}, {})
},
totalReplyDepth() {
totalReplyDepth () {
const depths = {}
const subTreeDepthFor = (id) => {
if (depths[id]) {
return depths[id]
}
depths[id] =
1 +
this.replyIds[id]
.map((cid) => subTreeDepthFor(cid))
.reduce((a, b) => (a > b ? a : b), 0)
depths[id] = 1 + this.replyIds[id].map(cid => subTreeDepthFor(cid)).reduce((a, b) => a > b ? a : b, 0)
return depths[id]
}
this.conversation.map((k) => k.id).map(subTreeDepthFor)
this.conversation.map(k => k.id).map(subTreeDepthFor)
return Object.keys(depths).reduce((res, id) => {
res[id] = depths[id] - 1 // exclude itself
return res
}, {})
},
depths() {
depths () {
return this.threadTree.reduce((a, k) => {
a[k.id] = k.depth
return a
}, {})
},
topLevel() {
const topLevel = this.conversation.reduce(
(tl, cur) =>
tl.filter(
(k) =>
this.getReplies(cur.id)
.map((v) => v.id)
.indexOf(k.id) === -1
),
this.conversation
)
topLevel () {
const topLevel = this.conversation.reduce((tl, cur) =>
tl.filter(k => this.getReplies(cur.id).map(v => v.id).indexOf(k.id) === -1), this.conversation)
return topLevel
},
otherTopLevelCount() {
otherTopLevelCount () {
return this.topLevel.length - 1
},
showingTopLevel() {
showingTopLevel () {
if (this.canDive && this.diveRoot) {
return [this.statusMap[this.diveRoot]]
}
return this.topLevel
},
diveRoot() {
diveRoot () {
const statusId = this.inlineDivePosition || this.statusId
const isTopLevel = !this.parentOf(statusId)
return isTopLevel ? null : statusId
},
diveDepth() {
diveDepth () {
return this.canDive && this.diveRoot ? this.depths[this.diveRoot] : 0
},
diveMode() {
diveMode () {
return this.canDive && !!this.diveRoot
},
shouldShowAllConversationButton() {
shouldShowAllConversationButton () {
// The "show all conversation" button tells the user that there exist
// other toplevel statuses, so do not show it if there is only a single root
return (
this.isTreeView &&
this.isExpanded &&
this.diveMode &&
this.topLevel.length > 1
)
return this.isTreeView && this.isExpanded && this.diveMode && this.topLevel.length > 1
},
shouldShowAncestors() {
return (
this.isTreeView &&
this.isExpanded &&
this.ancestorsOf(this.diveRoot).length
)
shouldShowAncestors () {
return this.isTreeView && this.isExpanded && this.ancestorsOf(this.diveRoot).length
},
replies() {
replies () {
let i = 1
// eslint-disable-next-line camelcase
return reduce(
this.conversation,
(result, { id, in_reply_to_status_id }) => {
/* eslint-disable camelcase */
const irid = in_reply_to_status_id
/* eslint-enable camelcase */
if (irid) {
result[irid] = result[irid] || []
result[irid].push({
name: `#${i}`,
id: id
})
}
i++
return result
},
{}
)
return reduce(this.conversation, (result, { id, in_reply_to_status_id }) => {
/* eslint-disable camelcase */
const irid = in_reply_to_status_id
/* eslint-enable camelcase */
if (irid) {
result[irid] = result[irid] || []
result[irid].push({
name: `#${i}`,
id: id
})
}
i++
return result
}, {})
},
isExpanded() {
isExpanded () {
return !!(this.expanded || this.isPage)
},
hiddenStyle() {
hiddenStyle () {
const height = (this.status && this.status.virtualHeight) || '120px'
return this.virtualHidden ? { height } : {}
},
threadDisplayStatus() {
threadDisplayStatus () {
return this.conversation.reduce((a, k) => {
const id = k.id
const depth = this.depths[id]
@ -347,7 +298,7 @@ const conversation = {
if (this.threadDisplayStatusObject[id]) {
return this.threadDisplayStatusObject[id]
}
if (depth - this.diveDepth <= this.maxDepthToShowByDefault) {
if ((depth - this.diveDepth) <= this.maxDepthToShowByDefault) {
return 'showing'
} else {
return 'hidden'
@ -358,7 +309,7 @@ const conversation = {
return a
}, {})
},
statusContentProperties() {
statusContentProperties () {
return this.conversation.reduce((a, k) => {
const id = k.id
const props = (() => {
@ -383,20 +334,20 @@ const conversation = {
return a
}, {})
},
canDive() {
canDive () {
return this.isTreeView && this.isExpanded
},
focused() {
focused () {
return (id) => {
return this.isExpanded && id === this.highlight
return (this.isExpanded) && id === this.highlight
}
},
maybeHighlight() {
maybeHighlight () {
return this.isExpanded ? this.highlight : null
},
...mapGetters(['mergedConfig']),
...mapState({
mastoUserSocketStatus: (state) => state.api.mastoUserSocketStatus
mastoUserSocketStatus: state => state.api.mastoUserSocketStatus
})
},
components: {
@ -404,59 +355,53 @@ const conversation = {
ThreadTree
},
watch: {
statusId(newVal, oldVal) {
statusId (newVal, oldVal) {
const newConversationId = this.getConversationId(newVal)
const oldConversationId = this.getConversationId(oldVal)
if (
newConversationId &&
oldConversationId &&
newConversationId === oldConversationId
) {
if (newConversationId && oldConversationId && newConversationId === oldConversationId) {
this.setHighlight(this.originalStatusId)
} else {
this.fetchConversation()
}
},
expanded(value) {
expanded (value) {
if (value) {
this.fetchConversation()
} else {
this.resetDisplayState()
}
},
virtualHidden(value) {
this.$store.dispatch('setVirtualHeight', {
statusId: this.statusId,
height: `${this.$el.clientHeight}px`
})
virtualHidden (value) {
this.$store.dispatch(
'setVirtualHeight',
{ statusId: this.statusId, height: `${this.$el.clientHeight}px` }
)
}
},
methods: {
fetchConversation() {
fetchConversation () {
if (this.status) {
this.$store.state.api.backendInteractor
.fetchConversation({ id: this.statusId })
this.$store.state.api.backendInteractor.fetchConversation({ id: this.statusId })
.then(({ ancestors, descendants }) => {
this.$store.dispatch('addNewStatuses', { statuses: ancestors })
this.$store.dispatch('addNewStatuses', { statuses: descendants })
this.setHighlight(this.originalStatusId)
})
} else {
this.$store.state.api.backendInteractor
.fetchStatus({ id: this.statusId })
this.$store.state.api.backendInteractor.fetchStatus({ id: this.statusId })
.then((status) => {
this.$store.dispatch('addNewStatuses', { statuses: [status] })
this.fetchConversation()
})
}
},
getReplies(id) {
getReplies (id) {
return this.replies[id] || []
},
getHighlight() {
getHighlight () {
return this.isExpanded ? this.highlight : null
},
setHighlight(id) {
setHighlight (id) {
if (!id) return
this.highlight = id
@ -467,38 +412,32 @@ const conversation = {
this.$store.dispatch('fetchFavsAndRepeats', id)
this.$store.dispatch('fetchEmojiReactionsBy', id)
},
toggleExpanded() {
toggleExpanded () {
this.expanded = !this.expanded
},
getConversationId(statusId) {
getConversationId (statusId) {
const status = this.$store.state.statuses.allStatusesObject[statusId]
return get(
status,
'retweeted_status.statusnet_conversation_id',
get(status, 'statusnet_conversation_id')
)
return get(status, 'retweeted_status.statusnet_conversation_id', get(status, 'statusnet_conversation_id'))
},
setThreadDisplay(id, nextStatus) {
setThreadDisplay (id, nextStatus) {
this.threadDisplayStatusObject = {
...this.threadDisplayStatusObject,
[id]: nextStatus
}
},
toggleThreadDisplay(id) {
toggleThreadDisplay (id) {
const curStatus = this.threadDisplayStatus[id]
const nextStatus = curStatus === 'showing' ? 'hidden' : 'showing'
this.setThreadDisplay(id, nextStatus)
},
setThreadDisplayRecursively(id, nextStatus) {
setThreadDisplayRecursively (id, nextStatus) {
this.setThreadDisplay(id, nextStatus)
this.getReplies(id)
.map((k) => k.id)
.map((id) => this.setThreadDisplayRecursively(id, nextStatus))
this.getReplies(id).map(k => k.id).map(id => this.setThreadDisplayRecursively(id, nextStatus))
},
showThreadRecursively(id) {
showThreadRecursively (id) {
this.setThreadDisplayRecursively(id, 'showing')
},
setStatusContentProperty(id, name, value) {
setStatusContentProperty (id, name, value) {
this.statusContentPropertiesObject = {
...this.statusContentPropertiesObject,
[id]: {
@ -507,14 +446,10 @@ const conversation = {
}
}
},
toggleStatusContentProperty(id, name) {
this.setStatusContentProperty(
id,
name,
!this.statusContentProperties[id][name]
)
toggleStatusContentProperty (id, name) {
this.setStatusContentProperty(id, name, !this.statusContentProperties[id][name])
},
leastVisibleAncestor(id) {
leastVisibleAncestor (id) {
let cur = id
let parent = this.parentOf(cur)
while (cur) {
@ -528,20 +463,18 @@ const conversation = {
// nothing found, fall back to toplevel
return this.topLevel[0] ? this.topLevel[0].id : undefined
},
diveIntoStatus(id, preventScroll) {
diveIntoStatus (id, preventScroll) {
this.tryScrollTo(id)
},
diveToTopLevel() {
this.tryScrollTo(
this.topLevelAncestorOrSelfId(this.diveRoot) || this.topLevel[0].id
)
diveToTopLevel () {
this.tryScrollTo(this.topLevelAncestorOrSelfId(this.diveRoot) || this.topLevel[0].id)
},
// only used when we are not on a page
undive() {
undive () {
this.inlineDivePosition = null
this.setHighlight(this.statusId)
},
tryScrollTo(id) {
tryScrollTo (id) {
if (!id) {
return
}
@ -570,13 +503,13 @@ const conversation = {
this.setHighlight(id)
})
},
goToCurrent() {
goToCurrent () {
this.tryScrollTo(this.diveRoot || this.topLevel[0].id)
},
statusById(id) {
statusById (id) {
return this.statusMap[id]
},
parentOf(id) {
parentOf (id) {
const status = this.statusById(id)
if (!status) {
return undefined
@ -587,11 +520,11 @@ const conversation = {
}
return parentId
},
parentOrSelf(id) {
parentOrSelf (id) {
return this.parentOf(id) || id
},
// Ancestors of some status, from top to bottom
ancestorsOf(id) {
ancestorsOf (id) {
const ancestors = []
let cur = this.parentOf(id)
while (cur) {
@ -600,7 +533,7 @@ const conversation = {
}
return ancestors
},
topLevelAncestorOrSelfId(id) {
topLevelAncestorOrSelfId (id) {
let cur = id
let parent = this.parentOf(id)
while (parent) {
@ -609,7 +542,7 @@ const conversation = {
}
return cur
},
resetDisplayState() {
resetDisplayState () {
this.undive()
this.threadDisplayStatusObject = {}
}

View file

@ -3,7 +3,7 @@
v-if="!hideStatus"
:style="hiddenStyle"
class="Conversation"
:class="{ '-expanded': isExpanded, panel: isExpanded }"
:class="{ '-expanded' : isExpanded, 'panel' : isExpanded }"
>
<div
v-if="isExpanded"
@ -35,15 +35,13 @@
@click.prevent="diveToTopLevel"
>
<template #icon>
<FAIcon icon="angle-double-left" />
<FAIcon
icon="angle-double-left"
/>
</template>
<template #text>
<span>
{{
$tc('status.show_all_conversation', otherTopLevelCount, {
numStatus: otherTopLevelCount
})
}}
{{ $tc('status.show_all_conversation', otherTopLevelCount, { numStatus: otherTopLevelCount }) }}
</span>
</template>
</i18n-t>
@ -56,20 +54,14 @@
v-for="status in ancestorsOf(diveRoot)"
:key="status.id"
class="thread-ancestor"
:class="{
'thread-ancestor-has-other-replies':
getReplies(status.id).length > 1,
'-faded': shouldFadeAncestors
}"
:class="{'thread-ancestor-has-other-replies': getReplies(status.id).length > 1, '-faded': shouldFadeAncestors}"
>
<status
ref="statusComponent"
:inline-expanded="collapsable && isExpanded"
:statusoid="status"
:expandable="!isExpanded"
:show-pinned="
pinnedStatusIdsObject && pinnedStatusIdsObject[status.id]
"
:show-pinned="pinnedStatusIdsObject && pinnedStatusIdsObject[status.id]"
:focused="focused(status.id)"
:in-conversation="isExpanded"
:highlight="getHighlight()"
@ -77,6 +69,7 @@
:in-profile="inProfile"
:profile-user-id="profileUserId"
class="conversation-status status-fadein panel-body"
:simple-tree="treeViewIsSimple"
:toggle-thread-display="toggleThreadDisplay"
:thread-display-status="threadDisplayStatus"
@ -85,47 +78,28 @@
:total-reply-depth="totalReplyDepth"
:show-other-replies-as-button="showOtherRepliesButtonInsideStatus"
:dive="() => diveIntoStatus(status.id)"
:controlled-showing-tall="
statusContentProperties[status.id].showingTall
"
:controlled-expanding-subject="
statusContentProperties[status.id].expandingSubject
"
:controlled-showing-long-subject="
statusContentProperties[status.id].showingLongSubject
"
:controlled-showing-tall="statusContentProperties[status.id].showingTall"
:controlled-expanding-subject="statusContentProperties[status.id].expandingSubject"
:controlled-showing-long-subject="statusContentProperties[status.id].showingLongSubject"
:controlled-replying="statusContentProperties[status.id].replying"
:controlled-media-playing="
statusContentProperties[status.id].mediaPlaying
"
:controlled-toggle-showing-tall="
() => toggleStatusContentProperty(status.id, 'showingTall')
"
:controlled-toggle-expanding-subject="
() => toggleStatusContentProperty(status.id, 'expandingSubject')
"
:controlled-toggle-showing-long-subject="
() =>
toggleStatusContentProperty(status.id, 'showingLongSubject')
"
:controlled-toggle-replying="
() => toggleStatusContentProperty(status.id, 'replying')
"
:controlled-set-media-playing="
(newVal) =>
toggleStatusContentProperty(status.id, 'mediaPlaying', newVal)
"
:controlled-media-playing="statusContentProperties[status.id].mediaPlaying"
:controlled-toggle-showing-tall="() => toggleStatusContentProperty(status.id, 'showingTall')"
:controlled-toggle-expanding-subject="() => toggleStatusContentProperty(status.id, 'expandingSubject')"
:controlled-toggle-showing-long-subject="() => toggleStatusContentProperty(status.id, 'showingLongSubject')"
:controlled-toggle-replying="() => toggleStatusContentProperty(status.id, 'replying')"
:controlled-set-media-playing="(newVal) => toggleStatusContentProperty(status.id, 'mediaPlaying', newVal)"
@goto="setHighlight"
@toggleExpanded="toggleExpanded"
/>
<div
v-if="
showOtherRepliesButtonBelowStatus &&
getReplies(status.id).length > 1
"
v-if="showOtherRepliesButtonBelowStatus && getReplies(status.id).length > 1"
class="thread-ancestor-dive-box"
>
<div class="thread-ancestor-dive-box-inner">
<div
class="thread-ancestor-dive-box-inner"
>
<i18n-t
tag="button"
scope="global"
@ -134,17 +108,13 @@
@click.prevent="diveIntoStatus(status.id)"
>
<template #icon>
<FAIcon icon="angle-double-right" />
<FAIcon
icon="angle-double-right"
/>
</template>
<template #text>
<span>
{{
$tc(
'status.ancestor_follow',
getReplies(status.id).length - 1,
{ numReplies: getReplies(status.id).length - 1 }
)
}}
{{ $tc('status.ancestor_follow', getReplies(status.id).length - 1, { numReplies: getReplies(status.id).length - 1 }) }}
</span>
</template>
</i18n-t>
@ -157,6 +127,7 @@
:key="status.id"
ref="statusComponent"
:depth="0"
:status="status"
:in-profile="inProfile"
:conversation="conversation"
@ -164,11 +135,13 @@
:is-expanded="isExpanded"
:pinned-status-ids-object="pinnedStatusIdsObject"
:profile-user-id="profileUserId"
:focused="focused"
:get-replies="getReplies"
:highlight="maybeHighlight"
:set-highlight="setHighlight"
:toggle-expanded="toggleExpanded"
:simple="treeViewIsSimple"
:toggle-thread-display="toggleThreadDisplay"
:thread-display-status="threadDisplayStatus"
@ -192,9 +165,7 @@
:inline-expanded="collapsable && isExpanded"
:statusoid="status"
:expandable="!isExpanded"
:show-pinned="
pinnedStatusIdsObject && pinnedStatusIdsObject[status.id]
"
:show-pinned="pinnedStatusIdsObject && pinnedStatusIdsObject[status.id]"
:focused="focused(status.id)"
:in-conversation="isExpanded"
:highlight="getHighlight()"
@ -202,6 +173,7 @@
:in-profile="inProfile"
:profile-user-id="profileUserId"
class="conversation-status status-fadein panel-body"
:toggle-thread-display="toggleThreadDisplay"
:thread-display-status="threadDisplayStatus"
:show-thread-recursively="showThreadRecursively"
@ -210,6 +182,7 @@
:status-content-properties="statusContentProperties"
:set-status-content-property="setStatusContentProperty"
:toggle-status-content-property="toggleStatusContentProperty"
@goto="setHighlight"
@toggleExpanded="toggleExpanded"
/>
@ -260,8 +233,7 @@
border-bottom-color: var(--border, $fallback--border);
border-radius: 0;
/* Make the button stretch along the whole row */
&,
&-inner {
&, &-inner {
display: flex;
align-items: stretch;
flex-direction: column;
@ -281,7 +253,8 @@
.thread-ancestor-has-other-replies .conversation-status,
.thread-ancestor:last-child .conversation-status,
.thread-ancestor:last-child .thread-ancestor-dive-box,
&:last-child .conversation-status {
&:last-child .conversation-status,
&.-expanded .thread-tree .conversation-status {
border-bottom: none;
}
@ -298,8 +271,7 @@
border-left-color: $fallback--cRed;
border-left-color: var(--cRed, $fallback--cRed);
border-radius: 0 0 $fallback--panelRadius $fallback--panelRadius;
border-radius: 0 0 var(--panelRadius, $fallback--panelRadius)
var(--panelRadius, $fallback--panelRadius);
border-radius: 0 0 var(--panelRadius, $fallback--panelRadius) var(--panelRadius, $fallback--panelRadius);
border-bottom: 1px solid var(--border, $fallback--border);
}

View file

@ -1,11 +1,6 @@
import SearchBar from 'components/search_bar/search_bar.vue'
import ConfirmModal from '../confirm_modal/confirm_modal.vue'
import { library } from '@fortawesome/fontawesome-svg-core'
import {
publicTimelineVisible,
federatedTimelineVisible,
bubbleTimelineVisible
} from '../../lib/timeline_visibility'
import {
faSignInAlt,
faSignOutAlt,
@ -24,7 +19,6 @@ import {
faInfoCircle,
faUserTie
} from '@fortawesome/free-solid-svg-icons'
import { mapState } from 'vuex'
library.add(
faSignInAlt,
@ -52,103 +46,76 @@ export default {
},
data: () => ({
searchBarHidden: true,
supportsMask:
window.CSS &&
window.CSS.supports &&
(window.CSS.supports('mask-size', 'contain') ||
supportsMask: window.CSS && window.CSS.supports && (
window.CSS.supports('mask-size', 'contain') ||
window.CSS.supports('-webkit-mask-size', 'contain') ||
window.CSS.supports('-moz-mask-size', 'contain') ||
window.CSS.supports('-ms-mask-size', 'contain') ||
window.CSS.supports('-o-mask-size', 'contain')),
window.CSS.supports('-o-mask-size', 'contain')
),
showingConfirmLogout: false
}),
computed: {
enableMask() {
return this.supportsMask && this.$store.state.instance.logoMask
},
logoStyle() {
enableMask () { return this.supportsMask && this.$store.state.instance.logoMask },
logoStyle () {
return {
visibility: this.enableMask ? 'hidden' : 'visible'
'visibility': this.enableMask ? 'hidden' : 'visible'
}
},
logoMaskStyle() {
return this.enableMask
? {
'mask-image': `url(${this.$store.state.instance.logo})`
}
: {
'background-color': this.enableMask ? '' : 'transparent'
}
logoMaskStyle () {
return this.enableMask ? {
'mask-image': `url(${this.$store.state.instance.logo})`
} : {
'background-color': this.enableMask ? '' : 'transparent'
}
},
logoBgStyle() {
return Object.assign(
{
margin: `${this.$store.state.instance.logoMargin} 0`,
opacity: this.searchBarHidden ? 1 : 0
},
this.enableMask
? {}
: {
'background-color': this.enableMask ? '' : 'transparent'
}
)
logoBgStyle () {
return Object.assign({
'margin': `${this.$store.state.instance.logoMargin} 0`,
opacity: this.searchBarHidden ? 1 : 0
}, this.enableMask ? {} : {
'background-color': this.enableMask ? '' : 'transparent'
})
},
logo() {
return this.$store.state.instance.logo
},
mergedConfig() {
logo () { return this.$store.state.instance.logo },
mergedConfig () {
return this.$store.getters.mergedConfig
},
sitename() {
return this.$store.state.instance.name
},
showNavShortcuts() {
sitename () { return this.$store.state.instance.name },
showNavShortcuts () {
return this.mergedConfig.showNavShortcuts
},
showWiderShortcuts() {
showWiderShortcuts () {
return this.mergedConfig.showWiderShortcuts
},
hideSiteFavicon() {
hideSiteFavicon () {
return this.mergedConfig.hideSiteFavicon
},
hideSiteName() {
hideSiteName () {
return this.mergedConfig.hideSiteName
},
hideSitename() {
return this.$store.state.instance.hideSitename
},
logoLeft() {
return this.$store.state.instance.logoLeft
},
currentUser() {
return this.$store.state.users.currentUser
},
privateMode() {
return this.$store.state.instance.private
},
shouldConfirmLogout() {
hideSitename () { return this.$store.state.instance.hideSitename },
logoLeft () { return this.$store.state.instance.logoLeft },
currentUser () { return this.$store.state.users.currentUser },
privateMode () { return this.$store.state.instance.private },
shouldConfirmLogout () {
return this.$store.getters.mergedConfig.modalOnLogout
},
showBubbleTimeline() {
showBubbleTimeline () {
return this.$store.state.instance.localBubbleInstances.length > 0
},
...mapState({
publicTimelineVisible,
federatedTimelineVisible,
bubbleTimelineVisible
})
}
},
methods: {
scrollToTop() {
scrollToTop () {
window.scrollTo(0, 0)
},
onSearchBarToggled(hidden) {
onSearchBarToggled (hidden) {
this.searchBarHidden = hidden
},
openSettingsModal() {
openSettingsModal () {
this.$store.dispatch('openSettingsModal')
},
openModModal() {
openModModal () {
this.$store.dispatch('openModModal')
}
}

View file

@ -15,7 +15,7 @@
display: grid;
grid-template-rows: var(--navbar-height);
grid-template-columns: 2fr auto 2fr;
grid-template-areas: 'nav-left logo actions';
grid-template-areas: "nav-left logo actions";
box-sizing: border-box;
padding: 0 1.2em;
margin: auto;
@ -24,12 +24,11 @@
&.-logoLeft .inner-nav {
grid-template-columns: auto 2fr 2fr;
grid-template-areas: 'logo nav-left actions';
grid-template-areas: "logo nav-left actions";
}
.button-default {
&,
svg {
&, svg {
color: $fallback--text;
color: var(--btnTopBarText, $fallback--text);
}
@ -50,7 +49,7 @@
color: $fallback--text;
color: var(--btnToggledTopBarText, $fallback--text);
background-color: $fallback--fg;
background-color: var(--btnToggledTopBar, $fallback--fg);
background-color: var(--btnToggledTopBar, $fallback--fg)
}
}
@ -89,25 +88,13 @@
width: 2em;
height: 100%;
text-align: center;
display: inline-block;
&.router-link-active {
// box-shadow: 0 -2px 0 var(--selectedMenuText, $fallback--text) inset;
position: relative;
&:after {
content: '';
position: absolute;
bottom: 0;
left: 0;
width: 100%;
height: 4px;
background-color: $fallback--fg;
background-color: var(--btn, $fallback--fg);
border-radius: $fallback--btnRadius;
border-radius: var(--btnRadius, $fallback--btnRadius);
}
font-size: 1.2em;
margin-top: 0.05em;
.svg-inline--fa {
font-weight: bolder;
color: $fallback--text;
color: var(--selectedMenuText, $fallback--text);
--lightText: var(--selectedMenuLightText, $fallback--lightText);

View file

@ -18,8 +18,8 @@
<img
v-if="!hideSiteFavicon"
class="favicon"
src="/favicon.svg"
/>
src="/favicon.png"
>
<span
v-if="!hideSiteName"
class="site-name"
@ -44,7 +44,6 @@
/>
</router-link>
<router-link
v-if="publicTimelineVisible"
:to="{ name: 'public-timeline' }"
class="nav-icon"
>
@ -56,7 +55,7 @@
/>
</router-link>
<router-link
v-if="bubbleTimelineVisible"
v-if="currentUser && showBubbleTimeline"
:to="{ name: 'bubble-timeline' }"
class="nav-icon"
>
@ -68,7 +67,6 @@
/>
</router-link>
<router-link
v-if="federatedTimelineVisible"
:to="{ name: 'public-external-timeline' }"
class="nav-icon"
>
@ -93,7 +91,7 @@
<img
:src="logo"
:style="logoStyle"
/>
>
</router-link>
<div class="item right actions">
<search-bar
@ -108,10 +106,7 @@
<router-link
v-if="currentUser"
class="nav-icon"
:to="{
name: 'interactions',
params: { username: currentUser.screen_name }
}"
:to="{ name: 'interactions', params: { username: currentUser.screen_name } }"
>
<FAIcon
fixed-width
@ -157,10 +152,7 @@
/>
</button>
<button
v-if="
(currentUser && currentUser.role === 'admin') ||
currentUser.role === 'moderator'
"
v-if="currentUser && currentUser.role === 'admin' || currentUser.role === 'moderator'"
class="button-unstyled nav-icon"
@click.stop="openModModal"
>

View file

@ -31,14 +31,14 @@
.dark-overlay {
&::before {
bottom: 0;
content: ' ';
content: " ";
display: block;
cursor: default;
left: 0;
position: fixed;
right: 0;
top: 0;
background: rgba(27, 31, 35, 0.5);
background: rgba(27,31,35,.5);
z-index: 2000;
}
}
@ -74,7 +74,7 @@
.dialog-modal-footer {
margin: 0;
padding: 0.5em 0.5em;
padding: .5em .5em;
background-color: $fallback--bg;
background-color: var(--bg, $fallback--bg);
border-top: 1px solid $fallback--border;
@ -84,8 +84,9 @@
button {
width: auto;
margin-left: 0.5rem;
margin-left: .5rem;
}
}
}
</style>

View file

@ -2,7 +2,7 @@ import Timeline from '../timeline/timeline.vue'
const DMs = {
computed: {
timeline() {
timeline () {
return this.$store.state.statuses.timelines.dms
}
},

View file

@ -6,18 +6,18 @@ const DomainMuteCard = {
ProgressButton
},
computed: {
user() {
user () {
return this.$store.state.users.currentUser
},
muted() {
muted () {
return this.user.domainMutes.includes(this.domain)
}
},
methods: {
unmuteDomain() {
unmuteDomain () {
return this.$store.dispatch('unmuteDomain', this.domain)
},
muteDomain() {
muteDomain () {
return this.$store.dispatch('muteDomain', this.domain)
}
}

View file

@ -9,7 +9,7 @@
class="btn button-default"
>
{{ $t('domain_mute_card.unmute') }}
<template #progress>
<template v-slot:progress>
{{ $t('domain_mute_card.unmute_progress') }}
</template>
</ProgressButton>
@ -19,7 +19,7 @@
class="btn button-default"
>
{{ $t('domain_mute_card.mute') }}
<template #progress>
<template v-slot:progress>
{{ $t('domain_mute_card.mute_progress') }}
</template>
</ProgressButton>

View file

@ -8,27 +8,27 @@ const EditStatusModal = {
PostStatusForm,
Modal
},
data() {
data () {
return {
resettingForm: false
}
},
computed: {
isLoggedIn() {
isLoggedIn () {
return !!this.$store.state.users.currentUser
},
modalActivated() {
modalActivated () {
return this.$store.state.editStatus.modalActivated
},
isFormVisible() {
isFormVisible () {
return this.isLoggedIn && !this.resettingForm && this.modalActivated
},
params() {
params () {
return this.$store.state.editStatus.params || {}
}
},
watch: {
params(newVal, oldVal) {
params (newVal, oldVal) {
if (get(newVal, 'statusId') !== get(oldVal, 'statusId')) {
this.resettingForm = true
this.$nextTick(() => {
@ -36,16 +36,14 @@ const EditStatusModal = {
})
}
},
isFormVisible(val) {
isFormVisible (val) {
if (val) {
this.$nextTick(
() => this.$el && this.$el.querySelector('textarea').focus()
)
this.$nextTick(() => this.$el && this.$el.querySelector('textarea').focus())
}
}
},
methods: {
doEditStatus({ status, spoilerText, sensitive, media, contentType, poll }) {
doEditStatus ({ status, spoilerText, sensitive, media, contentType, poll }) {
const params = {
store: this.$store,
statusId: this.$store.state.editStatus.params.statusId,
@ -57,8 +55,7 @@ const EditStatusModal = {
contentType
}
return statusPosterService
.editStatus(params)
return statusPosterService.editStatus(params)
.then((data) => {
return data
})
@ -69,7 +66,7 @@ const EditStatusModal = {
}
})
},
closeModal() {
closeModal () {
this.$store.dispatch('closeEditStatusModal')
}
}

View file

@ -11,10 +11,10 @@
<PostStatusForm
class="panel-body"
v-bind="params"
:disable-polls="true"
:disable-visibility-selector="true"
:post-handler="doEditStatus"
@posted="closeModal"
:disablePolls="true"
:disableVisibilitySelector="true"
:post-handler="doEditStatus"
/>
</div>
</Modal>

View file

@ -1,133 +0,0 @@
const EMOJI_SIZE = 32 + 8
const GROUP_TITLE_HEIGHT = 24
const BUFFER_SIZE = 3 * EMOJI_SIZE
const EmojiGrid = {
props: {
groups: {
required: true,
type: Array
}
},
data() {
return {
containerWidth: 0,
containerHeight: 0,
scrollPos: 0,
resizeObserver: null
}
},
mounted() {
const rect = this.$refs.container.getBoundingClientRect()
this.containerWidth = rect.width
this.containerHeight = rect.height
this.resizeObserver = new ResizeObserver((entries) => {
for (const entry of entries) {
this.containerWidth = entry.contentRect.width
this.containerHeight = entry.contentRect.height
}
})
this.resizeObserver.observe(this.$refs.container)
},
beforeUnmount() {
this.resizeObserver.disconnect()
this.resizeObserver = null
},
watch: {
groups() {
// Scroll to top when grid content changes
if (this.$refs.container) {
this.$refs.container.scrollTo(0, 0)
}
},
activeGroup(group) {
this.$emit('activeGroup', group)
}
},
methods: {
onScroll() {
this.scrollPos = this.$refs.container.scrollTop
},
onEmoji(emoji) {
this.$emit('emoji', emoji)
},
scrollToItem(itemId) {
const container = this.$refs.container
if (!container) return
for (const item of this.itemList) {
if (item.id === itemId) {
container.scrollTo(0, item.position.y)
return
}
}
}
},
computed: {
// Total height of scroller content
gridHeight() {
if (this.itemList.length === 0) return 0
const lastItem = this.itemList[this.itemList.length - 1]
return (
lastItem.position.y +
('title' in lastItem ? GROUP_TITLE_HEIGHT : EMOJI_SIZE)
)
},
activeGroup() {
const items = this.itemList
for (let i = items.length - 1; i >= 0; i--) {
const item = items[i]
if ('title' in item && item.position.y <= this.scrollPos) {
return item.id
}
}
return null
},
itemList() {
const items = []
let x = 0
let y = 0
for (const group of this.groups) {
items.push({ position: { x, y }, id: group.id, title: group.text })
if (group.text.length) {
y += GROUP_TITLE_HEIGHT
}
for (const emoji of group.emojis) {
items.push({
position: { x, y },
id: `${group.id}-${emoji.displayText}`,
emoji
})
x += EMOJI_SIZE
if (x + EMOJI_SIZE > this.containerWidth) {
y += EMOJI_SIZE
x = 0
}
}
if (x > 0) {
y += EMOJI_SIZE
x = 0
}
}
return items
},
visibleItems() {
const startPos = this.scrollPos - BUFFER_SIZE
const endPos = this.scrollPos + this.containerHeight + BUFFER_SIZE
return this.itemList.filter((i) => {
return i.position.y >= startPos && i.position.y < endPos
})
},
scrolledClass() {
if (this.scrollPos <= 5) {
return 'scrolled-top'
} else if (this.scrollPos >= this.gridHeight - this.containerHeight - 5) {
return 'scrolled-bottom'
} else {
return 'scrolled-middle'
}
}
}
}
export default EmojiGrid

View file

@ -1,60 +0,0 @@
.emoji {
&-grid {
flex: 1 1 1px;
position: relative;
overflow: auto;
user-select: none;
mask: linear-gradient(to top, white 0, transparent 100%) bottom no-repeat,
linear-gradient(to bottom, white 0, transparent 100%) top no-repeat,
linear-gradient(to top, white, white);
transition: mask-size 150ms;
mask-size: 100% 20px, 100% 20px, auto;
// Autoprefixed seem to ignore this one, and also syntax is different
-webkit-mask-composite: xor;
mask-composite: exclude;
&.scrolled {
&-top {
mask-size: 100% 20px, 100% 0, auto;
}
&-bottom {
mask-size: 100% 0, 100% 20px, auto;
}
}
margin-left: 5px;
min-height: 200px;
}
&-group-title {
position: absolute;
font-size: 0.85em;
width: 100%;
margin: 0;
height: 24px;
display: flex;
align-items: end;
&.disabled {
display: none;
}
}
&-item {
position: absolute;
width: 32px;
height: 32px;
box-sizing: border-box;
display: flex;
font-size: 32px;
align-items: center;
justify-content: center;
margin: 4px;
cursor: pointer;
img {
object-fit: contain;
max-width: 100%;
max-height: 100%;
}
}
}

View file

@ -1,48 +0,0 @@
<template>
<div
ref="container"
class="emoji-grid"
:class="scrolledClass"
@scroll.passive="onScroll"
>
<div
:style="{
height: `${gridHeight}px`
}"
>
<template v-for="item in visibleItems">
<h6
v-if="'title' in item && item.title.length"
:key="'title-' + item.id"
class="emoji-group-title"
:style="{
top: item.position.y + 'px',
left: item.position.x + 'px'
}"
>
{{ item.title }}
</h6>
<span
v-else-if="'emoji' in item"
:key="'emoji-' + item.id"
class="emoji-item"
:title="item.emoji.displayText"
:style="{
top: item.position.y + 'px',
left: item.position.x + 'px'
}"
@click.stop.prevent="onEmoji(item.emoji)"
>
<span v-if="!item.emoji.imageUrl">{{ item.emoji.replacement }}</span>
<img
v-else
:src="item.emoji.imageUrl"
/>
</span>
</template>
</div>
</div>
</template>
<script src="./emoji_grid.js"></script>
<style lang="scss" src="./emoji_grid.scss"></style>

View file

@ -4,9 +4,13 @@ import { take } from 'lodash'
import { findOffset } from '../../services/offset_finder/offset_finder.service.js'
import { library } from '@fortawesome/fontawesome-svg-core'
import { faSmileBeam } from '@fortawesome/free-regular-svg-icons'
import {
faSmileBeam
} from '@fortawesome/free-regular-svg-icons'
library.add(faSmileBeam)
library.add(
faSmileBeam
)
/**
* EmojiInput - augmented inputs for emoji and autocomplete support in inputs
@ -101,7 +105,7 @@ const EmojiInput = {
default: false
}
},
data() {
data () {
return {
input: undefined,
highlighted: 0,
@ -119,34 +123,29 @@ const EmojiInput = {
EmojiPicker
},
computed: {
padEmoji() {
padEmoji () {
return this.$store.getters.mergedConfig.padEmoji
},
showSuggestions() {
return (
this.focused &&
showSuggestions () {
return this.focused &&
this.suggestions &&
this.suggestions.length > 0 &&
!this.showPicker &&
!this.temporarilyHideSuggestions
)
},
textAtCaret() {
textAtCaret () {
return (this.wordAtCaret || {}).word || ''
},
wordAtCaret() {
wordAtCaret () {
if (this.modelValue && this.caret) {
const word =
Completion.wordAtPosition(this.modelValue, this.caret - 1) || {}
const word = Completion.wordAtPosition(this.modelValue, this.caret - 1) || {}
return word
}
}
},
mounted() {
mounted () {
const { root } = this.$refs
const input =
root.querySelector('.emoji-input > input') ||
root.querySelector('.emoji-input > textarea')
const input = root.querySelector('.emoji-input > input') || root.querySelector('.emoji-input > textarea')
if (!input) return
this.input = input
this.resize()
@ -159,7 +158,7 @@ const EmojiInput = {
input.addEventListener('transitionend', this.onTransition)
input.addEventListener('input', this.onInput)
},
unmounted() {
unmounted () {
const { input } = this
if (input) {
input.removeEventListener('blur', this.onBlur)
@ -184,29 +183,29 @@ const EmojiInput = {
// Async: cancel if textAtCaret has changed during wait
if (this.textAtCaret !== newWord) return
if (matchedSuggestions.length <= 0) return
this.suggestions = take(matchedSuggestions, 5).map(
({ imageUrl, ...rest }) => ({
this.suggestions = take(matchedSuggestions, 5)
.map(({ imageUrl, ...rest }) => ({
...rest,
img: imageUrl || ''
})
)
}))
},
suggestions: {
handler(newValue) {
handler (newValue) {
this.$nextTick(this.resize)
},
deep: true
}
},
methods: {
focusPickerInput() {
focusPickerInput () {
const pickerEl = this.$refs.picker.$el
if (!pickerEl) return
const pickerInput = pickerEl.querySelector('input')
if (pickerInput) pickerInput.focus()
},
triggerShowPicker() {
triggerShowPicker () {
this.showPicker = true
this.$refs.picker.startEmojiLoad()
this.$nextTick(() => {
this.scrollIntoView()
this.focusPickerInput()
@ -219,24 +218,21 @@ const EmojiInput = {
this.disableClickOutside = false
}, 0)
},
togglePicker() {
togglePicker () {
this.input.focus()
this.showPicker = !this.showPicker
if (this.showPicker) {
this.scrollIntoView()
this.$refs.picker.startEmojiLoad()
this.$nextTick(this.focusPickerInput)
}
},
replace(replacement) {
const newValue = Completion.replaceWord(
this.modelValue,
this.wordAtCaret,
replacement
)
replace (replacement) {
const newValue = Completion.replaceWord(this.modelValue, this.wordAtCaret, replacement)
this.$emit('update:modelValue', newValue)
this.caret = 0
},
insert({ insertion, keepOpen, surroundingSpace = true }) {
insert ({ insertion, keepOpen, surroundingSpace = true }) {
const before = this.modelValue.substring(0, this.caret) || ''
const after = this.modelValue.substring(this.caret) || ''
@ -255,25 +251,19 @@ const EmojiInput = {
* them, masto seem to be rendering :emoji::emoji: correctly now so why not
*/
const isSpaceRegex = /\s/
const spaceBefore =
surroundingSpace &&
!isSpaceRegex.exec(before.slice(-1)) &&
before.length &&
this.padEmoji > 0
? ' '
: ''
const spaceAfter =
surroundingSpace && !isSpaceRegex.exec(after[0]) && this.padEmoji
? ' '
: ''
const spaceBefore = (surroundingSpace && !isSpaceRegex.exec(before.slice(-1)) && before.length && this.padEmoji > 0) ? ' ' : ''
const spaceAfter = (surroundingSpace && !isSpaceRegex.exec(after[0]) && this.padEmoji) ? ' ' : ''
const newValue = [before, spaceBefore, insertion, spaceAfter, after].join(
''
)
const newValue = [
before,
spaceBefore,
insertion,
spaceAfter,
after
].join('')
this.keepOpen = keepOpen
this.$emit('update:modelValue', newValue)
const position =
this.caret + (insertion + spaceAfter + spaceBefore).length
const position = this.caret + (insertion + spaceAfter + spaceBefore).length
if (!keepOpen) {
this.input.focus()
}
@ -285,17 +275,12 @@ const EmojiInput = {
this.caret = position
})
},
replaceText(e, suggestion) {
replaceText (e, suggestion) {
const len = this.suggestions.length || 0
if (len > 0 || suggestion) {
const chosenSuggestion =
suggestion || this.suggestions[this.highlighted]
const chosenSuggestion = suggestion || this.suggestions[this.highlighted]
const replacement = chosenSuggestion.replacement
const newValue = Completion.replaceWord(
this.modelValue,
this.wordAtCaret,
replacement
)
const newValue = Completion.replaceWord(this.modelValue, this.wordAtCaret, replacement)
this.$emit('update:modelValue', newValue)
this.highlighted = 0
const position = this.wordAtCaret.start + replacement.length
@ -310,7 +295,7 @@ const EmojiInput = {
e.preventDefault()
}
},
cycleBackward(e) {
cycleBackward (e) {
const len = this.suggestions.length || 0
if (len > 1) {
this.highlighted -= 1
@ -322,7 +307,7 @@ const EmojiInput = {
this.highlighted = 0
}
},
cycleForward(e) {
cycleForward (e) {
const len = this.suggestions.length || 0
if (len > 1) {
this.highlighted += 1
@ -334,28 +319,26 @@ const EmojiInput = {
this.highlighted = 0
}
},
scrollIntoView() {
scrollIntoView () {
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
*/
const scrollerRef =
this.$el.closest('.sidebar-scroller') ||
this.$el.closest('.post-form-modal-view') ||
window
const currentScroll =
scrollerRef === window ? scrollerRef.scrollY : scrollerRef.scrollTop
const scrollerHeight =
scrollerRef === window
? scrollerRef.innerHeight
: scrollerRef.offsetHeight
const scrollerRef = this.$el.closest('.sidebar-scroller') ||
this.$el.closest('.post-form-modal-view') ||
window
const currentScroll = scrollerRef === window
? scrollerRef.scrollY
: scrollerRef.scrollTop
const scrollerHeight = scrollerRef === window
? scrollerRef.innerHeight
: scrollerRef.offsetHeight
const scrollerBottomBorder = currentScroll + scrollerHeight
// We check where the bottom border of root element is, this uses findOffset
// to find offset relative to scrollable container (scroller)
const rootBottomBorder =
rootRef.offsetHeight + findOffset(rootRef, scrollerRef).top
const rootBottomBorder = rootRef.offsetHeight + findOffset(rootRef, scrollerRef).top
const bottomDelta = Math.max(0, rootBottomBorder - scrollerBottomBorder)
// could also check top delta but there's no case for it
@ -377,10 +360,10 @@ const EmojiInput = {
}
})
},
onTransition(e) {
onTransition (e) {
this.resize()
},
onBlur(e) {
onBlur (e) {
// Clicking on any suggestion removes focus from autocomplete,
// preventing click handler ever executing.
this.blurTimeout = setTimeout(() => {
@ -389,10 +372,10 @@ const EmojiInput = {
this.resize()
}, 200)
},
onClick(e, suggestion) {
onClick (e, suggestion) {
this.replaceText(e, suggestion)
},
onFocus(e) {
onFocus (e) {
if (this.blurTimeout) {
clearTimeout(this.blurTimeout)
this.blurTimeout = null
@ -406,7 +389,7 @@ const EmojiInput = {
this.resize()
this.temporarilyHideSuggestions = false
},
onKeyUp(e) {
onKeyUp (e) {
const { key } = e
this.setCaret(e)
this.resize()
@ -419,11 +402,11 @@ const EmojiInput = {
this.temporarilyHideSuggestions = false
}
},
onPaste(e) {
onPaste (e) {
this.setCaret(e)
this.resize()
},
onKeyDown(e) {
onKeyDown (e) {
const { ctrlKey, shiftKey, key } = e
if (this.newlineOnCtrlEnter && ctrlKey && key === 'Enter') {
this.insert({ insertion: '\n', surroundingSpace: false })
@ -470,31 +453,31 @@ const EmojiInput = {
this.showPicker = false
this.resize()
},
onInput(e) {
onInput (e) {
this.showPicker = false
this.setCaret(e)
this.resize()
this.$emit('update:modelValue', e.target.value)
},
onClickInput(e) {
onClickInput (e) {
this.showPicker = false
},
onClickOutside(e) {
onClickOutside (e) {
if (this.disableClickOutside) return
this.showPicker = false
},
onStickerUploaded(e) {
onStickerUploaded (e) {
this.showPicker = false
this.$emit('sticker-uploaded', e)
},
onStickerUploadFailed(e) {
onStickerUploadFailed (e) {
this.showPicker = false
this.$emit('sticker-upload-Failed', e)
},
setCaret({ target: { selectionStart } }) {
setCaret ({ target: { selectionStart } }) {
this.caret = selectionStart
},
resize() {
resize () {
const panel = this.$refs.panel
if (!panel) return
const picker = this.$refs.picker.$el
@ -505,12 +488,9 @@ const EmojiInput = {
this.setPlacement(panelBody, panel, offsetBottom)
this.setPlacement(picker, picker, offsetBottom)
},
setPlacement(container, target, offsetBottom) {
setPlacement (container, target, offsetBottom) {
if (!container || !target) return
if (
this.placement === 'bottom' ||
(this.placement === 'auto' && !this.overflowsBottom(container))
) {
if (this.placement === 'bottom' || (this.placement === 'auto' && !this.overflowsBottom(container))) {
target.style.top = offsetBottom + 'px'
target.style.bottom = 'auto'
} else {
@ -518,7 +498,7 @@ const EmojiInput = {
target.style.bottom = this.input.offsetHeight + 'px'
}
},
overflowsBottom(el) {
overflowsBottom (el) {
return el.getBoundingClientRect().bottom > window.innerHeight
}
}

View file

@ -18,7 +18,6 @@
<EmojiPicker
v-if="enableEmojiPicker"
ref="picker"
show-keep-open
:class="{ hide: !showPicker }"
:enable-sticker-picker="enableStickerPicker"
class="emoji-picker-panel"
@ -43,14 +42,11 @@
:class="{ highlighted: index === highlighted }"
@click.stop.prevent="onClick($event, suggestion)"
>
<span
v-if="!suggestion.mfm"
class="image"
>
<span v-if="!suggestion.mfm" class="image">
<img
v-if="suggestion.img"
:src="suggestion.img"
/>
>
<span v-else>{{ suggestion.replacement }}</span>
</span>
<div class="label">
@ -81,7 +77,7 @@
position: absolute;
top: 0;
right: 0;
margin: 0.2em 0.25em;
margin: .2em .25em;
font-size: 1.3em;
cursor: pointer;
line-height: 24px;
@ -97,7 +93,7 @@
margin-top: 2px;
&.hide {
display: none;
display: none
}
}
@ -108,7 +104,7 @@
margin-top: 2px;
&.hide {
display: none;
display: none
}
&-body {
@ -182,8 +178,7 @@
}
}
input,
textarea {
input, textarea {
flex: 1 0 auto;
}
}

View file

@ -1,26 +1,5 @@
const MFM_TAGS = [
'blur',
'bounce',
'flip',
'font',
'jelly',
'jump',
'rainbow',
'rotate',
'shake',
'sparkle',
'spin',
'tada',
'twitch',
'x2',
'x3',
'x4'
].map((tag) => ({
displayText: tag,
detailText: '$[' + tag + ' ]',
replacement: '$[' + tag + ' ]',
mfm: true
}))
const MFM_TAGS = ['blur', 'bounce', 'flip', 'font', 'jelly', 'jump', 'rainbow', 'rotate', 'shake', 'sparkle', 'spin', 'tada', 'twitch', 'x2', 'x3', 'x4']
.map(tag => ({ displayText: tag, detailText: '$[' + tag + ' ]', replacement: '$[' + tag + ' ]', mfm: true }))
/**
* suggest - generates a suggestor function to be used by emoji-input
@ -34,10 +13,10 @@ const MFM_TAGS = [
* doesn't support user linking you can just provide only emoji.
*/
export default (data) => {
export default data => {
const emojiCurry = suggestEmoji(data.emoji)
const usersCurry = data.store && suggestUsers(data.store)
return (input) => {
return input => {
const firstChar = input[0]
if (firstChar === ':' && data.emoji) {
return emojiCurry(input)
@ -46,15 +25,14 @@ export default (data) => {
return usersCurry(input)
}
if (firstChar === '$') {
return MFM_TAGS.filter(
({ replacement }) => replacement.toLowerCase().indexOf(input) !== -1
)
return MFM_TAGS
.filter(({ replacement }) => replacement.toLowerCase().indexOf(input) !== -1)
}
return []
}
}
export const suggestEmoji = (emojis) => (input) => {
export const suggestEmoji = emojis => input => {
const noPrefix = input.toLowerCase().substr(1)
return emojis
.filter(({ displayText }) => displayText.toLowerCase().match(noPrefix))
@ -107,7 +85,7 @@ export const suggestUsers = ({ dispatch, state }) => {
})
}
return async (input) => {
return async input => {
const noPrefix = input.toLowerCase().substr(1)
if (previousQuery === noPrefix) return suggestions
@ -121,47 +99,36 @@ export const suggestUsers = ({ dispatch, state }) => {
await debounceUserSearch(noPrefix)
}
const newSuggestions = state.users.users
.filter(
(user) =>
user.screen_name.toLowerCase().startsWith(noPrefix) ||
user.name.toLowerCase().startsWith(noPrefix)
)
.slice(0, 20)
.sort((a, b) => {
let aScore = 0
let bScore = 0
const newSuggestions = state.users.users.filter(
user =>
user.screen_name.toLowerCase().startsWith(noPrefix) ||
user.name.toLowerCase().startsWith(noPrefix)
).slice(0, 20).sort((a, b) => {
let aScore = 0
let bScore = 0
// Matches on screen name (i.e. user@instance) makes a priority
aScore += a.screen_name.toLowerCase().startsWith(noPrefix) ? 2 : 0
bScore += b.screen_name.toLowerCase().startsWith(noPrefix) ? 2 : 0
// Matches on screen name (i.e. user@instance) makes a priority
aScore += a.screen_name.toLowerCase().startsWith(noPrefix) ? 2 : 0
bScore += b.screen_name.toLowerCase().startsWith(noPrefix) ? 2 : 0
// Matches on name takes second priority
aScore += a.name.toLowerCase().startsWith(noPrefix) ? 1 : 0
bScore += b.name.toLowerCase().startsWith(noPrefix) ? 1 : 0
// Matches on name takes second priority
aScore += a.name.toLowerCase().startsWith(noPrefix) ? 1 : 0
bScore += b.name.toLowerCase().startsWith(noPrefix) ? 1 : 0
const diff = (bScore - aScore) * 10
const diff = (bScore - aScore) * 10
// Then sort alphabetically
const nameAlphabetically = a.name > b.name ? 1 : -1
const screenNameAlphabetically = a.screen_name > b.screen_name ? 1 : -1
// Then sort alphabetically
const nameAlphabetically = a.name > b.name ? 1 : -1
const screenNameAlphabetically = a.screen_name > b.screen_name ? 1 : -1
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 + ' '
})
)
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 + ' '
}))
/* eslint-enable camelcase */
suggestions = newSuggestions || []

View file

@ -1,6 +1,5 @@
import { defineAsyncComponent } from 'vue'
import Checkbox from '../checkbox/checkbox.vue'
import EmojiGrid from '../emoji_grid/emoji_grid.vue'
import { library } from '@fortawesome/fontawesome-svg-core'
import {
faBoxOpen,
@ -9,7 +8,18 @@ import {
} from '@fortawesome/free-solid-svg-icons'
import { trim, escapeRegExp, startCase } from 'lodash'
library.add(faBoxOpen, faStickyNote, faSmileBeam)
library.add(
faBoxOpen,
faStickyNote,
faSmileBeam
)
// At widest, approximately 20 emoji are visible in a row,
// loading 3 rows, could be overkill for narrow picker
const LOAD_EMOJI_BY = 60
// When to start loading new batch emoji, in pixels
const LOAD_EMOJI_MARGIN = 64
const EmojiPicker = {
props: {
@ -17,88 +27,147 @@ const EmojiPicker = {
required: false,
type: Boolean,
default: false
},
showKeepOpen: {
required: false,
type: Boolean,
default: false
}
},
data() {
data () {
return {
keyword: '',
activeGroup: 'standard',
showingStickers: false,
keepOpen: false
groupsScrolledClass: 'scrolled-top',
keepOpen: false,
customEmojiBufferSlice: LOAD_EMOJI_BY,
customEmojiTimeout: null,
customEmojiLoadAllConfirmed: false
}
},
components: {
StickerPicker: defineAsyncComponent(() =>
import('../sticker_picker/sticker_picker.vue')
),
Checkbox,
EmojiGrid
StickerPicker: defineAsyncComponent(() => import('../sticker_picker/sticker_picker.vue')),
Checkbox
},
methods: {
onStickerUploaded(e) {
onStickerUploaded (e) {
this.$emit('sticker-uploaded', e)
},
onStickerUploadFailed(e) {
onStickerUploadFailed (e) {
this.$emit('sticker-upload-failed', e)
},
onEmoji(emoji) {
const value = emoji.imageUrl
? `:${emoji.displayText}:`
: emoji.replacement
onEmoji (emoji) {
const value = emoji.imageUrl ? `:${emoji.displayText}:` : emoji.replacement
this.$emit('emoji', { insertion: value, keepOpen: this.keepOpen })
this.$store.commit('emojiUsed', emoji)
},
onWheel(e) {
e.preventDefault()
this.$refs['emoji-tabs'].scrollBy(e.deltaY, 0)
onScroll (e) {
const target = (e && e.target) || this.$refs['emoji-groups']
this.updateScrolledClass(target)
this.scrolledGroup(target)
this.triggerLoadMore(target)
},
highlight(key) {
highlight (key) {
this.setShowStickers(false)
this.activeGroup = key
if (this.keyword.length) {
this.$refs.emojiGrid.scrollToItem(key)
},
updateScrolledClass (target) {
if (target.scrollTop <= 5) {
this.groupsScrolledClass = 'scrolled-top'
} else if (target.scrollTop >= target.scrollTopMax - 5) {
this.groupsScrolledClass = 'scrolled-bottom'
} else {
this.groupsScrolledClass = 'scrolled-middle'
}
},
onActiveGroup(group) {
this.activeGroup = group
triggerLoadMore (target) {
const ref = this.$refs['group-end-custom']
if (!ref) return
const bottom = ref.offsetTop + ref.offsetHeight
const scrollerBottom = target.scrollTop + target.clientHeight
const scrollerTop = target.scrollTop
const scrollerMax = target.scrollHeight
// Loads more emoji when they come into view
const approachingBottom = bottom - scrollerBottom < LOAD_EMOJI_MARGIN
// Always load when at the very top in case there's no scroll space yet
const atTop = scrollerTop < 5
// Don't load when looking at unicode category or at the very bottom
const bottomAboveViewport = bottom < scrollerTop || scrollerBottom === scrollerMax
if (!bottomAboveViewport && (approachingBottom || atTop)) {
this.loadEmoji()
}
},
toggleStickers() {
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) {
setShowStickers (value) {
this.showingStickers = value
},
filterByKeyword(list) {
filterByKeyword (list) {
if (this.keyword === '') return list
const regex = new RegExp(escapeRegExp(trim(this.keyword)), 'i')
return list.filter((emoji) => {
return (
regex.test(emoji.displayText) ||
(!emoji.imageUrl && emoji.replacement === this.keyword)
)
return list.filter(emoji => {
return regex.test(emoji.displayText)
})
}
},
watch: {
keyword () {
this.customEmojiLoadAllConfirmed = false
this.onScroll()
this.startEmojiLoad(true)
}
},
computed: {
activeGroupView() {
activeGroupView () {
return this.showingStickers ? '' : this.activeGroup
},
stickersAvailable() {
stickersAvailable () {
if (this.$store.state.instance.stickers) {
return this.$store.state.instance.stickers.length > 0
}
return 0
},
filteredEmoji() {
return this.filterByKeyword(this.$store.state.instance.customEmoji || [])
filteredEmoji () {
return this.filterByKeyword(
this.$store.state.instance.customEmoji || []
)
},
emojis() {
const recentEmojis = this.$store.getters.recentEmojis
customEmojiBuffer () {
return this.filteredEmoji.slice(0, this.customEmojiBufferSlice)
},
emojis () {
const standardEmojis = this.$store.state.instance.emoji || []
const customEmojis = this.sortedEmoji
const emojiPacks = []
@ -111,15 +180,6 @@ const EmojiPicker = {
})
})
return [
{
id: 'recent',
text: this.$t('emoji.recent'),
first: {
imageUrl: '',
replacement: '🕒'
},
emojis: this.filterByKeyword(recentEmojis)
},
{
id: 'standard',
text: this.$t('emoji.unicode'),
@ -131,7 +191,7 @@ const EmojiPicker = {
}
].concat(emojiPacks)
},
sortedEmoji() {
sortedEmoji () {
const customEmojis = this.$store.state.instance.customEmoji || []
const sortedEmojiGroups = new Map()
customEmojis.forEach((emoji) => {
@ -143,22 +203,19 @@ const EmojiPicker = {
})
return new Map([...sortedEmojiGroups.entries()].sort())
},
emojisView() {
emojisView () {
if (this.keyword === '') {
return this.emojis.filter((pack) => {
return this.emojis.filter(pack => {
return pack.id === this.activeGroup
})
} else {
return this.emojis.filter((pack) => {
return this.emojis.filter(pack => {
return pack.emojis.length > 0
})
}
},
stickerPickerEnabled() {
return (
(this.$store.state.instance.stickers || []).length !== 0 &&
this.enableStickerPicker
)
stickerPickerEnabled () {
return (this.$store.state.instance.stickers || []).length !== 0 && this.enableStickerPicker
}
}
}

View file

@ -1,23 +1,5 @@
@import '../../_variables.scss';
// The worst query selector ever
// selects ONLY emojis pickers in replies in notifications
// who thought this was a good idea?
.notification
> .Status
> .status-container
> .post-status-form
> form
> .form-group
> .emoji-input
> .emoji-picker {
max-width: 100%;
left: 0;
@media (min-width: 1300px) {
left: -30px;
}
}
.Notification {
.emoji-picker {
min-width: 160%;
@ -25,7 +7,7 @@
overflow: hidden;
left: -70%;
max-width: 100%;
@media (min-width: 800px) and (max-width: 1280px) {
@media (min-width: 800px) and (max-width: 1300px) {
left: -50%;
min-width: 50%;
max-width: 130%;
@ -36,10 +18,6 @@
min-width: 50%;
max-width: 130%;
}
.Status > .emoji-picker {
z-index: 1000;
}
}
}
.emoji-picker {
@ -92,6 +70,10 @@
flex-grow: 1;
}
.emoji-groups {
min-height: 200px;
}
.additional-tabs {
border-left: 1px solid;
border-left-color: $fallback--icon;
@ -118,7 +100,7 @@
justify-content: center;
width: 32px;
height: 32px;
padding: 0.4em;
padding: .4em;
cursor: pointer;
img {
@ -151,7 +133,7 @@
}
.sticker-picker {
flex: 1 1 auto;
flex: 1 1 auto
}
.stickers,
@ -170,12 +152,14 @@
}
}
.emoji-search {
padding: 5px;
flex: 0 0 auto;
.emoji {
&-search {
padding: 5px;
flex: 0 0 auto;
input {
width: 100%;
input {
width: 100%;
}
}
&-groups {
@ -184,8 +168,8 @@
overflow: auto;
user-select: none;
mask: linear-gradient(to top, white 0, transparent 100%) bottom no-repeat,
linear-gradient(to bottom, white 0, transparent 100%) top no-repeat,
linear-gradient(to top, white, white);
linear-gradient(to bottom, white 0, transparent 100%) top no-repeat,
linear-gradient(to top, white, white);
transition: mask-size 150ms;
mask-size: 100% 20px, 100% 20px, auto;
// Autoprefixed seem to ignore this one, and also syntax is different
@ -237,5 +221,7 @@
max-height: 100%;
}
}
}
}

View file

@ -1,11 +1,7 @@
<template>
<div class="emoji-picker panel panel-default panel-body">
<div class="heading">
<span
ref="emoji-tabs"
class="emoji-tabs"
@wheel="onWheel"
>
<span class="emoji-tabs">
<span
v-for="group in emojis"
:key="group.id"
@ -17,18 +13,16 @@
:title="group.text"
@click.prevent="highlight(group.id)"
>
<span v-if="!group.first.imageUrl">{{
group.first.replacement
}}</span>
<span v-if="!group.first.imageUrl">{{ group.first.replacement }}</span>
<img
v-else
:src="group.first.imageUrl"
/>
>
</span>
<span
v-if="stickerPickerEnabled"
class="stickers-tab-icon emoji-tabs-item"
:class="{ active: showingStickers }"
:class="{active: showingStickers}"
:title="$t('emoji.stickers')"
@click.prevent="toggleStickers"
>
@ -42,7 +36,7 @@
<div class="content">
<div
class="emoji-content"
:class="{ hidden: showingStickers }"
:class="{hidden: showingStickers}"
>
<div class="emoji-search">
<input
@ -51,17 +45,13 @@
class="form-control"
:placeholder="$t('emoji.search_emoji')"
@input="$event.target.composing = false"
/>
>
</div>
<EmojiGrid
ref="emojiGrid"
:groups="emojisView"
@emoji="onEmoji"
@active-group="onActiveGroup"
/>
<div
v-if="showKeepOpen"
class="keep-open"
ref="emoji-groups"
class="emoji-groups"
:class="groupsScrolledClass"
@scroll="onScroll"
>
<div
v-for="group in emojisView"
@ -85,7 +75,7 @@
<img
v-else
:src="emoji.imageUrl"
/>
>
</span>
<span :ref="'group-end-' + group.id" />
</div>

View file

@ -3,11 +3,6 @@ import UserListPopover from '../user_list_popover/user_list_popover.vue'
const EMOJI_REACTION_COUNT_CUTOFF = 12
const findEmojiByReplacement = (state, replacement) => {
const allEmojis = state.instance.emoji.concat(state.instance.customEmoji)
return allEmojis.find((emoji) => emoji.replacement === replacement)
}
const EmojiReactions = {
name: 'EmojiReactions',
components: {
@ -19,20 +14,18 @@ const EmojiReactions = {
showAll: false
}),
computed: {
tooManyReactions() {
tooManyReactions () {
return this.status.emoji_reactions.length > EMOJI_REACTION_COUNT_CUTOFF
},
emojiReactions() {
emojiReactions () {
return this.showAll
? this.status.emoji_reactions
: this.status.emoji_reactions.slice(0, EMOJI_REACTION_COUNT_CUTOFF)
},
showMoreString() {
return `+${
this.status.emoji_reactions.length - EMOJI_REACTION_COUNT_CUTOFF
}`
showMoreString () {
return `+${this.status.emoji_reactions.length - EMOJI_REACTION_COUNT_CUTOFF}`
},
accountsForEmoji() {
accountsForEmoji () {
return this.status.emoji_reactions.reduce((acc, reaction) => {
if (reaction.url) {
acc[reaction.url] = reaction.accounts || []
@ -42,32 +35,30 @@ const EmojiReactions = {
return acc
}, {})
},
loggedIn() {
loggedIn () {
return !!this.$store.state.users.currentUser
}
},
methods: {
toggleShowAll() {
toggleShowAll () {
this.showAll = !this.showAll
},
reactedWith(emoji) {
return this.status.emoji_reactions.find((r) => r.name === emoji).me
reactedWith (emoji) {
return this.status.emoji_reactions.find(r => r.name === emoji).me
},
fetchEmojiReactionsByIfMissing() {
const hasNoAccounts = this.status.emoji_reactions.find((r) => !r.accounts)
fetchEmojiReactionsByIfMissing () {
const hasNoAccounts = this.status.emoji_reactions.find(r => !r.accounts)
if (hasNoAccounts) {
this.$store.dispatch('fetchEmojiReactionsBy', this.status.id)
}
},
reactWith(emoji) {
reactWith (emoji) {
this.$store.dispatch('reactWithEmoji', { id: this.status.id, emoji })
const emojiObject = findEmojiByReplacement(this.$store.state, emoji)
this.$store.commit('emojiUsed', emojiObject)
},
unreact(emoji) {
unreact (emoji) {
this.$store.dispatch('unreactWithEmoji', { id: this.status.id, emoji })
},
emojiOnClick(emoji, event) {
emojiOnClick (emoji, event) {
if (!this.loggedIn) return
if (this.reactedWith(emoji)) {

View file

@ -1,35 +1,28 @@
<template>
<div class="emoji-reactions">
<UserListPopover
v-for="reaction in emojiReactions"
v-for="(reaction) in emojiReactions"
:key="reaction.url || reaction.name"
:users="accountsForEmoji[reaction.url || reaction.name]"
>
<button
class="emoji-reaction btn button-default"
:class="{
'picked-reaction': reactedWith(reaction.name),
'not-clickable': !loggedIn
}"
:class="{ 'picked-reaction': reactedWith(reaction.name), 'not-clickable': !loggedIn }"
@click="emojiOnClick(reaction.name, $event)"
@mouseenter="fetchEmojiReactionsByIfMissing()"
>
<span
v-if="reaction.url !== null"
class="emoji-button-inner"
>
<img
:src="reaction.url"
:title="reaction.name"
class="reaction-emoji"
width="2.55em"
/>
>
{{ reaction.count }}
</span>
<span
v-else
class="emoji-button-inner"
>
<span v-else>
<span class="reaction-emoji unicode-emoji">
{{ reaction.name }}
</span>
@ -48,7 +41,7 @@
</div>
</template>
<script src="./emoji_reactions.js"></script>
<script src="./emoji_reactions.js" ></script>
<style lang="scss">
@import '../../_variables.scss';
@ -56,11 +49,10 @@
display: flex;
margin-top: 0.25em;
flex-wrap: wrap;
container-type: inline-size;
}
.unicode-emoji {
font-size: 128%;
font-size: 210%;
}
.emoji-reaction {
@ -68,20 +60,13 @@
margin-right: 0.5em;
margin-top: 0.5em;
display: flex;
height: 28px;
align-items: center;
justify-content: center;
box-sizing: border-box;
.reaction-emoji {
width: auto;
max-width: 96cqw;
height: 2.55em !important;
width: 2.55em !important;
margin-right: 0.25em;
}
img.reaction-emoji {
width: 1.55em !important;
display: block;
}
&:focus {
outline: none;
}
@ -108,12 +93,9 @@
}
.button-default.picked-reaction {
background: none;
padding: 1px 0.5em;
.emoji-button-inner {
display: flex;
align-items: center;
}
border: 1px solid var(--accent, $fallback--link);
margin-left: -1px; // offset the border, can't use inset shadows either
margin-right: calc(0.5em - 1px);
}
</style>

View file

@ -1,7 +1,9 @@
import { library } from '@fortawesome/fontawesome-svg-core'
import { faCircleNotch } from '@fortawesome/free-solid-svg-icons'
library.add(faCircleNotch)
library.add(
faCircleNotch
)
const Exporter = {
props: {
@ -16,30 +18,26 @@ const Exporter = {
exportButtonLabel: { type: String },
processingMessage: { type: String }
},
data() {
data () {
return {
processing: false
}
},
methods: {
process() {
process () {
this.processing = true
this.getContent().then((content) => {
const fileToDownload = document.createElement('a')
fileToDownload.setAttribute(
'href',
'data:text/plain;charset=utf-8,' + encodeURIComponent(content)
)
fileToDownload.setAttribute('download', this.filename)
fileToDownload.style.display = 'none'
document.body.appendChild(fileToDownload)
fileToDownload.click()
document.body.removeChild(fileToDownload)
// Add delay before hiding processing state since browser takes some time to handle file download
setTimeout(() => {
this.processing = false
}, 2000)
})
this.getContent()
.then((content) => {
const fileToDownload = document.createElement('a')
fileToDownload.setAttribute('href', 'data:text/plain;charset=utf-8,' + encodeURIComponent(content))
fileToDownload.setAttribute('download', this.filename)
fileToDownload.style.display = 'none'
document.body.appendChild(fileToDownload)
fileToDownload.click()
document.body.removeChild(fileToDownload)
// Add delay before hiding processing state since browser takes some time to handle file download
setTimeout(() => { this.processing = false }, 2000)
})
}
}
}

View file

@ -15,7 +15,6 @@ import {
faBookmark as faBookmarkReg,
faFlag
} from '@fortawesome/free-regular-svg-icons'
import { mapState } from 'vuex'
library.add(
faEllipsisH,
@ -36,7 +35,7 @@ const ExtraButtons = {
Popover,
ConfirmModal
},
data() {
data () {
return {
expanded: false,
showingDeleteDialog: false,
@ -44,206 +43,154 @@ const ExtraButtons = {
}
},
methods: {
deleteStatus() {
deleteStatus () {
if (this.shouldConfirmDelete) {
this.showDeleteStatusConfirmDialog()
} else {
this.doDeleteStatus()
}
},
doDeleteStatus() {
doDeleteStatus () {
this.$store.dispatch('deleteStatus', { id: this.status.id })
this.hideDeleteStatusConfirmDialog()
},
showDeleteStatusConfirmDialog() {
showDeleteStatusConfirmDialog () {
this.showingDeleteDialog = true
},
hideDeleteStatusConfirmDialog() {
hideDeleteStatusConfirmDialog () {
this.showingDeleteDialog = false
},
translateStatus() {
translateStatus () {
if (this.noTranslationTargetSet) {
this.$store.dispatch('pushGlobalNotice', {
messageKey: 'toast.no_translation_target_set',
level: 'info'
})
this.$store.dispatch('pushGlobalNotice', { messageKey: 'toast.no_translation_target_set', level: 'info' })
}
const translateTo =
this.$store.getters.mergedConfig.translationLanguage ||
this.$store.state.instance.interfaceLanguage
this.$store
.dispatch('translateStatus', {
id: this.status.id,
language: translateTo
})
const translateTo = this.$store.getters.mergedConfig.translationLanguage || this.$store.state.instance.interfaceLanguage
this.$store.dispatch('translateStatus', { id: this.status.id, language: translateTo })
.then(() => this.$emit('onSuccess'))
.catch((err) => this.$emit('onError', err.error.error))
.catch(err => this.$emit('onError', err.error.error))
},
pinStatus() {
this.$store
.dispatch('pinStatus', this.status.id)
pinStatus () {
this.$store.dispatch('pinStatus', this.status.id)
.then(() => this.$emit('onSuccess'))
.catch((err) => this.$emit('onError', err.error.error))
.catch(err => this.$emit('onError', err.error.error))
},
unpinStatus() {
this.$store
.dispatch('unpinStatus', this.status.id)
unpinStatus () {
this.$store.dispatch('unpinStatus', this.status.id)
.then(() => this.$emit('onSuccess'))
.catch((err) => this.$emit('onError', err.error.error))
.catch(err => this.$emit('onError', err.error.error))
},
muteConversation() {
this.$store
.dispatch('muteConversation', this.status.id)
muteConversation () {
this.$store.dispatch('muteConversation', this.status.id)
.then(() => this.$emit('onSuccess'))
.catch((err) => this.$emit('onError', err.error.error))
.catch(err => this.$emit('onError', err.error.error))
},
unmuteConversation() {
this.$store
.dispatch('unmuteConversation', this.status.id)
unmuteConversation () {
this.$store.dispatch('unmuteConversation', this.status.id)
.then(() => this.$emit('onSuccess'))
.catch((err) => this.$emit('onError', err.error.error))
.catch(err => this.$emit('onError', err.error.error))
},
copyLink() {
navigator.clipboard
.writeText(this.statusLink)
copyLink () {
navigator.clipboard.writeText(this.statusLink)
.then(() => this.$emit('onSuccess'))
.catch((err) => this.$emit('onError', err.error.error))
.catch(err => this.$emit('onError', err.error.error))
},
bookmarkStatus() {
this.$store
.dispatch('bookmark', { id: this.status.id })
bookmarkStatus () {
this.$store.dispatch('bookmark', { id: this.status.id })
.then(() => this.$emit('onSuccess'))
.catch((err) => this.$emit('onError', err.error.error))
.catch(err => this.$emit('onError', err.error.error))
},
unbookmarkStatus() {
this.$store
.dispatch('unbookmark', { id: this.status.id })
unbookmarkStatus () {
this.$store.dispatch('unbookmark', { id: this.status.id })
.then(() => this.$emit('onSuccess'))
.catch((err) => this.$emit('onError', err.error.error))
.catch(err => this.$emit('onError', err.error.error))
},
reportStatus() {
this.$store.dispatch('openUserReportingModal', {
userId: this.status.user.id,
statusIds: [this.status.id]
})
reportStatus () {
this.$store.dispatch('openUserReportingModal', { userId: this.status.user.id, statusIds: [this.status.id] })
},
editStatus() {
this.$store
.dispatch('fetchStatusSource', { id: this.status.id })
.then((data) =>
this.$store.dispatch('openEditStatusModal', {
statusId: this.status.id,
subject: data.spoiler_text,
statusText: data.text,
statusIsSensitive: this.status.nsfw,
statusPoll: this.status.poll,
statusFiles: [...this.status.attachments],
visibility: this.status.visibility,
statusContentType: data.content_type
})
)
editStatus () {
this.$store.dispatch('fetchStatusSource', { id: this.status.id })
.then(data => this.$store.dispatch('openEditStatusModal', {
statusId: this.status.id,
subject: data.spoiler_text,
statusText: data.text,
statusIsSensitive: this.status.nsfw,
statusPoll: this.status.poll,
statusFiles: [...this.status.attachments],
visibility: this.status.visibility,
statusContentType: data.content_type
}))
},
showStatusHistory() {
showStatusHistory () {
const originalStatus = { ...this.status }
const stripFieldsList = [
'attachments',
'created_at',
'emojis',
'text',
'raw_html',
'nsfw',
'poll',
'summary',
'summary_raw_html'
]
stripFieldsList.forEach((p) => delete originalStatus[p])
const stripFieldsList = ['attachments', 'created_at', 'emojis', 'text', 'raw_html', 'nsfw', 'poll', 'summary', 'summary_raw_html']
stripFieldsList.forEach(p => delete originalStatus[p])
this.$store.dispatch('openStatusHistoryModal', originalStatus)
},
redraftStatus() {
redraftStatus () {
if (this.shouldConfirmDelete) {
this.showRedraftStatusConfirmDialog()
} else {
this.doRedraftStatus()
}
},
doRedraftStatus() {
this.$store
.dispatch('fetchStatusSource', { id: this.status.id })
.then((data) =>
this.$store.dispatch('openPostStatusModal', {
isRedraft: true,
statusId: this.status.id,
subject: data.spoiler_text,
statusText: data.text,
statusIsSensitive: this.status.nsfw,
statusPoll: this.status.poll,
statusFiles: [...this.status.attachments],
statusScope: this.status.visibility,
statusLanguage: this.status.language,
statusContentType: data.content_type
})
)
doRedraftStatus () {
this.$store.dispatch('fetchStatusSource', { id: this.status.id })
.then(data => this.$store.dispatch('openPostStatusModal', {
isRedraft: true,
statusId: this.status.id,
subject: data.spoiler_text,
statusText: data.text,
statusIsSensitive: this.status.nsfw,
statusPoll: this.status.poll,
statusFiles: [...this.status.attachments],
statusScope: this.status.visibility,
statusContentType: data.content_type
}))
this.doDeleteStatus()
},
showRedraftStatusConfirmDialog() {
showRedraftStatusConfirmDialog () {
this.showingRedraftDialog = true
},
hideRedraftStatusConfirmDialog() {
hideRedraftStatusConfirmDialog () {
this.showingRedraftDialog = false
}
},
computed: {
currentUser() {
return this.$store.state.users.currentUser
},
canDelete() {
if (!this.currentUser) {
return
}
const superuser =
this.currentUser.rights.moderator || this.currentUser.rights.admin
currentUser () { return this.$store.state.users.currentUser },
canDelete () {
if (!this.currentUser) { return }
const superuser = this.currentUser.rights.moderator || this.currentUser.rights.admin
return superuser || this.status.user.id === this.currentUser.id
},
ownStatus() {
ownStatus () {
return this.status.user.id === this.currentUser.id
},
canPin() {
return (
this.ownStatus &&
(this.status.visibility === 'public' ||
this.status.visibility === 'unlisted')
)
canPin () {
return this.ownStatus && (this.status.visibility === 'public' || this.status.visibility === 'unlisted')
},
canMute() {
canMute () {
return !!this.currentUser
},
canTranslate() {
canTranslate () {
return this.$store.state.instance.translationEnabled === true
},
noTranslationTargetSet() {
noTranslationTargetSet () {
return this.$store.getters.mergedConfig.translationLanguage === undefined
},
statusLink() {
statusLink () {
if (this.status.is_local) {
return `${this.$store.state.instance.server}${
this.$router.resolve({
name: 'conversation',
params: { id: this.status.id }
}).href
}`
return `${this.$store.state.instance.server}${this.$router.resolve({ name: 'conversation', params: { id: this.status.id } }).href}`
} else {
return this.status.external_url
}
},
shouldConfirmDelete() {
shouldConfirmDelete () {
return this.$store.getters.mergedConfig.modalOnDelete
},
isEdited() {
isEdited () {
return this.status.edited_at !== null
},
editingAvailable() {
return this.$store.state.instance.editingAvailable
}
editingAvailable () { return this.$store.state.instance.editingAvailable }
}
}

View file

@ -7,7 +7,7 @@
:bound-to="{ x: 'container' }"
remove-padding
>
<template #content="{ close }">
<template v-slot:content="{close}">
<div class="dropdown-menu">
<button
v-if="canMute && !status.thread_muted"
@ -17,7 +17,7 @@
<FAIcon
fixed-width
icon="eye-slash"
/><span>{{ $t('status.mute_conversation') }}</span>
/><span>{{ $t("status.mute_conversation") }}</span>
</button>
<button
v-if="canMute && status.thread_muted"
@ -27,7 +27,7 @@
<FAIcon
fixed-width
icon="eye-slash"
/><span>{{ $t('status.unmute_conversation') }}</span>
/><span>{{ $t("status.unmute_conversation") }}</span>
</button>
<button
v-if="!status.pinned && canPin"
@ -38,7 +38,7 @@
<FAIcon
fixed-width
icon="thumbtack"
/><span>{{ $t('status.pin') }}</span>
/><span>{{ $t("status.pin") }}</span>
</button>
<button
v-if="status.pinned && canPin"
@ -49,7 +49,7 @@
<FAIcon
fixed-width
icon="thumbtack"
/><span>{{ $t('status.unpin') }}</span>
/><span>{{ $t("status.unpin") }}</span>
</button>
<button
v-if="!status.bookmarked"
@ -60,7 +60,7 @@
<FAIcon
fixed-width
:icon="['far', 'bookmark']"
/><span>{{ $t('status.bookmark') }}</span>
/><span>{{ $t("status.bookmark") }}</span>
</button>
<button
v-if="status.bookmarked"
@ -71,7 +71,7 @@
<FAIcon
fixed-width
icon="bookmark"
/><span>{{ $t('status.unbookmark') }}</span>
/><span>{{ $t("status.unbookmark") }}</span>
</button>
<button
v-if="ownStatus && editingAvailable"
@ -82,7 +82,7 @@
<FAIcon
fixed-width
icon="pen"
/><span>{{ $t('status.edit') }}</span>
/><span>{{ $t("status.edit") }}</span>
</button>
<button
v-if="isEdited && editingAvailable"
@ -93,7 +93,7 @@
<FAIcon
fixed-width
icon="history"
/><span>{{ $t('status.edit_history') }}</span>
/><span>{{ $t("status.edit_history") }}</span>
</button>
<button
v-if="ownStatus"
@ -104,7 +104,7 @@
<FAIcon
fixed-width
icon="file-pen"
/><span>{{ $t('status.redraft') }}</span>
/><span>{{ $t("status.redraft") }}</span>
</button>
<button
v-if="canDelete"
@ -115,7 +115,7 @@
<FAIcon
fixed-width
icon="times"
/><span>{{ $t('status.delete') }}</span>
/><span>{{ $t("status.delete") }}</span>
</button>
<button
class="button-default dropdown-item dropdown-item-icon"
@ -125,7 +125,7 @@
<FAIcon
fixed-width
icon="share-alt"
/><span>{{ $t('status.copy_link') }}</span>
/><span>{{ $t("status.copy_link") }}</span>
</button>
<a
v-if="!status.is_local"
@ -137,7 +137,7 @@
<FAIcon
fixed-width
icon="external-link-alt"
/><span>{{ $t('status.external_source') }}</span>
/><span>{{ $t("status.external_source") }}</span>
</a>
<button
class="button-default dropdown-item dropdown-item-icon"
@ -147,7 +147,7 @@
<FAIcon
fixed-width
:icon="['far', 'flag']"
/><span>{{ $t('user_card.report') }}</span>
/><span>{{ $t("user_card.report") }}</span>
</button>
<button
v-if="canTranslate"
@ -158,7 +158,7 @@
<FAIcon
fixed-width
icon="globe"
/><span>{{ $t('status.translate') }}</span>
/><span>{{ $t("status.translate") }}</span>
<template v-if="noTranslationTargetSet">
<span class="dropdown-item-icon__badge warning">
@ -172,7 +172,7 @@
</button>
</div>
</template>
<template #trigger>
<template v-slot:trigger>
<button class="button-unstyled popover-trigger">
<FAIcon
class="fa-scale-110 fa-old-padding"
@ -205,7 +205,7 @@
</Popover>
</template>
<script src="./extra_buttons.js"></script>
<script src="./extra_buttons.js" ></script>
<style lang="scss">
@import '../../_variables.scss';

View file

@ -1,19 +1,24 @@
import { mapGetters } from 'vuex'
import { library } from '@fortawesome/fontawesome-svg-core'
import { faStar } from '@fortawesome/free-solid-svg-icons'
import { faStar as faStarRegular } from '@fortawesome/free-regular-svg-icons'
import {
faStar as faStarRegular
} from '@fortawesome/free-regular-svg-icons'
library.add(faStar, faStarRegular)
library.add(
faStar,
faStarRegular
)
const FavoriteButton = {
props: ['status', 'loggedIn'],
data() {
data () {
return {
animated: false
}
},
methods: {
favorite() {
favorite () {
if (!this.status.favorited) {
this.$store.dispatch('favorite', { id: this.status.id })
} else {
@ -27,10 +32,8 @@ const FavoriteButton = {
},
computed: {
...mapGetters(['mergedConfig']),
remoteInteractionLink() {
return this.$store.getters.remoteInteractionLink({
statusId: this.status.id
})
remoteInteractionLink () {
return this.$store.getters.remoteInteractionLink({ statusId: this.status.id })
}
}
}

View file

@ -35,7 +35,7 @@
</div>
</template>
<script src="./favorite_button.js"></script>
<script src="./favorite_button.js" ></script>
<style lang="scss">
@import '../../_variables.scss';
@ -56,7 +56,6 @@
.interactive {
.svg-inline--fa {
animation-duration: 0.6s;
animation-timing-function: cubic-bezier(0.075, 0.82, 0.165, 1);
}
&:hover .svg-inline--fa,

View file

@ -2,20 +2,10 @@ import fileSizeFormatService from '../../services/file_size_format/file_size_for
const FeaturesPanel = {
computed: {
whoToFollow: function () {
return this.$store.state.instance.suggestionsEnabled
},
mediaProxy: function () {
return this.$store.state.instance.mediaProxyAvailable
},
textlimit: function () {
return this.$store.state.instance.textlimit
},
uploadlimit: function () {
return fileSizeFormatService.fileSizeFormat(
this.$store.state.instance.uploadlimit
)
}
whoToFollow: function () { return this.$store.state.instance.suggestionsEnabled },
mediaProxy: function () { return this.$store.state.instance.mediaProxyAvailable },
textlimit: function () { return this.$store.state.instance.textlimit },
uploadlimit: function () { return fileSizeFormatService.fileSizeFormat(this.$store.state.instance.uploadlimit) }
}
}

View file

@ -16,20 +16,17 @@
</li>
<li>{{ $t('features_panel.scope_options') }}</li>
<li>{{ $t('features_panel.text_limit') }} = {{ textlimit }}</li>
<li>
{{ $t('features_panel.upload_limit') }} = {{ uploadlimit.num }}
{{ $t('upload.file_size_units.' + uploadlimit.unit) }}
</li>
<li>{{ $t('features_panel.upload_limit') }} = {{ uploadlimit.num }} {{ $t('upload.file_size_units.' + uploadlimit.unit) }}</li>
</ul>
</div>
</div>
</div>
</template>
<script src="./features_panel.js"></script>
<script src="./features_panel.js" ></script>
<style lang="scss">
.features-panel li {
line-height: 24px;
}
.features-panel li {
line-height: 24px;
}
</style>

View file

@ -5,11 +5,14 @@ import {
faExclamationTriangle
} from '@fortawesome/free-solid-svg-icons'
library.add(faStop, faExclamationTriangle)
library.add(
faStop,
faExclamationTriangle
)
const Flash = {
props: ['src'],
data() {
props: [ 'src' ],
data () {
return {
player: false, // can be true, "hidden", false. hidden = element exists
loaded: false,
@ -17,7 +20,7 @@ const Flash = {
}
},
methods: {
openPlayer() {
openPlayer () {
if (this.player) return // prevent double-loading, or re-loading on failure
this.player = 'hidden'
RuffleService.getRuffle().then((ruffle) => {
@ -29,20 +32,17 @@ const Flash = {
container.appendChild(player)
player.style.width = '100%'
player.style.height = '100%'
player
.load(this.src)
.then(() => {
this.player = true
})
.catch((e) => {
console.error('Error loading ruffle', e)
this.player = 'error'
})
player.load(this.src).then(() => {
this.player = true
}).catch((e) => {
console.error('Error loading ruffle', e)
this.player = 'error'
})
this.ruffleInstance = player
this.$emit('playerOpened')
})
},
closePlayer() {
closePlayer () {
this.ruffleInstance && this.ruffleInstance.remove()
this.player = false
this.$emit('playerClosed')

View file

@ -1,27 +1,24 @@
import ConfirmModal from '../confirm_modal/confirm_modal.vue'
import {
requestFollow,
requestUnfollow
} from '../../services/follow_manipulate/follow_manipulate'
import { requestFollow, requestUnfollow } from '../../services/follow_manipulate/follow_manipulate'
export default {
props: ['relationship', 'user', 'labelFollowing', 'buttonClass'],
components: {
ConfirmModal
},
data() {
data () {
return {
inProgress: false,
showingConfirmUnfollow: false
}
},
computed: {
shouldConfirmUnfollow() {
shouldConfirmUnfollow () {
return this.$store.getters.mergedConfig.modalOnUnfollow
},
isPressed() {
isPressed () {
return this.inProgress || this.relationship.following
},
title() {
title () {
if (this.inProgress || this.relationship.following) {
return this.$t('user_card.follow_unfollow')
} else if (this.relationship.requested) {
@ -30,7 +27,7 @@ export default {
return this.$t('user_card.follow')
}
},
label() {
label () {
if (this.inProgress) {
return this.$t('user_card.follow_progress')
} else if (this.relationship.following) {
@ -41,44 +38,39 @@ export default {
return this.$t('user_card.follow')
}
},
disabled() {
disabled () {
return this.inProgress || this.user.deactivated
}
},
methods: {
showConfirmUnfollow() {
showConfirmUnfollow () {
this.showingConfirmUnfollow = true
},
hideConfirmUnfollow() {
hideConfirmUnfollow () {
this.showingConfirmUnfollow = false
},
onClick() {
this.relationship.following || this.relationship.requested
? this.unfollow()
: this.follow()
onClick () {
this.relationship.following || this.relationship.requested ? this.unfollow() : this.follow()
},
follow() {
follow () {
this.inProgress = true
requestFollow(this.relationship.id, this.$store).then(() => {
this.inProgress = false
})
},
unfollow() {
unfollow () {
if (this.shouldConfirmUnfollow) {
this.showConfirmUnfollow()
} else {
this.doUnfollow()
}
},
doUnfollow() {
doUnfollow () {
const store = this.$store
this.inProgress = true
requestUnfollow(this.relationship.id, store).then(() => {
this.inProgress = false
store.commit('removeStatus', {
timeline: 'friends',
userId: this.relationship.id
})
store.commit('removeStatus', { timeline: 'friends', userId: this.relationship.id })
})
this.hideConfirmUnfollow()

View file

@ -21,7 +21,9 @@
tag="span"
>
<template #user>
<span v-text="user.screen_name_ui" />
<span
v-text="user.screen_name_ui"
/>
</template>
</i18n-t>
</confirm-modal>

View file

@ -4,7 +4,10 @@ import FollowButton from '../follow_button/follow_button.vue'
import RemoveFollowerButton from '../remove_follower_button/remove_follower_button.vue'
const FollowCard = {
props: ['user', 'noFollowsYou'],
props: [
'user',
'noFollowsYou'
],
components: {
BasicUserCard,
RemoteFollow,
@ -12,13 +15,13 @@ const FollowCard = {
RemoveFollowerButton
},
computed: {
isMe() {
isMe () {
return this.$store.state.users.currentUser.id === this.user.id
},
loggedIn() {
loggedIn () {
return this.$store.state.users.currentUser
},
relationship() {
relationship () {
return this.$store.getters.relationship(this.user.id)
}
}

View file

@ -8,90 +8,78 @@ const FollowRequestCard = {
BasicUserCard,
ConfirmModal
},
data() {
data () {
return {
showingApproveConfirmDialog: false,
showingDenyConfirmDialog: false
}
},
methods: {
findFollowRequestNotificationId() {
findFollowRequestNotificationId () {
const notif = notificationsFromStore(this.$store).find(
(notif) =>
notif.from_profile.id === this.user.id &&
notif.type === 'follow_request'
(notif) => notif.from_profile.id === this.user.id && notif.type === 'follow_request'
)
return notif && notif.id
},
showApproveConfirmDialog() {
showApproveConfirmDialog () {
this.showingApproveConfirmDialog = true
},
hideApproveConfirmDialog() {
hideApproveConfirmDialog () {
this.showingApproveConfirmDialog = false
},
showDenyConfirmDialog() {
showDenyConfirmDialog () {
this.showingDenyConfirmDialog = true
},
hideDenyConfirmDialog() {
hideDenyConfirmDialog () {
this.showingDenyConfirmDialog = false
},
approveUser() {
approveUser () {
if (this.shouldConfirmApprove) {
this.showApproveConfirmDialog()
} else {
this.doApprove()
}
},
doApprove() {
doApprove () {
this.$store.state.api.backendInteractor.approveUser({ id: this.user.id })
this.$store.dispatch('removeFollowRequest', this.user)
this.$store.dispatch('decrementFollowRequestsCount')
const notifId = this.findFollowRequestNotificationId()
this.$store.dispatch('markSingleNotificationAsSeen', { id: notifId })
this.$store.dispatch('updateNotification', {
id: notifId,
updater: (notification) => {
updater: notification => {
notification.type = 'follow'
}
})
this.hideApproveConfirmDialog()
},
denyUser() {
denyUser () {
if (this.shouldConfirmDeny) {
this.showDenyConfirmDialog()
} else {
this.doDeny()
}
},
doDeny() {
doDeny () {
const notifId = this.findFollowRequestNotificationId()
this.$store.state.api.backendInteractor
.denyUser({ id: this.user.id })
this.$store.state.api.backendInteractor.denyUser({ id: this.user.id })
.then(() => {
this.$store.dispatch('dismissNotificationLocal', { id: notifId })
this.$store.dispatch('decrementFollowRequestsCount')
this.$store.dispatch('removeFollowRequest', this.user)
})
this.hideDenyConfirmDialog()
}
},
computed: {
mergedConfig() {
mergedConfig () {
return this.$store.getters.mergedConfig
},
shouldConfirmApprove() {
shouldConfirmApprove () {
return this.mergedConfig.modalOnApproveFollow
},
shouldConfirmDeny() {
shouldConfirmDeny () {
return this.mergedConfig.modalOnDenyFollow
},
show() {
const notifId = this.$store.state.api.followRequests.find(
(req) => req.id === this.user.id
)
return notifId !== undefined
}
}
}

View file

@ -1,8 +1,5 @@
<template>
<basic-user-card
v-if="show"
:user="user"
>
<basic-user-card :user="user">
<div class="follow-request-card-content-container">
<button
class="btn button-default"

View file

@ -1,29 +1,11 @@
import FollowRequestCard from '../follow_request_card/follow_request_card.vue'
import withLoadMore from '../../hocs/with_load_more/with_load_more'
import List from '../list/list.vue'
import get from 'lodash/get'
const FollowRequestList = withLoadMore({
fetch: (props, $store) => $store.dispatch('fetchFollowRequests'),
select: (props, $store) =>
get($store.state.api, 'followRequests', []).map((req) =>
$store.getters.findUser(req.id)
),
destroy: (props, $store) => $store.dispatch('clearFollowRequests'),
childPropName: 'items',
additionalPropNames: ['userId']
})(List)
const FollowRequests = {
components: {
FollowRequestCard,
FollowRequestList
FollowRequestCard
},
computed: {
userId() {
return this.$store.state.users.currentUser.id
},
requests() {
requests () {
return this.$store.state.api.followRequests
}
}

View file

@ -6,11 +6,12 @@
</div>
</div>
<div class="panel-body">
<FollowRequestList :user-id="userId">
<template #item="{ item }">
<FollowRequestCard :user="item" />
</template>
</FollowRequestList>
<FollowRequestCard
v-for="request in requests"
:key="request.id"
:user="request"
class="list-item"
/>
</div>
</div>
</template>

View file

@ -1,78 +0,0 @@
<template>
<div class="followed-tag-card">
<span>
<router-link :to="{ name: 'tag-timeline', params: { tag: tag.name } }">
<span class="tag-link">#{{ tag.name }}</span>
</router-link>
<span class="unfollow-tag">
<button
v-if="isFollowing"
class="button-default unfollow-tag-button"
:title="$t('user_card.unfollow_tag')"
@click="unfollowTag(tag.name)"
>
{{ $t('user_card.unfollow_tag') }}
</button>
<button
v-else
class="button-default follow-tag-button"
:title="$t('user_card.follow_tag')"
@click="followTag(tag.name)"
>
{{ $t('user_card.follow_tag') }}
</button>
</span>
</span>
</div>
</template>
<script>
export default {
name: 'FollowedTagCard',
props: {
tag: {
type: Object,
required: true
}
},
// this is a hack to update the state of the button
// for some reason, List does not update on changes to the tag object
data: () => ({
isFollowing: true
}),
mounted() {
this.isFollowing = this.tag.following
},
methods: {
unfollowTag(tag) {
this.$store.dispatch('unfollowTag', tag)
this.isFollowing = false
},
followTag(tag) {
this.$store.dispatch('followTag', tag)
this.isFollowing = true
}
}
}
</script>
<style scoped>
.followed-tag-card {
margin-left: 1rem;
margin-top: 1rem;
margin-bottom: 1rem;
}
.unfollow-tag {
position: absolute;
right: 1rem;
}
.tag-link {
font-size: large;
}
.unfollow-tag-button,
.follow-tag-button {
font-size: medium;
}
</style>

View file

@ -5,9 +5,11 @@ export default {
components: {
Select
},
props: ['name', 'label', 'modelValue', 'fallback', 'options', 'no-inherit'],
props: [
'name', 'label', 'modelValue', 'fallback', 'options', 'no-inherit'
],
emits: ['update:modelValue'],
data() {
data () {
return {
lValue: this.modelValue,
availableOptions: [
@ -17,45 +19,43 @@ export default {
'serif',
'monospace',
'sans-serif'
].filter((_) => _)
].filter(_ => _)
}
},
beforeUpdate() {
beforeUpdate () {
this.lValue = this.modelValue
},
computed: {
present() {
present () {
return typeof this.lValue !== 'undefined'
},
dValue() {
dValue () {
return this.lValue || this.fallback || {}
},
family: {
get() {
get () {
return this.dValue.family
},
set(v) {
set (v) {
set(this.lValue, 'family', v)
this.$emit('update:modelValue', this.lValue)
}
},
isCustom() {
isCustom () {
return this.preset === 'custom'
},
preset: {
get() {
if (
this.family === 'serif' ||
this.family === 'sans-serif' ||
this.family === 'monospace' ||
this.family === 'inherit'
) {
get () {
if (this.family === 'serif' ||
this.family === 'sans-serif' ||
this.family === 'monospace' ||
this.family === 'inherit') {
return this.family
} else {
return 'custom'
}
},
set(v) {
set (v) {
this.family = v === 'custom' ? '' : v
}
}

View file

@ -15,13 +15,8 @@
class="opt exlcude-disabled"
type="checkbox"
:checked="present"
@change="
$emit(
'update:modelValue',
typeof modelValue === 'undefined' ? fallback : undefined
)
"
/>
@change="$emit('update:modelValue', typeof modelValue === 'undefined' ? fallback : undefined)"
>
<label
v-if="typeof fallback !== 'undefined'"
class="opt-l"
@ -48,11 +43,11 @@
v-model="family"
class="custom-font"
type="text"
/>
>
</div>
</template>
<script src="./font_control.js"></script>
<script src="./font_control.js" ></script>
<style lang="scss">
@import '../../_variables.scss';

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