Compare commits

..

No commits in common. "bnakkoma" and "develop" have entirely different histories.

189 changed files with 9108 additions and 10215 deletions

View file

@ -1,13 +0,0 @@
.DS_Store
node_modules/
dist/
npm-debug.log
test/unit/coverage
test/e2e/reports
selenium-debug.log
.idea/
config/local.json
config/local.*.json
docs/site/
.vscode/
akkoma-fe.zip

2
.eslintignore Normal file
View file

@ -0,0 +1,2 @@
build/*.js
config/*.js

30
.eslintrc.js Normal file
View file

@ -0,0 +1,30 @@
module.exports = {
root: true,
parserOptions: {
parser: '@babel/eslint-parser',
sourceType: 'module'
},
// https://github.com/feross/standard/blob/master/RULES.md#javascript-standard-style
extends: [
'plugin:vue/recommended'
],
// required to lint *.vue files
plugins: [
'vue',
'import'
],
// add your custom rules here
rules: {
// allow paren-less arrow functions
'arrow-parens': 0,
// allow async-await
'generator-star-spacing': 0,
// allow debugger during development
'no-debugger': process.env.NODE_ENV === 'production' ? 2 : 0,
'vue/require-prop-types': 0,
'vue/no-unused-vars': 0,
'no-tabs': 0,
'vue/multi-word-component-names': 0,
'vue/no-reserved-component-names': 0
}
}

1
.gitignore vendored
View file

@ -10,4 +10,3 @@ config/local.json
config/local.*.json config/local.*.json
docs/site/ docs/site/
.vscode/ .vscode/
akkoma-fe.zip

1
.node-version Normal file
View file

@ -0,0 +1 @@
7.2.1

View file

@ -1 +0,0 @@
nodejs 20.12.2

View file

@ -1,21 +1,20 @@
labels:
platform: linux/amd64 platform: linux/amd64
pipeline:
steps:
lint: lint:
when: when:
event: event:
- pull_request - pull_request
image: node:20 image: node:18
commands: commands:
- yarn - yarn
- yarn lint - yarn lint
#- yarn stylelint
test: test:
when: when:
event: event:
- pull_request - pull_request
image: node:20 image: node:18
commands: commands:
- apt update - apt update
- apt install firefox-esr -y --no-install-recommends - apt install firefox-esr -y --no-install-recommends
@ -29,7 +28,7 @@ steps:
branch: branch:
- develop - develop
- stable - stable
image: node:20 image: node:18
commands: commands:
- yarn - yarn
- yarn build - yarn build
@ -41,15 +40,15 @@ steps:
branch: branch:
- develop - develop
- stable - stable
image: node:20 image: node:18
secrets: secrets:
- SCW_ACCESS_KEY - SCW_ACCESS_KEY
- SCW_SECRET_KEY - SCW_SECRET_KEY
- SCW_DEFAULT_ORGANIZATION_ID - SCW_DEFAULT_ORGANIZATION_ID
commands: commands:
- apt-get update && apt-get install -y rclone wget zip - apt-get update && apt-get install -y rclone wget zip
- wget https://github.com/scaleway/scaleway-cli/releases/download/v2.30.0/scaleway-cli_2.30.0_linux_amd64 - wget https://github.com/scaleway/scaleway-cli/releases/download/v2.5.1/scaleway-cli_2.5.1_linux_amd64
- mv scaleway-cli_2.30.0_linux_amd64 scaleway-cli - mv scaleway-cli_2.5.1_linux_amd64 scaleway-cli
- chmod +x scaleway-cli - chmod +x scaleway-cli
- ./scaleway-cli object config install type=rclone - ./scaleway-cli object config install type=rclone
- zip akkoma-fe.zip -r dist - zip akkoma-fe.zip -r dist
@ -71,8 +70,8 @@ steps:
- SCW_DEFAULT_ORGANIZATION_ID - SCW_DEFAULT_ORGANIZATION_ID
commands: commands:
- apt-get update && apt-get install -y rclone wget git zip - apt-get update && apt-get install -y rclone wget git zip
- wget https://github.com/scaleway/scaleway-cli/releases/download/v2.30.0/scaleway-cli_2.30.0_linux_amd64 - wget https://github.com/scaleway/scaleway-cli/releases/download/v2.5.1/scaleway-cli_2.5.1_linux_amd64
- mv scaleway-cli_2.30.0_linux_amd64 scaleway-cli - mv scaleway-cli_2.5.1_linux_amd64 scaleway-cli
- chmod +x scaleway-cli - chmod +x scaleway-cli
- ./scaleway-cli object config install type=rclone - ./scaleway-cli object config install type=rclone
- cd docs - cd docs

View file

@ -1,24 +0,0 @@
FROM node:22.6.0-alpine3.20 as build
RUN apk add --no-cache \
git \
chromium-chromedriver
# use chromedriver from apk
ENV CHROMEDRIVER_FILEPATH=/usr/bin/chromedriver
WORKDIR /app/
ARG NODE_ENV=production
ENV YARN_CACHE_FOLDER=/.yarn/
COPY . /app/
RUN \
--mount=type=cache,target=/.yarn \
NODE_ENV=development yarn install \
&& NODE_ENV=${NODE_ENV} yarn run build
FROM scratch as result
COPY --from=build /app/dist/ /

View file

@ -1,26 +0,0 @@
RUNTIME ?= docker
BUILD_DIR ?= ./dist/
OUTFILE_ZIP ?= ./akkoma-fe.zip
NODE_ENV ?= production
.PHONY: all
all: build-fe package
.PHONY: build-fe
build-fe:
ifeq ("$(wildcard $(BUILD_DIR))","")
mkdir $(BUILD_DIR)
else
rm -rf $(BUILD_DIR)
mkdir $(BUILD_DIR)
endif
$(RUNTIME) build --build-arg NODE_ENV=$(NODE_ENV) --output type=local,dest=./dist/ .
.PHONY: package
package:
ifneq ("$(wildcard $(OUTFILE_ZIP))","")
rm $(OUTFILE_ZIP)
endif
zip -r -9 $(OUTFILE_ZIP) $(BUILD_DIR)
# vim:set noexpandtab:

View file

@ -1,60 +1,12 @@
# BNAkkoma: Brand New Akkoma # Akkoma-FE
<small>It's not that new. This is just a cheap pun on the title of a [furry anime](https://en.wikipedia.org/wiki/BNA:_Brand_New_Animal).</small> ![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)
Also keep in mind that B in BNAkkoma stands for 'bleeding-edge', **features can be added, changed or removed at any time!**
If you are experiencing any strange quirks, make sure both your frontend and backend are up to date.
If your software are up to date but the bug is still there, ping me on the fediverse at '@itepechi@fedi.itepechi.me'.
## What is this?
This is a fork of [AkkomaGang/akkoma-fe](https://akkoma.dev/AkkomaGang/akkoma-fe/), with a custom theme and Makefile.
The differences from the upstream repository are described below:
- Added a Makefile where you can build the client inside a Docker container
- Refactored some components and styles
- Fixed a lot of broken CSS rules and misaligned elements
- Added and changed the default theme to a custom one
- Added support for setting the default post language
- Stole some commits from the original Pleroma frontend
- Added OpenSearch protocol support
- Added the Media tab to the search page (requires appropriate backend)
- Added 'Limit to Following' filter to the search page (requires appropriate backend)
- Added 'Limit to Local' filter to the search page (requires appropriate backend)
- Implemented lazy loading of search results
- More than 200 (!) Japanese translations have been fixed
- Removed some themes to save network bandwidth
- Improved PWA support, sort of
Although this frontend application is designed to work with the BNAkkoma backend, it remains compatible with the original version of Akkoma by only enabling additional features when the backend server returns a corresponding flag.
### How to build
**Requires 2GB+ memory.**
```sh
# Docker
make
# Podman
make RUNTIME=podman
```
---
## Akkoma-FE
![English OK](https://img.shields.io/badge/English-OK-blueviolet?style=for-the-badge) ![日本語OK](https://img.shields.io/badge/%E6%97%A5%E6%9C%AC%E8%AA%9E-OK-blueviolet?style=for-the-badge)
This is a fork of Akkoma-FE from the Pleroma project, with support for new Akkoma features such as: This is a fork of Akkoma-FE from the Pleroma project, with support for new Akkoma features such as:
- MFM support via [marked-mfm](https://akkoma.dev/sfr/marked-mfm) - MFM support via [marked-mfm](https://akkoma.dev/sfr/marked-mfm)
- Custom emoji reactions - Custom emoji reactions
## For Translators # 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 Akkoma-FE.
@ -62,15 +14,15 @@ Alternatively, edit/create `src/i18n/$LANGUAGE_CODE.json` (where `$LANGUAGE_CODE
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. 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.
## FOR ADMINS # 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 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.
### Build Setup ## Build Setup
``` bash ``` bash
# install dependencies # install dependencies
corepack enable npm install -g yarn
yarn yarn
# serve with hot reload at localhost:8080 # serve with hot reload at localhost:8080
@ -83,21 +35,21 @@ npm run build
npm run unit npm run unit
``` ```
## For Contributors: # For Contributors:
You can create file `/config/local.json` (see [example](https://akkoma.dev/AkkomaGang/akkoma-fe/src/branch/develop/config/local.example.json)) to enable some convenience dev options: You can create file `/config/local.json` (see [example](https://git.pleroma.social/pleroma/pleroma-fe/blob/develop/config/local.example.json)) to enable some convenience dev options:
- `target`: makes local dev server redirect to some existing instance's BE instead of local BE, useful for testing things in near-production environment and searching for real-life use-cases. * `target`: makes local dev server redirect to some existing instance's BE instead of local BE, useful for testing things in near-production environment and searching for real-life use-cases.
- `staticConfigPreference`: makes FE's `/static/config.json` take preference of BE-served `/api/statusnet/config.json`. Only works in dev mode. * `staticConfigPreference`: makes FE's `/static/config.json` take preference of BE-served `/api/statusnet/config.json`. Only works in dev mode.
FE Build process also leaves current commit hash in global variable `___pleromafe_commit_hash` so that you can easily see which pleroma-fe commit instance is running, also helps pinpointing which commit was used when FE was bundled into BE. FE Build process also leaves current commit hash in global variable `___pleromafe_commit_hash` so that you can easily see which pleroma-fe commit instance is running, also helps pinpointing which commit was used when FE was bundled into BE.
## Configuration # Configuration
Edit config.json for configuration. Edit config.json for configuration.
### Options ## Options
#### Login methods ### 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

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

View file

@ -5,7 +5,7 @@ var path = require('path')
var express = require('express') var express = require('express')
var webpack = require('webpack') var webpack = require('webpack')
var opn = require('opn') var opn = require('opn')
const { createProxyMiddleware } = require('http-proxy-middleware'); var proxyMiddleware = require('http-proxy-middleware')
var webpackConfig = process.env.NODE_ENV === 'testing' var webpackConfig = process.env.NODE_ENV === 'testing'
? require('./webpack.prod.conf') ? require('./webpack.prod.conf')
: require('./webpack.dev.conf') : require('./webpack.dev.conf')
@ -36,13 +36,7 @@ Object.keys(proxyTable).forEach(function (context) {
if (typeof options === 'string') { if (typeof options === 'string') {
options = { target: options } options = { target: options }
} }
const targetUrl = new URL(options.target); app.use(proxyMiddleware(context, options))
// add path
targetUrl.pathname = context;
options.target = targetUrl.toString();
console.log("Proxying", context, "to", options.target);
app.use(context, createProxyMiddleware(options))
}) })
// handle fallback for HTML5 history API // handle fallback for HTML5 history API

View file

@ -3,7 +3,6 @@ var config = require('../config')
var utils = require('./utils') var utils = require('./utils')
var projectRoot = path.resolve(__dirname, '../') var projectRoot = path.resolve(__dirname, '../')
var { VueLoaderPlugin } = require('vue-loader') var { VueLoaderPlugin } = require('vue-loader')
const ESLintPlugin = require('eslint-webpack-plugin');
var env = process.env.NODE_ENV var env = process.env.NODE_ENV
// check env & config/index.js to decide weither to enable CSS Sourcemaps for the // check env & config/index.js to decide weither to enable CSS Sourcemaps for the
@ -36,7 +35,6 @@ module.exports = {
], ],
fallback: { fallback: {
"url": require.resolve("url/"), "url": require.resolve("url/"),
querystring: require.resolve("querystring-es3")
}, },
alias: { alias: {
'static': path.resolve(__dirname, '../static'), 'static': path.resolve(__dirname, '../static'),
@ -49,6 +47,20 @@ module.exports = {
module: { module: {
noParse: /node_modules\/localforage\/dist\/localforage.js/, noParse: /node_modules\/localforage\/dist\/localforage.js/,
rules: [ rules: [
{
enforce: 'pre',
test: /\.(js|vue)$/,
include: projectRoot,
exclude: /node_modules/,
use: {
loader: 'eslint-loader',
options: {
formatter: require('eslint-friendly-formatter'),
sourceMap: config.build.productionSourceMap,
extract: true
}
}
},
{ {
enforce: 'post', enforce: 'post',
test: /\.(json5?|ya?ml)$/, // target json, json5, yaml and yml files test: /\.(json5?|ya?ml)$/, // target json, json5, yaml and yml files
@ -106,9 +118,6 @@ module.exports = {
] ]
}, },
plugins: [ plugins: [
new VueLoaderPlugin(), new VueLoaderPlugin()
new ESLintPlugin({
configType: 'flat'
})
] ]
} }

View file

@ -1,4 +1,4 @@
{ {
"target": "https://otp.akkoma.dev/", "target": "https://pleroma.soykaf.com/",
"staticConfigPreference": false "staticConfigPreference": false
} }

View file

@ -2,4 +2,5 @@ var { merge } = require('webpack-merge')
var devEnv = require('./dev.env') var devEnv = require('./dev.env')
module.exports = merge(devEnv, { module.exports = merge(devEnv, {
NODE_ENV: '"testing"'
}) })

View file

@ -1,31 +0,0 @@
const pluginVue = require('eslint-plugin-vue')
const pluginImport = require('eslint-plugin-import')
module.exports = [
...pluginVue.configs['flat/recommended'],
{
languageOptions: {
parserOptions: {
parser: '@babel/eslint-parser',
sourceType: 'module'
}
},
rules: {
// allow paren-less arrow functions
'arrow-parens': 0,
// allow async-await
'generator-star-spacing': 0,
// allow debugger during development
'no-debugger': process.env.NODE_ENV === 'production' ? 2 : 0,
'vue/require-prop-types': 0,
'vue/no-unused-vars': 0,
'no-tabs': 0,
'vue/multi-word-component-names': 0,
'vue/no-reserved-component-names': 0
},
ignores: [
'build/*.js',
'config/*.js'
]
}
]

View file

@ -11,7 +11,6 @@
<link rel="stylesheet" href="/static/theme-holder.css" id="theme-holder"> <link rel="stylesheet" href="/static/theme-holder.css" id="theme-holder">
<!--server-generated-meta--> <!--server-generated-meta-->
<link rel="icon" type="image/png" href="/favicon.png"> <link rel="icon" type="image/png" href="/favicon.png">
<link rel="apple-touch-icon" href="/static/apple-touch-icon.png">
<link rel="manifest" href="/manifest.json"> <link rel="manifest" href="/manifest.json">
</head> </head>
<body class="hidden"> <body class="hidden">

View file

@ -1,7 +1,7 @@
{ {
"name": "pleroma_fe", "name": "pleroma_fe",
"version": "3.10.0+bnakkoma", "version": "3.10.0",
"description": "An extended frontend for Akkoma instances", "description": "A frontend for Akkoma instances",
"author": "Roger Braun <roger@rogerbraun.net>", "author": "Roger Braun <roger@rogerbraun.net>",
"private": true, "private": true,
"scripts": { "scripts": {
@ -12,118 +12,120 @@
"e2e": "node test/e2e/runner.js", "e2e": "node test/e2e/runner.js",
"test": "npm run unit && npm run e2e", "test": "npm run unit && npm run e2e",
"stylelint": "stylelint src/**/*.scss", "stylelint": "stylelint src/**/*.scss",
"lint": "eslint src test/unit/specs test/e2e/specs", "lint": "eslint --ext .js,.vue src test/unit/specs test/e2e/specs",
"lint-fix": "eslint --fix src test/unit/specs test/e2e/specs" "lint-fix": "eslint --fix --ext .js,.vue src test/unit/specs test/e2e/specs"
}, },
"dependencies": { "dependencies": {
"@babel/runtime": "7.17.8", "@babel/runtime": "7.17.8",
"@chenfengyuan/vue-qrcode": "^2.0.0", "@chenfengyuan/vue-qrcode": "2.0.0",
"@floatingghost/pinch-zoom-element": "^1.3.1", "@floatingghost/pinch-zoom-element": "^1.3.1",
"@fortawesome/fontawesome-svg-core": "^6.5.2", "@fortawesome/fontawesome-svg-core": "1.3.0",
"@fortawesome/free-regular-svg-icons": "^6.5.2", "@fortawesome/free-regular-svg-icons": "^6.1.2",
"@fortawesome/free-solid-svg-icons": "^6.5.2", "@fortawesome/free-solid-svg-icons": "^6.2.0",
"@fortawesome/vue-fontawesome": "^3.0.8", "@fortawesome/vue-fontawesome": "3.0.1",
"@vuelidate/core": "^2.0.3", "@vuelidate/core": "^2.0.0",
"@vuelidate/validators": "^2.0.4", "@vuelidate/validators": "^2.0.0",
"blurhash": "^2.0.5", "blurhash": "^2.0.4",
"body-scroll-lock": "^3.1.5", "body-scroll-lock": "2.7.1",
"chromatism": "^3.0.0", "chromatism": "3.0.0",
"click-outside-vue3": "^4.0.1", "click-outside-vue3": "4.0.1",
"cropperjs": "^1.6.2", "cropperjs": "1.5.12",
"diff": "^5.2.0", "diff": "3.5.0",
"escape-html": "^1.0.3", "escape-html": "1.0.3",
"iso-639-1": "^2.1.15", "iso-639-1": "^2.1.15",
"js-cookie": "^3.0.1", "js-cookie": "^3.0.1",
"localforage": "^1.10.0", "localforage": "1.10.0",
"parse-link-header": "^2.0.0", "parse-link-header": "^2.0.0",
"phoenix": "^1.7.12", "phoenix": "1.6.2",
"punycode.js": "^2.3.1", "punycode.js": "2.1.0",
"qrcode": "^1.5.3", "qrcode": "1",
"querystring-es3": "^0.2.1", "url": "^0.11.0",
"url": "^0.11.3", "vue": "^3.2.31",
"vue": "^3.4.38", "vue-i18n": "^9.2.2",
"vue-i18n": "^9.14.0", "vue-router": "4.0.14",
"vue-router": "^4.4.3", "vue-template-compiler": "2.6.11",
"vue-template-compiler": "^2.7.16", "vuex": "4.0.2"
"vuex": "^4.1.0"
}, },
"devDependencies": { "devDependencies": {
"@babel/core": "^7.24.6", "@babel/core": "7.17.8",
"@babel/eslint-parser": "^7.19.1", "@babel/eslint-parser": "^7.19.1",
"@babel/plugin-transform-runtime": "^7.24.6", "@babel/plugin-transform-runtime": "7.17.0",
"@babel/preset-env": "^7.24.6", "@babel/preset-env": "7.16.11",
"@babel/register": "^7.24.6", "@babel/register": "7.17.7",
"@intlify/vue-i18n-loader": "^5.0.0", "@intlify/vue-i18n-loader": "^5.0.0",
"@ungap/event-target": "^0.2.4", "@ungap/event-target": "0.2.3",
"@vue/babel-helper-vue-jsx-merge-props": "^1.4.0", "@vue/babel-helper-vue-jsx-merge-props": "1.2.1",
"@vue/babel-plugin-jsx": "^1.2.2", "@vue/babel-plugin-jsx": "1.1.1",
"@vue/compiler-sfc": "^3.1.0", "@vue/compiler-sfc": "^3.1.0",
"@vue/test-utils": "^2.0.2", "@vue/test-utils": "^2.0.2",
"autoprefixer": "^10.4.19", "autoprefixer": "6.7.7",
"babel-loader": "^9.1.0", "babel-loader": "^9.1.0",
"babel-plugin-lodash": "^3.3.4", "babel-plugin-lodash": "3.3.4",
"chai": "^4.3.7", "chai": "^4.3.7",
"chalk": "^1.1.3", "chalk": "1.1.3",
"chromedriver": "^119.0.1", "chromedriver": "^107.0.3",
"connect-history-api-fallback": "^2.0.0", "connect-history-api-fallback": "^2.0.0",
"cross-spawn": "^7.0.3", "cross-spawn": "^7.0.3",
"css-loader": "^7.1.2", "css-loader": "^6.7.2",
"custom-event-polyfill": "^1.0.7", "custom-event-polyfill": "^1.0.7",
"eslint": "^9.3.0", "eslint": "^7.32.0",
"eslint-config-standard": "^17.1.0", "eslint-config-standard": "^17.0.0",
"eslint-friendly-formatter": "^4.0.1", "eslint-friendly-formatter": "^4.0.1",
"eslint-plugin-import": "^2.29.1", "eslint-loader": "^4.0.2",
"eslint-plugin-import": "^2.26.0",
"eslint-plugin-node": "^11.1.0", "eslint-plugin-node": "^11.1.0",
"eslint-plugin-promise": "^6.2.0", "eslint-plugin-promise": "^6.1.1",
"eslint-plugin-standard": "^5.0.0", "eslint-plugin-standard": "^5.0.0",
"eslint-plugin-vue": "^9.26.0", "eslint-plugin-vue": "^9.7.0",
"eslint-webpack-plugin": "^4.2.0", "eventsource-polyfill": "0.9.6",
"eventsource-polyfill": "^0.9.6", "express": "4.17.3",
"express": "^4.19.2",
"file-loader": "^6.2.0", "file-loader": "^6.2.0",
"function-bind": "^1.1.2", "function-bind": "1.1.1",
"html-webpack-plugin": "^5.5.0", "html-webpack-plugin": "^5.5.0",
"http-proxy-middleware": "^3.0.0", "http-proxy-middleware": "0.21.0",
"json-loader": "^0.5.7", "inject-loader": "2.0.1",
"karma": "^6.4.3", "isparta-loader": "2.0.0",
"karma-coverage": "^2.2.1", "json-loader": "0.5.7",
"karma-firefox-launcher": "^2.1.3", "karma": "6.3.17",
"karma-mocha": "^2.0.1", "karma-coverage": "1.1.2",
"karma-mocha-reporter": "^2.2.5", "karma-firefox-launcher": "1.3.0",
"karma-sinon-chai": "^2.0.2", "karma-mocha": "2.0.1",
"karma-sourcemap-loader": "^0.4.0", "karma-mocha-reporter": "2.2.5",
"karma-spec-reporter": "^0.0.36", "karma-sinon-chai": "2.0.2",
"karma-sourcemap-loader": "0.3.8",
"karma-spec-reporter": "0.0.33",
"karma-webpack": "^5.0.0", "karma-webpack": "^5.0.0",
"lodash": "^4.17.21", "lodash": "4.17.21",
"lolex": "^6.0.0", "lolex": "1.6.0",
"mini-css-extract-plugin": "^2.9.0", "mini-css-extract-plugin": "0.12.0",
"mocha": "^10.4.0", "mocha": "3.5.3",
"nightwatch": "^3.6.3", "nightwatch": "0.9.21",
"opn": "^6.0.0", "opn": "4.0.2",
"ora": "0.4.1",
"postcss-html": "^1.5.0", "postcss-html": "^1.5.0",
"postcss-loader": "^8.1.1", "postcss-loader": "3.0.0",
"postcss-sass": "^0.5.0", "postcss-sass": "^0.5.0",
"raw-loader": "^4.0.2", "raw-loader": "0.5.1",
"sass": "^1.77.2", "sass": "^1.56.0",
"sass-loader": "^14.2.1", "sass-loader": "^13.2.0",
"selenium-server": "^3.141.59", "selenium-server": "2.53.1",
"semver": "^7.6.2", "semver": "5.7.1",
"shelljs": "^0.8.5", "shelljs": "0.8.5",
"sinon": "^18.0.0", "sinon": "2.4.1",
"sinon-chai": "^3.7.0", "sinon-chai": "2.14.0",
"stylelint": "^14.15.0", "stylelint": "^14.15.0",
"stylelint-config-recommended-vue": "^1.4.0", "stylelint-config-recommended-vue": "^1.4.0",
"stylelint-config-standard": "^29.0.0", "stylelint-config-standard": "^29.0.0",
"stylelint-config-standard-scss": "^6.1.0", "stylelint-config-standard-scss": "^6.1.0",
"stylelint-rscss": "^0.4.0", "stylelint-rscss": "^0.4.0",
"url-loader": "^4.1.1", "url-loader": "^4.1.1",
"vue-loader": "^17.4.2", "vue-loader": "^17.0.0",
"vue-style-loader": "^4.1.3", "vue-style-loader": "^4.1.2",
"webpack": "^5.91.0", "webpack": "^5.75.0",
"webpack-dev-middleware": "^7.2.1", "webpack-dev-middleware": "^5.3.3",
"webpack-hot-middleware": "^2.26.1", "webpack-hot-middleware": "^2.25.1",
"webpack-merge": "^5.10.0", "webpack-merge": "^5.8.0",
"workbox-webpack-plugin": "^7.1.0" "workbox-webpack-plugin": "^6.5.4"
}, },
"engines": { "engines": {
"node": ">= 16.0.0", "node": ">= 16.0.0",

View file

@ -64,11 +64,6 @@ export default {
'-' + this.layoutType '-' + this.layoutType
] ]
}, },
pageBackground () {
return this.mergedConfig.displayPageBackgrounds
? this.$store.state.users.displayBackground
: null
},
currentUser () { return this.$store.state.users.currentUser }, currentUser () { return this.$store.state.users.currentUser },
userBackground () { return this.currentUser.background_image }, userBackground () { return this.currentUser.background_image },
instanceBackground () { instanceBackground () {
@ -76,7 +71,7 @@ export default {
? null ? null
: this.$store.state.instance.background : this.$store.state.instance.background
}, },
background () { return this.pageBackground || this.userBackground || this.instanceBackground }, background () { return this.userBackground || this.instanceBackground },
bgStyle () { bgStyle () {
if (this.background) { if (this.background) {
return { return {

View file

@ -8,7 +8,7 @@
} }
html { html {
font-size: 0.875rem; font-size: 14px;
// overflow-x: clip causes my browser's tab to crash with SIGILL lul // overflow-x: clip causes my browser's tab to crash with SIGILL lul
} }
@ -665,12 +665,8 @@ option {
} }
.alert { .alert {
display: inline-flex; margin: 0 0.35em;
justify-content: center; padding: 0 0.25em;
align-items: center;
line-height: 1;
margin: 0 0.5em;
padding: 0.35em;
border-radius: $fallback--tooltipRadius; border-radius: $fallback--tooltipRadius;
border-radius: var(--tooltipRadius, $fallback--tooltipRadius); border-radius: var(--tooltipRadius, $fallback--tooltipRadius);
@ -771,8 +767,6 @@ option {
.btn.button-default { .btn.button-default {
min-height: 2em; min-height: 2em;
padding-left: 0.5em;
padding-right: 0.5em;
} }
.new-status-notification { .new-status-notification {
@ -786,11 +780,6 @@ option {
.mobile-hidden { .mobile-hidden {
display: none; display: none;
} }
.btn.button-default {
padding-left: 0.7em;
padding-right: 0.7em;
}
} }
@keyframes spin { @keyframes spin {

View file

@ -77,9 +77,7 @@ const getInstanceConfig = async ({ store }) => {
store.dispatch('setInstanceOption', { name: 'accountApprovalRequired', value: data.approval_required }) store.dispatch('setInstanceOption', { name: 'accountApprovalRequired', value: data.approval_required })
// don't override cookie if set // don't override cookie if set
if (!Cookies.get('userLanguage')) { if (!Cookies.get('userLanguage')) {
const language = resolveLanguage(data.languages) store.dispatch('setOption', { name: 'interfaceLanguage', value: resolveLanguage(data.languages) })
store.dispatch('setOption', { name: 'interfaceLanguage', value: language })
store.dispatch('setOption', { name: 'postLanguage', value: language })
} }
if (vapidPublicKey) { if (vapidPublicKey) {
@ -185,12 +183,6 @@ const setSettings = async ({ apiConfig, staticConfig, store }) => {
copyInstanceOption('renderMisskeyMarkdown') copyInstanceOption('renderMisskeyMarkdown')
copyInstanceOption('sidebarRight') copyInstanceOption('sidebarRight')
if (config.backendCommitUrl)
copyInstanceOption('backendCommitUrl')
if (config.frontendCommitUrl)
copyInstanceOption('frontendCommitUrl')
return store.dispatch('setTheme', config['theme']) return store.dispatch('setTheme', config['theme'])
} }
@ -287,10 +279,6 @@ const getNodeInfo = async ({ store }) => {
store.dispatch('setInstanceOption', { name: 'pollLimits', value: metadata.pollLimits }) store.dispatch('setInstanceOption', { name: 'pollLimits', value: metadata.pollLimits })
store.dispatch('setInstanceOption', { name: 'mailerEnabled', value: metadata.mailerEnabled }) store.dispatch('setInstanceOption', { name: 'mailerEnabled', value: metadata.mailerEnabled })
store.dispatch('setInstanceOption', { name: 'translationEnabled', value: features.includes('akkoma:machine_translation') }) store.dispatch('setInstanceOption', { name: 'translationEnabled', value: features.includes('akkoma:machine_translation') })
store.dispatch('setInstanceOption', { name: 'searchTypeMediaEnabled', value: features.includes('bnakkoma:search_type_media') })
store.dispatch('setInstanceOption', { name: 'searchOptionFollowingEnabled', value: features.includes('bnakkoma:search_option_following') })
store.dispatch('setInstanceOption', { name: 'searchOptionLocalEnabled', value: features.includes('bnakkoma:search_option_local') })
store.dispatch('setInstanceOption', { name: 'opensearchProtocolSupported', value: features.includes('bnakkoma:opensearch_protocol') })
const uploadLimits = metadata.uploadLimits const uploadLimits = metadata.uploadLimits
store.dispatch('setInstanceOption', { name: 'uploadlimit', value: parseInt(uploadLimits.general) }) store.dispatch('setInstanceOption', { name: 'uploadlimit', value: parseInt(uploadLimits.general) })
@ -414,16 +402,6 @@ const afterStoreSetup = async ({ store, i18n }) => {
getTOS({ store }) getTOS({ store })
getStickers({ store }) getStickers({ store })
// Create a link tag for OpenSearch and forget about it
if (store.state.instance.opensearchProtocolSupported) {
const node = document.createElement('link')
node.setAttribute('rel', 'search')
node.setAttribute('type', 'application/opensearchdescription+xml')
node.setAttribute('href', '/opensearch.xml')
node.setAttribute('title', store.state.instance.name)
document.head.appendChild(node)
}
const router = createRouter({ const router = createRouter({
history: createWebHistory(), history: createWebHistory(),
routes: routes(store), routes: routes(store),

View file

@ -6,7 +6,7 @@
:bound-to="{ x: 'container' }" :bound-to="{ x: 'container' }"
remove-padding remove-padding
> >
<template #content> <template v-slot:content>
<div class="dropdown-menu"> <div class="dropdown-menu">
<template v-if="relationship.following"> <template v-if="relationship.following">
<button <button
@ -71,7 +71,7 @@
</button> </button>
</div> </div>
</template> </template>
<template #trigger> <template v-slot:trigger>
<button class="button-unstyled ellipsis-button"> <button class="button-unstyled ellipsis-button">
<FAIcon <FAIcon
class="icon" class="icon"
@ -93,7 +93,7 @@
keypath="user_card.block_confirm" keypath="user_card.block_confirm"
tag="span" tag="span"
> >
<template #user> <template v-slot:user>
<span <span
v-text="user.screen_name_ui" v-text="user.screen_name_ui"
/> />

View file

@ -37,7 +37,7 @@
white-space: pre-line; white-space: pre-line;
word-break: break-word; word-break: break-word;
text-overflow: ellipsis; text-overflow: ellipsis;
overflow: auto; overflow: scroll;
} }
&.-static { &.-static {

View file

@ -155,7 +155,6 @@
> >
<StillImage <StillImage
class="image" class="image"
:loading="'lazy'"
:referrerpolicy="referrerpolicy" :referrerpolicy="referrerpolicy"
:mimetype="attachment.mimetype" :mimetype="attachment.mimetype"
:src="attachment.large_thumb_url || attachment.url" :src="attachment.large_thumb_url || attachment.url"
@ -247,8 +246,8 @@
ref="flash" ref="flash"
class="flash" class="flash"
:src="attachment.large_thumb_url || attachment.url" :src="attachment.large_thumb_url || attachment.url"
@player-opened="setFlashLoaded(true)" @playerOpened="setFlashLoaded(true)"
@player-closed="setFlashLoaded(false)" @playerClosed="setFlashLoaded(false)"
/> />
</span> </span>
</div> </div>

View file

@ -61,11 +61,12 @@
} }
&-user-name { &-user-name {
--emoji-size: 14px; img {
object-fit: contain;
height: 16px;
width: 16px;
vertical-align: middle;
} }
&-user-name-value {
font-weight: bold;
} }
&-user-name-value, &-user-name-value,

View file

@ -22,36 +22,31 @@
<script> <script>
export default { export default {
emits: ['update:modelValue'],
props: [ props: [
'modelValue', 'modelValue',
'indeterminate', 'indeterminate',
'disabled' 'disabled'
], ]
emits: ['update:modelValue']
} }
</script> </script>
<style lang="scss"> <style lang="scss">
@import '../../_variables.scss'; @import '../../_variables.scss';
$checkbox-size: 1.2em;
$padding-size: 0.5em;
.checkbox { .checkbox {
position: relative; position: relative;
display: inline-block; display: inline-block;
min-height: $checkbox-size; min-height: 1.2em;
padding-left: calc($checkbox-size + $padding-size);
&-indicator { &-indicator {
position: relative; position: relative;
padding-left: calc($checkbox-size + $padding-size); padding-left: 1.2em;
margin-left: calc(($checkbox-size + $padding-size) * -1);
} }
&-indicator::before { &-indicator::before {
position: absolute; position: absolute;
left: 0; right: 0;
top: 0; top: 0;
display: block; display: block;
content: '✓'; content: '✓';
@ -97,6 +92,11 @@ $padding-size: 0.5em;
color: $fallback--text; color: $fallback--text;
color: var(--inputText, $fallback--text); color: var(--inputText, $fallback--text);
} }
}
& > span {
margin-left: .5em;
} }
} }
</style> </style>

View file

@ -14,7 +14,7 @@
:model-value="present" :model-value="present"
:disabled="disabled" :disabled="disabled"
class="opt" class="opt"
@update:model-value="$emit('update:modelValue', typeof modelValue === 'undefined' ? fallback : undefined)" @update:modelValue="$emit('update:modelValue', typeof modelValue === 'undefined' ? fallback : undefined)"
/> />
<div class="input color-input-field"> <div class="input color-input-field">
<input <input
@ -46,6 +46,7 @@
</div> </div>
</div> </div>
</template> </template>
<style lang="scss" src="./color_input.scss"></style>
<script> <script>
import Checkbox from '../checkbox/checkbox.vue' import Checkbox from '../checkbox/checkbox.vue'
import { hex2rgb } from '../../services/color_convert/color_convert.js' import { hex2rgb } from '../../services/color_convert/color_convert.js'
@ -107,7 +108,6 @@ export default {
} }
} }
</script> </script>
<style lang="scss" src="./color_input.scss"></style>
<style lang="scss"> <style lang="scss">
.color-control { .color-control {

View file

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

View file

@ -267,11 +267,11 @@ const conversation = {
}, },
replies () { replies () {
let i = 1 let i = 1
// eslint-disable-next-line camelcase
return reduce(this.conversation, (result, { id, in_reply_to_status_id }) => { return reduce(this.conversation, (result, { id, in_reply_to_status_id }) => {
/* eslint-disable camelcase */
const irid = in_reply_to_status_id const irid = in_reply_to_status_id
/* eslint-enable camelcase */
if (irid) { if (irid) {
result[irid] = result[irid] || [] result[irid] = result[irid] || []
result[irid].push({ result[irid].push({

View file

@ -91,7 +91,7 @@
:controlled-set-media-playing="(newVal) => toggleStatusContentProperty(status.id, 'mediaPlaying', newVal)" :controlled-set-media-playing="(newVal) => toggleStatusContentProperty(status.id, 'mediaPlaying', newVal)"
@goto="setHighlight" @goto="setHighlight"
@toggle-expanded="toggleExpanded" @toggleExpanded="toggleExpanded"
/> />
<div <div
v-if="showOtherRepliesButtonBelowStatus && getReplies(status.id).length > 1" v-if="showOtherRepliesButtonBelowStatus && getReplies(status.id).length > 1"
@ -184,7 +184,7 @@
:toggle-status-content-property="toggleStatusContentProperty" :toggle-status-content-property="toggleStatusContentProperty"
@goto="setHighlight" @goto="setHighlight"
@toggle-expanded="toggleExpanded" @toggleExpanded="toggleExpanded"
/> />
</div> </div>
</div> </div>

View file

@ -51,6 +51,7 @@ export default {
ConfirmModal ConfirmModal
}, },
data: () => ({ data: () => ({
searchBarHidden: true,
supportsMask: window.CSS && window.CSS.supports && ( supportsMask: window.CSS && window.CSS.supports && (
window.CSS.supports('mask-size', 'contain') || window.CSS.supports('mask-size', 'contain') ||
window.CSS.supports('-webkit-mask-size', 'contain') || window.CSS.supports('-webkit-mask-size', 'contain') ||
@ -76,7 +77,8 @@ export default {
}, },
logoBgStyle () { logoBgStyle () {
return Object.assign({ return Object.assign({
'margin': `${this.$store.state.instance.logoMargin} 0` 'margin': `${this.$store.state.instance.logoMargin} 0`,
opacity: this.searchBarHidden ? 1 : 0
}, this.enableMask ? {} : { }, this.enableMask ? {} : {
'background-color': this.enableMask ? '' : 'transparent' 'background-color': this.enableMask ? '' : 'transparent'
}) })
@ -118,6 +120,9 @@ export default {
scrollToTop () { scrollToTop () {
window.scrollTo(0, 0) window.scrollTo(0, 0)
}, },
onSearchBarToggled (hidden) {
this.searchBarHidden = hidden
},
openSettingsModal () { openSettingsModal () {
this.$store.dispatch('openSettingsModal') this.$store.dispatch('openSettingsModal')
}, },

View file

@ -56,6 +56,13 @@
.logo { .logo {
grid-area: logo; grid-area: logo;
position: relative; position: relative;
transition: opacity;
transition-timing-function: ease-out;
transition-duration: 100ms;
@media all and (min-width: 800px) {
opacity: 1 !important;
}
.mask { .mask {
mask-repeat: no-repeat; mask-repeat: no-repeat;

View file

@ -44,9 +44,9 @@
/> />
</router-link> </router-link>
<router-link <router-link
v-if="publicTimelineVisible"
:to="{ name: 'public-timeline' }" :to="{ name: 'public-timeline' }"
class="nav-icon" class="nav-icon"
v-if="publicTimelineVisible"
> >
<FAIcon <FAIcon
fixed-width fixed-width
@ -68,9 +68,9 @@
/> />
</router-link> </router-link>
<router-link <router-link
v-if="federatedTimelineVisible"
:to="{ name: 'public-external-timeline' }" :to="{ name: 'public-external-timeline' }"
class="nav-icon" class="nav-icon"
v-if="federatedTimelineVisible"
> >
<FAIcon <FAIcon
fixed-width fixed-width
@ -98,6 +98,7 @@
<div class="item right actions"> <div class="item right actions">
<search-bar <search-bar
v-if="currentUser || !privateMode" v-if="currentUser || !privateMode"
@toggled="onSearchBarToggled"
@click.stop @click.stop
/> />
<div <div

View file

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

View file

@ -2,7 +2,7 @@
<Modal <Modal
v-if="isFormVisible" v-if="isFormVisible"
class="edit-form-modal-view" class="edit-form-modal-view"
@backdrop-clicked="closeModal" @backdropClicked="closeModal"
> >
<div class="edit-form-modal-panel panel"> <div class="edit-form-modal-panel panel">
<div class="panel-heading"> <div class="panel-heading">
@ -11,10 +11,10 @@
<PostStatusForm <PostStatusForm
class="panel-body" class="panel-body"
v-bind="params" v-bind="params"
:disable-polls="true"
:disable-visibility-selector="true"
:post-handler="doEditStatus"
@posted="closeModal" @posted="closeModal"
:disablePolls="true"
:disableVisibilitySelector="true"
:post-handler="doEditStatus"
/> />
</div> </div>
</Modal> </Modal>

View file

@ -43,10 +43,7 @@
:class="{ highlighted: index === highlighted }" :class="{ highlighted: index === highlighted }"
@click.stop.prevent="onClick($event, suggestion)" @click.stop.prevent="onClick($event, suggestion)"
> >
<span <span v-if="!suggestion.mfm" class="image">
v-if="!suggestion.mfm"
class="image"
>
<img <img
v-if="suggestion.img" v-if="suggestion.img"
:src="suggestion.img" :src="suggestion.img"

View file

@ -122,14 +122,14 @@ export const suggestUsers = ({ dispatch, state }) => {
const screenNameAlphabetically = a.screen_name > b.screen_name ? 1 : -1 const screenNameAlphabetically = a.screen_name > b.screen_name ? 1 : -1
return diff + nameAlphabetically + screenNameAlphabetically return diff + nameAlphabetically + screenNameAlphabetically
/* eslint-disable camelcase */
}).map(({ screen_name, screen_name_ui, name, profile_image_url_original }) => ({ }).map(({ screen_name, screen_name_ui, name, profile_image_url_original }) => ({
displayText: screen_name_ui, displayText: screen_name_ui,
detailText: name, detailText: name,
imageUrl: profile_image_url_original, imageUrl: profile_image_url_original,
replacement: '@' + screen_name + ' ' replacement: '@' + screen_name + ' '
})) }))
/* eslint-enable camelcase */
suggestions = newSuggestions || [] suggestions = newSuggestions || []
return suggestions return suggestions

View file

@ -7,7 +7,7 @@
:bound-to="{ x: 'container' }" :bound-to="{ x: 'container' }"
remove-padding remove-padding
> >
<template #content="{close}"> <template v-slot:content="{close}">
<div class="dropdown-menu"> <div class="dropdown-menu">
<button <button
v-if="canMute && !status.thread_muted" v-if="canMute && !status.thread_muted"
@ -172,7 +172,7 @@
</button> </button>
</div> </div>
</template> </template>
<template #trigger> <template v-slot:trigger>
<button class="button-unstyled popover-trigger"> <button class="button-unstyled popover-trigger">
<FAIcon <FAIcon
class="fa-scale-110 fa-old-padding" class="fa-scale-110 fa-old-padding"

View file

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

View file

@ -31,8 +31,8 @@
:description="descriptions && descriptions[attachment.id]" :description="descriptions && descriptions[attachment.id]"
:hide-description="size === 'small' || tooManyAttachments && hidingLong" :hide-description="size === 'small' || tooManyAttachments && hidingLong"
:style="itemStyle(attachment.id, row.items)" :style="itemStyle(attachment.id, row.items)"
@set-media="onMedia" @setMedia="onMedia"
@natural-size-load="onNaturalSizeLoad" @naturalSizeLoad="onNaturalSizeLoad"
/> />
</div> </div>
</div> </div>

View file

@ -42,7 +42,6 @@ export default {
@import '../../_variables.scss'; @import '../../_variables.scss';
.list { .list {
min-height: 1em;
&-item:not(:last-child) { &-item:not(:last-child) {
border-bottom: 1px solid; border-bottom: 1px solid;
border-bottom-color: $fallback--border; border-bottom-color: $fallback--border;

View file

@ -2,7 +2,7 @@
<Modal <Modal
v-if="showing" v-if="showing"
class="media-modal-view" class="media-modal-view"
@backdrop-clicked="hideIfNotSwiped" @backdropClicked="hideIfNotSwiped"
> >
<SwipeClick <SwipeClick
v-if="type === 'image'" v-if="type === 'image'"

View file

@ -42,7 +42,7 @@ const mediaUpload = {
.then((fileData) => { .then((fileData) => {
self.$emit('uploaded', fileData) self.$emit('uploaded', fileData)
self.decreaseUploadCount() self.decreaseUploadCount()
}, (error) => { }, (error) => { // eslint-disable-line handle-callback-err
self.$emit('upload-failed', 'default') self.$emit('upload-failed', 'default')
self.decreaseUploadCount() self.decreaseUploadCount()
}) })

View file

@ -93,6 +93,9 @@ const MentionLink = {
this.highlightType this.highlightType
] ]
}, },
useAtIcon () {
return this.mergedConfig.useAtIcon
},
isRemote () { isRemote () {
return this.userName !== this.userNameFull return this.userName !== this.userNameFull
}, },

View file

@ -18,7 +18,6 @@
<input <input
id="code" id="code"
v-model="code" v-model="code"
autocomplete="one-time-code"
class="form-control" class="form-control"
> >
</div> </div>

View file

@ -5,7 +5,6 @@
class="mod-modal" class="mod-modal"
:class="{ peek: modalPeeked }" :class="{ peek: modalPeeked }"
:no-background="modalPeeked" :no-background="modalPeeked"
@backdropClicked="closeModal"
> >
<div class="mod-modal-panel panel"> <div class="mod-modal-panel panel">
<div class="panel-heading"> <div class="panel-heading">

View file

@ -4,7 +4,7 @@
class="panel-heading" class="panel-heading"
@click="toggleHidden" @click="toggleHidden"
> >
<h4>{{ $t('moderation.reports.report') + ' ' + account.screen_name }}</h4> <h4>{{ $t('moderation.reports.report') + ' ' + this.account.screen_name }}</h4>
<button <button
v-if="isOpen" v-if="isOpen"
class="button-default" class="button-default"
@ -35,10 +35,7 @@
<div v-if="content"> <div v-if="content">
{{ decode(content) }} {{ decode(content) }}
</div> </div>
<i <i v-else class="faint">
v-else
class="faint"
>
{{ $t('moderation.reports.no_content') }} {{ $t('moderation.reports.no_content') }}
</i> </i>
<div class="report-author"> <div class="report-author">
@ -46,12 +43,12 @@
class="small-avatar" class="small-avatar"
:user="actor" :user="actor"
/> />
{{ actor.screen_name }} {{ this.actor.screen_name }}
</div> </div>
</div> </div>
<div <div
v-if="!hidden && statuses.length > 0"
class="dropdown" class="dropdown"
v-if="!hidden && this.statuses.length > 0"
> >
<button <button
class="button button-unstyled dropdown-header" class="button button-unstyled dropdown-header"
@ -77,8 +74,8 @@
</div> </div>
</div> </div>
<div <div
v-if="!hidden && notes.length > 0"
class="dropdown" class="dropdown"
v-if="!hidden && this.notes.length > 0"
> >
<button <button
class="button button-unstyled dropdown-header" class="button button-unstyled dropdown-header"
@ -102,9 +99,9 @@
</div> </div>
<div class="report-add-note"> <div class="report-add-note">
<textarea <textarea
v-model.trim="note"
rows="1" rows="1"
cols="1" cols="1"
v-model.trim="note"
:placeholder="$t('moderation.reports.note_placeholder')" :placeholder="$t('moderation.reports.note_placeholder')"
/> />
<button <button
@ -137,7 +134,7 @@
:offset="{ y: 5 }" :offset="{ y: 5 }"
remove-padding remove-padding
> >
<template #trigger> <template v-slot:trigger>
<button <button
class="btn button-default" class="btn button-default"
:disabled="!tagPolicyEnabled" :disabled="!tagPolicyEnabled"
@ -150,7 +147,7 @@
/> />
</button> </button>
</template> </template>
<template #content="{close}"> <template v-slot:content="{close}">
<div <div
class="dropdown-menu" class="dropdown-menu"
:disabled="!tagPolicyEnabled" :disabled="!tagPolicyEnabled"

View file

@ -6,7 +6,7 @@
class="small-avatar" class="small-avatar"
:user="user" :user="user"
/> />
{{ user.screen_name }} {{ this.user.screen_name }}
</div> </div>
<div class="header-right"> <div class="header-right">
<Timeago <Timeago

View file

@ -22,9 +22,6 @@ export default {
default: false default: false
} }
}, },
emits: [
'backdropClicked',
],
computed: { computed: {
classes () { classes () {
return { return {

View file

@ -8,7 +8,7 @@
@show="setToggled(true)" @show="setToggled(true)"
@close="setToggled(false)" @close="setToggled(false)"
> >
<template #content> <template v-slot:content>
<div class="dropdown-menu"> <div class="dropdown-menu">
<span v-if="user.is_local"> <span v-if="user.is_local">
<button <button
@ -122,7 +122,7 @@
</span> </span>
</div> </div>
</template> </template>
<template #trigger> <template v-slot:trigger>
<button <button
class="btn button-default btn-block moderation-tools-button" class="btn button-default btn-block moderation-tools-button"
:class="{ toggled }" :class="{ toggled }"
@ -137,11 +137,11 @@
v-if="showDeleteUserDialog" v-if="showDeleteUserDialog"
:on-cancel="deleteUserDialog.bind(this, false)" :on-cancel="deleteUserDialog.bind(this, false)"
> >
<template #header> <template v-slot:header>
{{ $t('user_card.admin_menu.delete_user') }} {{ $t('user_card.admin_menu.delete_user') }}
</template> </template>
<p>{{ $t('user_card.admin_menu.delete_user_confirmation') }}</p> <p>{{ $t('user_card.admin_menu.delete_user_confirmation') }}</p>
<template #footer> <template v-slot:footer>
<button <button
class="btn button-default" class="btn button-default"
@click="deleteUserDialog(false)" @click="deleteUserDialog(false)"

View file

@ -125,6 +125,20 @@
border-color: $fallback--border; border-color: $fallback--border;
border-color: var(--border, $fallback--border); border-color: var(--border, $fallback--border);
padding: 0; padding: 0;
&:first-child .menu-item {
border-top-right-radius: $fallback--panelRadius;
border-top-right-radius: var(--panelRadius, $fallback--panelRadius);
border-top-left-radius: $fallback--panelRadius;
border-top-left-radius: var(--panelRadius, $fallback--panelRadius);
}
&:last-child .menu-item {
border-bottom-right-radius: $fallback--panelRadius;
border-bottom-right-radius: var(--panelRadius, $fallback--panelRadius);
border-bottom-left-radius: $fallback--panelRadius;
border-bottom-left-radius: var(--panelRadius, $fallback--panelRadius);
}
} }
li:last-child { li:last-child {

View file

@ -6,7 +6,6 @@ import UserCard from '../user_card/user_card.vue'
import Timeago from '../timeago/timeago.vue' import Timeago from '../timeago/timeago.vue'
import RichContent from 'src/components/rich_content/rich_content.jsx' import RichContent from 'src/components/rich_content/rich_content.jsx'
import ConfirmModal from '../confirm_modal/confirm_modal.vue' import ConfirmModal from '../confirm_modal/confirm_modal.vue'
import StillImage from '../still-image/still-image.vue'
import { isStatusNotification } from '../../services/notification_utils/notification_utils.js' import { isStatusNotification } from '../../services/notification_utils/notification_utils.js'
import { highlightClass, highlightStyle } from '../../services/user_highlighter/user_highlighter.js' import { highlightClass, highlightStyle } from '../../services/user_highlighter/user_highlighter.js'
import generateProfileLink from 'src/services/user_profile_link_generator/user_profile_link_generator' import generateProfileLink from 'src/services/user_profile_link_generator/user_profile_link_generator'
@ -51,8 +50,7 @@ const Notification = {
Timeago, Timeago,
Status, Status,
RichContent, RichContent,
ConfirmModal, ConfirmModal
StillImage
}, },
methods: { methods: {
toggleUserExpanded () { toggleUserExpanded () {

View file

@ -116,13 +116,12 @@
scope="global" scope="global"
keypath="notifications.reacted_with" keypath="notifications.reacted_with"
> >
<still-image <img
v-if="notification.emoji_url !== null" v-if="notification.emoji_url !== null"
class="notification-reaction-emoji" class="notification-reaction-emoji"
:src="notification.emoji_url" :src="notification.emoji_url"
:title="notification.emoji" :name="notification.emoji"
:alt="notification.emoji" >
/>
<span <span
v-else v-else
class="emoji-reaction-emoji" class="emoji-reaction-emoji"
@ -152,6 +151,7 @@
> >
<Timeago <Timeago
:time="notification.created_at" :time="notification.created_at"
:with-direction="true"
:auto-update="240" :auto-update="240"
/> />
</router-link> </router-link>

View file

@ -5,7 +5,7 @@
placement="bottom" placement="bottom"
:bound-to="{ x: 'container' }" :bound-to="{ x: 'container' }"
> >
<template #content> <template v-slot:content>
<div class="dropdown-menu"> <div class="dropdown-menu">
<button <button
class="button-default dropdown-item" class="button-default dropdown-item"
@ -72,7 +72,7 @@
</button> </button>
</div> </div>
</template> </template>
<template #trigger> <template v-slot:trigger>
<button class="filter-trigger-button button-unstyled"> <button class="filter-trigger-button button-unstyled">
<FAIcon icon="filter" /> <FAIcon icon="filter" />
</button> </button>

View file

@ -105,12 +105,9 @@
flex: 1; flex: 1;
padding-left: 0.8em; padding-left: 0.8em;
min-width: 0; min-width: 0;
}
.heading-right, .notification-right {
.timeago { .timeago {
display: inline-block; min-width: 3em;
min-width: 6em;
text-align: right; text-align: right;
} }
} }

View file

@ -14,7 +14,7 @@
:model-value="present" :model-value="present"
:disabled="disabled" :disabled="disabled"
class="opt" class="opt"
@update:model-value="$emit('update:modelValue', !present ? fallback : undefined)" @update:modelValue="$emit('update:modelValue', !present ? fallback : undefined)"
/> />
<input <input
:id="name" :id="name"

View file

@ -2,6 +2,7 @@
<pinch-zoom <pinch-zoom
class="pinch-zoom-parent" class="pinch-zoom-parent"
v-bind="$attrs" v-bind="$attrs"
v-on="$listeners"
> >
<slot /> <slot />
</pinch-zoom> </pinch-zoom>

View file

@ -18,9 +18,7 @@
:placeholder="$t('polls.option')" :placeholder="$t('polls.option')"
:maxlength="maxLength" :maxlength="maxLength"
@change="updatePollToParent" @change="updatePollToParent"
@keydown.enter.stop.prevent=" @keydown.enter.stop.prevent="nextOption(index)"
$event.isComposing || $event.keyCode === 229 || nextOption(index)
"
> >
</div> </div>
<button <button

View file

@ -3,14 +3,14 @@
@mouseenter="onMouseenter" @mouseenter="onMouseenter"
@mouseleave="onMouseleave" @mouseleave="onMouseleave"
> >
<div <button
ref="trigger" ref="trigger"
class="button-unstyled popover-trigger-button" class="button-unstyled popover-trigger-button"
type="button" type="button"
@click="onClick" @click="onClick"
> >
<slot name="trigger" /> <slot name="trigger" />
</div> </button>
<div <div
v-if="!hidden" v-if="!hidden"
ref="content" ref="content"

View file

@ -9,12 +9,11 @@ import StatusContent from '../status_content/status_content.vue'
import fileTypeService from '../../services/file_type/file_type.service.js' import fileTypeService from '../../services/file_type/file_type.service.js'
import { findOffset } from '../../services/offset_finder/offset_finder.service.js' import { findOffset } from '../../services/offset_finder/offset_finder.service.js'
import { reject, map, uniqBy, debounce } from 'lodash' import { reject, map, uniqBy, debounce } from 'lodash'
import { usePostLanguageOptions } from 'src/lib/post_language'
import suggestor from '../emoji_input/suggestor.js' import suggestor from '../emoji_input/suggestor.js'
import { mapGetters, mapState } from 'vuex' import { mapGetters, mapState } from 'vuex'
import Checkbox from '../checkbox/checkbox.vue' import Checkbox from '../checkbox/checkbox.vue'
import Select from '../select/select.vue' import Select from '../select/select.vue'
import iso6391 from 'iso-639-1'
import { library } from '@fortawesome/fontawesome-svg-core' import { library } from '@fortawesome/fontawesome-svg-core'
import { import {
@ -63,13 +62,6 @@ const deleteDraft = (draftKey) => {
localStorage.setItem('drafts', JSON.stringify(draftData)); localStorage.setItem('drafts', JSON.stringify(draftData));
} }
const interfaceToISOLanguage = (ilang) => {
const sep = ilang.indexOf("_");
return sep < 0 ?
ilang :
ilang.substr(0, sep);
}
const PostStatusForm = { const PostStatusForm = {
props: [ props: [
'statusId', 'statusId',
@ -137,13 +129,6 @@ const PostStatusForm = {
this.$refs.textarea.focus() this.$refs.textarea.focus()
} }
}, },
setup() {
const {postLanguageOptions} = usePostLanguageOptions()
return {
postLanguageOptions,
}
},
data () { data () {
const preset = this.$route.query.message const preset = this.$route.query.message
let statusText = preset || '' let statusText = preset || ''
@ -153,15 +138,7 @@ const PostStatusForm = {
statusText = buildMentionsString({ user: this.repliedUser, attentions: this.attentions }, currentUser) statusText = buildMentionsString({ user: this.repliedUser, attentions: this.attentions }, currentUser)
} }
const { const { postContentType: contentType, sensitiveByDefault, sensitiveIfSubject, interfaceLanguage } = this.$store.getters.mergedConfig
postContentType: contentType,
postLanguage: defaultPostLanguage,
sensitiveByDefault,
sensitiveIfSubject,
interfaceLanguage,
alwaysShowSubjectInput,
} = this.$store.getters.mergedConfig
const postLanguage = defaultPostLanguage || interfaceToISOLanguage(interfaceLanguage)
let statusParams = { let statusParams = {
spoilerText: this.subject || '', spoilerText: this.subject || '',
@ -172,13 +149,12 @@ const PostStatusForm = {
poll: {}, poll: {},
mediaDescriptions: {}, mediaDescriptions: {},
visibility: this.suggestedVisibility(), visibility: this.suggestedVisibility(),
language: postLanguage, language: interfaceLanguage,
contentType contentType
} }
if (this.statusId || this.isRedraft) { if (this.statusId || this.isRedraft) {
const statusContentType = this.statusContentType || contentType const statusContentType = this.statusContentType || contentType
const statusLanguage = this.statusLanguage || language
statusParams = { statusParams = {
spoilerText: this.subject || '', spoilerText: this.subject || '',
status: this.statusText || '', status: this.statusText || '',
@ -188,7 +164,7 @@ const PostStatusForm = {
poll: this.statusPoll || {}, poll: this.statusPoll || {},
mediaDescriptions: this.statusMediaDescriptions || {}, mediaDescriptions: this.statusMediaDescriptions || {},
visibility: this.statusScope || this.suggestedVisibility(), visibility: this.statusScope || this.suggestedVisibility(),
language: this.statusLanguage || postLanguage, language: this.statusLanguage || interfaceLanguage,
contentType: statusContentType contentType: statusContentType
} }
} }
@ -213,8 +189,8 @@ const PostStatusForm = {
poll: draft.data.poll, poll: draft.data.poll,
mediaDescriptions: draft.data.mediaDescriptions, mediaDescriptions: draft.data.mediaDescriptions,
visibility: draft.data.visibility, visibility: draft.data.visibility,
contentType: draft.data.contentType, language: draft.data.language,
language: draft.data.language contentType: draft.data.contentType
} }
if (draft.data.poll) { if (draft.data.poll) {
@ -223,10 +199,6 @@ const PostStatusForm = {
} }
} }
// When first loading the form, hide the subject (CW) field if it's disabled or doesn't have a starting value.
// "disableSubject" seems to take priority over "alwaysShowSubjectInput".
const showSubject = !this.disableSubject && (statusParams.spoilerText || alwaysShowSubjectInput)
return { return {
dropFiles: [], dropFiles: [],
uploadingFiles: false, uploadingFiles: false,
@ -241,10 +213,7 @@ const PostStatusForm = {
preview: null, preview: null,
previewLoading: false, previewLoading: false,
emojiInputShown: false, emojiInputShown: false,
idempotencyKey: '', idempotencyKey: ''
activeEmojiInput: undefined,
activeTextInput: undefined,
subjectVisible: showSubject
} }
}, },
computed: { computed: {
@ -333,11 +302,13 @@ const PostStatusForm = {
...mapState({ ...mapState({
mobileLayout: state => state.interface.mobileLayout mobileLayout: state => state.interface.mobileLayout
}), }),
isoLanguages () {
return iso6391.getAllCodes();
}
}, },
watch: { watch: {
'newStatus': { 'newStatus': {
deep: true, deep: true,
flush: 'sync',
handler () { handler () {
this.statusChanged() this.statusChanged()
} }
@ -703,33 +674,8 @@ const PostStatusForm = {
this.$refs['emoji-input'].resize() this.$refs['emoji-input'].resize()
}, },
showEmojiPicker () { showEmojiPicker () {
if (!this.activeEmojiInput || !this.activeTextInput) this.$refs['textarea'].focus()
this.focusStatusInput() this.$refs['emoji-input'].triggerShowPicker()
this.$refs[this.activeTextInput].focus()
this.$refs[this.activeEmojiInput].triggerShowPicker()
},
focusStatusInput() {
this.activeEmojiInput = 'emoji-input'
this.activeTextInput = 'textarea'
},
focusSubjectInput() {
this.activeEmojiInput = 'subject-emoji-input'
this.activeTextInput = 'subject-input'
},
toggleSubjectVisible() {
// If hiding CW, then we need to clear the subject and reset focus
if (this.subjectVisible)
{
this.focusStatusInput()
// "nsfw" property is normally set by the @change listener, but this bypasses it.
// We need to clear it manually instead.
this.newStatus.spoilerText = ''
this.newStatus.nsfw = false
}
this.subjectVisible = !this.subjectVisible
}, },
clearError () { clearError () {
this.error = null this.error = null

View file

@ -118,16 +118,13 @@
/> />
</div> </div>
<EmojiInput <EmojiInput
v-if="subjectVisible" v-if="!disableSubject && (newStatus.spoilerText || alwaysShowSubject)"
ref="subject-emoji-input"
v-model="newStatus.spoilerText" v-model="newStatus.spoilerText"
enable-emoji-picker enable-emoji-picker
hide-emoji-button
:suggest="emojiSuggestor" :suggest="emojiSuggestor"
class="form-control" class="form-control"
> >
<input <input
ref="subject-input"
v-model="newStatus.spoilerText" v-model="newStatus.spoilerText"
type="text" type="text"
:placeholder="$t('post_status.content_warning')" :placeholder="$t('post_status.content_warning')"
@ -135,7 +132,6 @@
size="1" size="1"
class="form-post-subject" class="form-post-subject"
@input="onSubjectInput" @input="onSubjectInput"
@focus="focusSubjectInput()"
> >
</EmojiInput> </EmojiInput>
<i18n-t <i18n-t
@ -170,18 +166,13 @@
cols="1" cols="1"
:disabled="posting && !optimisticPosting" :disabled="posting && !optimisticPosting"
class="form-post-body" class="form-post-body"
:class="{ 'scrollable-form': !!maxHeight, '-has-subject': subjectVisible }" :class="{ 'scrollable-form': !!maxHeight }"
@keydown.exact.enter=" @keydown.exact.enter="submitOnEnter && postStatus($event, newStatus)"
$event.isComposing ||
$event.keyCode === 229 ||
(submitOnEnter && postStatus($event, newStatus))
"
@keydown.meta.enter="postStatus($event, newStatus)" @keydown.meta.enter="postStatus($event, newStatus)"
@keydown.ctrl.enter="!submitOnEnter && postStatus($event, newStatus)" @keydown.ctrl.enter="!submitOnEnter && postStatus($event, newStatus)"
@input="resize" @input="resize"
@compositionupdate="resize" @compositionupdate="resize"
@paste="paste" @paste="paste"
@focus="focusStatusInput()"
/> />
<p <p
v-if="hasStatusLengthLimit" v-if="hasStatusLengthLimit"
@ -194,7 +185,6 @@
<div <div
v-if="!disableScopeSelector" v-if="!disableScopeSelector"
class="visibility-tray" class="visibility-tray"
:class="{ 'visibility-tray-edit': isEdit }"
> >
<scope-selector <scope-selector
v-if="!disableVisibilitySelector" v-if="!disableVisibilitySelector"
@ -205,9 +195,7 @@
/> />
<div <div
class="format-selector-container"> class="language-selector"
<div
class="format-selector"
> >
<Select <Select
id="post-language" id="post-language"
@ -215,17 +203,17 @@
class="form-control" class="form-control"
> >
<option <option
v-for="language in postLanguageOptions" v-for="language in isoLanguages"
:key="language.key" :key="language"
:value="language.value" :value="language"
> >
{{ language.label }} {{ language }}
</option> </option>
</Select> </Select>
</div> </div>
<div <div
v-if="postFormats.length > 1" v-if="postFormats.length > 1"
class="text-format format-selector" class="text-format"
> >
<Select <Select
id="post-content-type" id="post-content-type"
@ -243,7 +231,7 @@
</div> </div>
<div <div
v-if="postFormats.length === 1 && postFormats[0] !== 'text/plain'" v-if="postFormats.length === 1 && postFormats[0] !== 'text/plain'"
class="text-format format-selector" class="text-format"
> >
<span class="only-format"> <span class="only-format">
{{ $t(`post_status.content_type["${postFormats[0]}"]`) }} {{ $t(`post_status.content_type["${postFormats[0]}"]`) }}
@ -251,7 +239,6 @@
</div> </div>
</div> </div>
</div> </div>
</div>
<poll-form <poll-form
v-if="pollsAvailable" v-if="pollsAvailable"
ref="pollForm" ref="pollForm"
@ -289,15 +276,6 @@
> >
<FAIcon icon="poll-h" /> <FAIcon icon="poll-h" />
</button> </button>
<button
v-if="!disableSubject"
class="spoiler-icon button-unstyled"
:class="{ selected: subjectVisible }"
:title="$t('post_status.toggle_content_warning')"
@click="toggleSubjectVisible"
>
<FAIcon icon="eye-slash" />
</button>
</div> </div>
<button <button
v-if="posting" v-if="posting"
@ -468,10 +446,6 @@
align-items: baseline; align-items: baseline;
} }
.visibility-tray-edit {
justify-content: right;
}
.visibility-notice.edit-warning { .visibility-notice.edit-warning {
> :first-child { > :first-child {
margin-top: 0; margin-top: 0;
@ -482,13 +456,7 @@
} }
} }
.format-selector-container { .media-upload-icon, .poll-icon, .emoji-icon {
.format-selector {
display: inline-block;
}
}
.media-upload-icon, .poll-icon, .emoji-icon, .spoiler-icon {
font-size: 1.85em; font-size: 1.85em;
line-height: 1.1; line-height: 1.1;
flex: 1; flex: 1;
@ -531,11 +499,6 @@
.poll-icon { .poll-icon {
order: 3; order: 3;
justify-content: center;
}
.spoiler-icon {
order: 4;
justify-content: right; justify-content: right;
} }
@ -588,11 +551,6 @@
line-height: 1.85; line-height: 1.85;
} }
.form-post-subject {
border-bottom-left-radius: 0;
border-bottom-right-radius: 0;
}
.form-post-body { .form-post-body {
// TODO: make a resizable textarea component? // TODO: make a resizable textarea component?
box-sizing: content-box; // needed for easier computation of dynamic size box-sizing: content-box; // needed for easier computation of dynamic size
@ -605,11 +563,6 @@
min-height: calc(var(--post-line-height) * 1em); min-height: calc(var(--post-line-height) * 1em);
resize: none; resize: none;
&.-has-subject {
border-top-left-radius: 0;
border-top-right-radius: 0;
}
&.scrollable-form { &.scrollable-form {
overflow-y: auto; overflow-y: auto;
} }

View file

@ -3,7 +3,7 @@
v-if="isLoggedIn && !resettingForm" v-if="isLoggedIn && !resettingForm"
:is-open="modalActivated" :is-open="modalActivated"
class="post-form-modal-view" class="post-form-modal-view"
@backdrop-clicked="closeModal" @backdropClicked="closeModal"
> >
<div class="post-form-modal-panel panel"> <div class="post-form-modal-panel panel">
<div class="panel-heading"> <div class="panel-heading">

View file

@ -8,13 +8,13 @@
remove-padding remove-padding
@show="focusInput" @show="focusInput"
> >
<template #content="{close}"> <template v-slot:content="{close}">
<EmojiPicker <EmojiPicker
:enable-sticker-picker="false" :enable-sticker-picker="false"
@emoji="addReaction($event, close)" @emoji="addReaction($event, close)"
/> />
</template> </template>
<template #trigger> <template v-slot:trigger>
<button <button
class="button-unstyled popover-trigger" class="button-unstyled popover-trigger"
:title="$t('tool_tip.add_reaction')" :title="$t('tool_tip.add_reaction')"

View file

@ -2,7 +2,7 @@ export default {
props: [ 'user' ], props: [ 'user' ],
computed: { computed: {
subscribeUrl () { subscribeUrl () {
// eslint-disable-next-line no-undef
const serverUrl = new URL(this.user.statusnet_profile_url) const serverUrl = new URL(this.user.statusnet_profile_url)
return `${serverUrl.protocol}//${serverUrl.host}/main/ostatus` return `${serverUrl.protocol}//${serverUrl.host}/main/ostatus`
} }

View file

@ -1,54 +1,38 @@
import FollowCard from '../follow_card/follow_card.vue' import FollowCard from '../follow_card/follow_card.vue'
import Conversation from '../conversation/conversation.vue' import Conversation from '../conversation/conversation.vue'
import Status from '../status/status.vue'
import TabSwitcher from 'src/components/tab_switcher/tab_switcher.jsx' import TabSwitcher from 'src/components/tab_switcher/tab_switcher.jsx'
import SearchFilters from './search_filters.vue'
import map from 'lodash/map' import map from 'lodash/map'
import { library } from '@fortawesome/fontawesome-svg-core' import { library } from '@fortawesome/fontawesome-svg-core'
import { import {
faCircleNotch, faCircleNotch,
faSearch faSearch
} from '@fortawesome/free-solid-svg-icons' } from '@fortawesome/free-solid-svg-icons'
import { uniqBy } from 'lodash'
library.add( library.add(
faCircleNotch, faCircleNotch,
faSearch faSearch
) )
const allSearchTypes = Object.freeze(['statuses', 'media', 'accounts', 'hashtags'])
const Search = { const Search = {
components: { components: {
FollowCard, FollowCard,
Conversation, Conversation,
TabSwitcher, Status,
SearchFilters TabSwitcher
}, },
props: [ props: [
'query' 'query'
], ],
data () { data () {
return { return {
queryCount: 0, loaded: false,
loadedInitially: false, loading: false,
loading: Object.fromEntries(
allSearchTypes.map((searchType) => [searchType, false])
),
searchTerm: this.query || '', searchTerm: this.query || '',
userIds: [], userIds: [],
statuses: [], statuses: [],
media: [],
hashtags: [], hashtags: [],
allTabs: allSearchTypes, currenResultTab: 'statuses'
currentResultTab: 'statuses',
hasUserSelectedTab: false,
statusesOffset: 0,
lastStatusFetchCount: 0,
mediaOffset: 0,
lastMediaFetchCount: 0,
lastQuery: '',
filter: {}
} }
}, },
computed: { computed: {
@ -61,20 +45,6 @@ const Search = {
return this.statuses.filter(status => return this.statuses.filter(status =>
allStatusesObject[status.id] && !allStatusesObject[status.id].deleted allStatusesObject[status.id] && !allStatusesObject[status.id].deleted
) )
},
visibleMedia () {
const allStatusesObject = this.$store.state.statuses.allStatusesObject
return this.media.filter(status =>
allStatusesObject[status.id] && !allStatusesObject[status.id].deleted
)
},
canSearchMediaPosts () {
return this.$store.state.instance.searchTypeMediaEnabled === true
},
hasAtLeastOneResult () {
return allSearchTypes
.some((searchType) => this.getVisibleLength(searchType) > 0)
} }
}, },
mounted () { mounted () {
@ -84,191 +54,55 @@ const Search = {
query (newValue) { query (newValue) {
this.searchTerm = newValue this.searchTerm = newValue
this.search(newValue) this.search(newValue)
},
filter: {
deep: true,
handler () {
this.lastQuery = "" // invalidate state
this.search(this.searchTerm)
}
} }
}, },
methods: { methods: {
newQuery (query) { newQuery (query) {
if (this.lastQuery === query && !this.loading[this.currentResultTab]) {
// Handle search retries
this.lastQuery = "" // invalidate state
this.search(query)
} else {
this.$router.push({ name: 'search', query: { query } }) this.$router.push({ name: 'search', query: { query } })
this.$refs.searchInput.focus() this.$refs.searchInput.focus()
}
}, },
async search (query, searchType = null) { search (query) {
if (!query) { if (!query) {
for (const searchType of allSearchTypes) { this.loading = false
this.loading[searchType] = false
}
return return
} }
const localQueryCount = ++this.queryCount this.loading = true
const isNewSearch = this.lastQuery !== query
this.$refs.searchInput.blur()
if (isNewSearch) {
this.userIds = [] this.userIds = []
this.hashtags = []
this.statuses = [] this.statuses = []
this.media = [] this.hashtags = []
this.$refs.searchInput.blur()
this.statusesOffset = 0 this.$store.dispatch('search', { q: query, resolve: true })
this.lastStatusFetchCount = 0 .then(data => {
this.mediaOffset = 0 this.loading = false
this.lastMediaFetchCount = 0 this.userIds = map(data.accounts, 'id')
} this.statuses = data.statuses
this.hashtags = data.hashtags
let searchTypes = allSearchTypes this.currenResultTab = this.getActiveTab()
if (searchType) { this.loaded = true
// Search only for `searchType` if it is provided
searchTypes = [searchType]
} else if (this.hasUserSelectedTab && this.currentResultTab !== 'statuses') {
// Start the search from the tab that the user has selected
// No need to sort if userPreferredTab === 'statuses'
searchTypes = [
this.currentResultTab,
...allSearchTypes.filter((tab) => tab !== this.currentResultTab)
]
}
for (const searchType of searchTypes) {
this.loading[searchType] = true
}
let oldStatusesLength = this.statuses.length
let oldMediaLength = this.media.length
let skipMediaSearch = !this.canSearchMediaPosts
for (const searchType of searchTypes) {
try {
if (searchType === 'media' && skipMediaSearch) {
continue
}
let searchOffset
if (searchType === 'statuses') {
searchOffset = this.statusesOffset
} else if (searchType === 'media') {
searchOffset = this.mediaOffset
}
const data = await this.$store.dispatch('search', {
q: query,
resolve: true,
offset: searchOffset,
'type': searchType,
following:
'followingOnly' in this.filter && this.filter.followingOnly,
local: 'localOnly' in this.filter && this.filter.localOnly
}) })
if (localQueryCount !== this.queryCount) {
// Query count differs, there should be a newer query
return
}
// Always append to old results. If new results are empty, this doesn't change anything
this.userIds = this.userIds.concat(map(data.accounts, 'id'))
this.statuses = uniqBy(this.statuses.concat(data.statuses), 'id')
if ('media' in data) {
this.media = uniqBy(this.media.concat(data.media), 'id')
}
this.hashtags = this.hashtags.concat(data.hashtags)
if (isNewSearch) {
if (!this.hasUserSelectedTab) {
this.currentResultTab = this.getFirstTabWithResults()
}
if (searchType === 'statuses' && data.statuses.length === 0) {
// Safe to assume that there are no media posts
skipMediaSearch = true
}
}
} catch (error) {
console.error(error)
} finally {
if (localQueryCount !== this.queryCount) {
// Skip cleanups if there's a newer query
return
}
if (!this.loadedInitially && this.hasAtLeastOneResult) {
// Show results on the first meaningful response
this.loadedInitially = true
}
this.loading[searchType] = false
if (searchType === 'statuses') {
// Offset from whatever we already have
this.statusesOffset = this.statuses.length
// Because the amount of new statuses can actually be zero, compare to old length instead
this.lastStatusFetchCount = this.statuses.length - oldStatusesLength
} else if (searchType === 'media') {
this.mediaOffset = this.media.length
this.lastMediaFetchCount = this.media.length - oldMediaLength
}
}
}
this.lastQuery = query
this.loadedInitially = true
for (const searchType of allSearchTypes) {
this.loading[searchType] = false
}
}, },
resultCount (tab) { resultCount (tabName) {
const length = this.getVisibleLength(tab) const length = this[tabName].length
return length === 0 ? '' : ` (${length})`
if (length === 0 || !this.loadedInitially) {
return ''
}
if (
(tab === 'statuses' && this.lastStatusFetchCount !== 0) ||
(tab === 'media' && this.lastMediaFetchCount !== 0)
) {
return ` (${length}+)`
}
return ` (${length})`
}, },
onResultTabSwitch (key) { onResultTabSwitch (key) {
this.currentResultTab = key this.currenResultTab = key
this.hasUserSelectedTab = true
}, },
getFirstTabWithResults () { getActiveTab () {
for (const tab of allSearchTypes) { if (this.visibleStatuses.length > 0) {
if (this.getVisibleLength(tab) > 0) { return 'statuses'
return tab } else if (this.users.length > 0) {
} return 'people'
} else if (this.hashtags.length > 0) {
return 'hashtags'
} }
return 'statuses' return 'statuses'
}, },
lastHistoryRecord (hashtag) { lastHistoryRecord (hashtag) {
return hashtag.history && hashtag.history[0] return hashtag.history && hashtag.history[0]
},
getVisibleLength (tab) {
if (tab === 'statuses') {
return this.visibleStatuses.length
} else if (tab === 'media') {
return this.visibleMedia.length
} else if (tab === 'accounts') {
return this.users.length
} else if (tab === 'hashtags') {
return this.hashtags.length
}
} }
} }
} }

View file

@ -4,9 +4,6 @@
<div class="title"> <div class="title">
{{ $t('nav.search') }} {{ $t('nav.search') }}
</div> </div>
<SearchFilters
v-model="filter"
/>
</div> </div>
<div class="search-input-container"> <div class="search-input-container">
<input <input
@ -14,9 +11,7 @@
v-model="searchTerm" v-model="searchTerm"
class="search-input" class="search-input"
:placeholder="$t('nav.search')" :placeholder="$t('nav.search')"
@keydown.enter=" @keyup.enter="newQuery(searchTerm)"
$event.isComposing || $event.keyCode === 229 || newQuery(searchTerm)
"
> >
<button <button
class="btn button-default search-button" class="btn button-default search-button"
@ -26,25 +21,30 @@
<FAIcon icon="search" /> <FAIcon icon="search" />
</button> </button>
</div> </div>
<div v-if="loadedInitially"> <div
v-if="loading"
class="text-center loading-icon"
>
<FAIcon
icon="circle-notch"
spin
size="lg"
/>
</div>
<div v-else-if="loaded">
<div class="search-nav-heading"> <div class="search-nav-heading">
<tab-switcher <tab-switcher
ref="tabSwitcher" ref="tabSwitcher"
:on-switch="onResultTabSwitch" :on-switch="onResultTabSwitch"
:active-tab="currentResultTab" :active-tab="currenResultTab"
> >
<span <span
key="statuses" key="statuses"
:label="$t('user_card.statuses') + resultCount('statuses')" :label="$t('user_card.statuses') + resultCount('visibleStatuses')"
/> />
<span <span
v-if="canSearchMediaPosts" key="people"
key="media" :label="$t('search.people') + resultCount('users')"
:label="$t('user_card.media') + resultCount('media')"
/>
<span
key="accounts"
:label="$t('search.people') + resultCount('accounts')"
/> />
<span <span
key="hashtags" key="hashtags"
@ -54,97 +54,27 @@
</div> </div>
</div> </div>
<div class="panel-body"> <div class="panel-body">
<div v-if="currenResultTab === 'statuses'">
<div <div
v-if="!Object.values(loading).includes(false) && !hasAtLeastOneResult" v-if="visibleStatuses.length === 0 && !loading && loaded"
class="text-center loading-icon" class="search-result-heading"
> >
<FAIcon <h4>{{ $t('search.no_results') }}</h4>
icon="circle-notch"
spin
size="lg"
/>
</div> </div>
<div v-if="currentResultTab === 'statuses'"> <Status
<Conversation
v-for="status in visibleStatuses" v-for="status in visibleStatuses"
:key="status.id" :key="status.id"
:collapsable="true" :collapsable="false"
class="status-fadein" :expandable="false"
:status-id="status.id" :compact="false"
/> class="search-result"
<button :statusoid="status"
v-if="!loading['statuses'] && loadedInitially && lastStatusFetchCount > 0" :no-heading="false"
class="more-statuses-button button-unstyled -link -fullwidth"
@click.prevent="search(lastQuery, 'statuses')"
>
<div class="new-status-notification text-center">
{{ $t('search.load_more') }}
</div>
</button>
<div
v-else-if="loading['statuses'] && statusesOffset > 0"
class="text-center loading-icon"
>
<FAIcon
icon="circle-notch"
spin
size="lg"
/> />
</div> </div>
<div v-else-if="currenResultTab === 'people'">
<div <div
v-if=" v-if="users.length === 0 && !loading && loaded"
(visibleStatuses.length === 0 || lastStatusFetchCount === 0) &&
!loading['statuses'] && loadedInitially
"
class="search-result-heading"
>
<h4>
{{ visibleStatuses.length === 0 ? $t('search.no_results') : $t('search.no_more_results') }}
</h4>
</div>
</div>
<div v-if="currentResultTab === 'media'">
<Conversation
v-for="media in visibleMedia"
:key="media.id"
:collapsable="true"
class="status-fadein"
:status-id="media.id"
/>
<button
v-if="!loading['media'] && loadedInitially && lastMediaFetchCount > 0"
class="more-statuses-button button-unstyled -link -fullwidth"
@click.prevent="search(lastQuery, 'media')"
>
<div class="new-status-notification text-center">
{{ $t('search.load_more') }}
</div>
</button>
<div
v-else-if="loading['media'] && mediaOffset > 0"
class="text-center loading-icon"
>
<FAIcon
icon="circle-notch"
spin
size="lg"
/>
</div>
<div
v-if="
(visibleMedia.length === 0 || lastMediaFetchCount === 0) &&
!loading['media'] && loadedInitially
"
class="search-result-heading"
>
<h4>
{{ visibleMedia.length === 0 ? $t('search.no_results') : $t('search.no_more_results') }}
</h4>
</div>
</div>
<div v-else-if="currentResultTab === 'accounts'">
<div
v-if="users.length === 0 && !loading['accounts'] && loadedInitially"
class="search-result-heading" class="search-result-heading"
> >
<h4>{{ $t('search.no_results') }}</h4> <h4>{{ $t('search.no_results') }}</h4>
@ -156,9 +86,9 @@
class="list-item search-result" class="list-item search-result"
/> />
</div> </div>
<div v-else-if="currentResultTab === 'hashtags'"> <div v-else-if="currenResultTab === 'hashtags'">
<div <div
v-if="hashtags.length === 0 && !loading['hashtags'] && loadedInitially" v-if="hashtags.length === 0 && !loading && loaded"
class="search-result-heading" class="search-result-heading"
> >
<h4>{{ $t('search.no_results') }}</h4> <h4>{{ $t('search.no_results') }}</h4>
@ -217,6 +147,13 @@
} }
} }
.search-result {
box-sizing: border-box;
border-bottom: 1px solid;
border-color: $fallback--border;
border-color: var(--border, $fallback--border);
}
.search-result-footer { .search-result-footer {
border-width: 1px 0 0 0; border-width: 1px 0 0 0;
border-style: solid; border-style: solid;
@ -241,10 +178,6 @@
.search-button { .search-button {
margin-left: 0.5em; margin-left: 0.5em;
width: 3em;
display: flex;
justify-content: center;
align-items: center;
} }
} }
@ -256,12 +189,6 @@
display: flex; display: flex;
align-items: center; align-items: center;
& + & {
border-top: 1px solid;
border-color: $fallback--border;
border-color: var(--border, $fallback--border);
}
.hashtag { .hashtag {
flex: 1 1 auto; flex: 1 1 auto;
color: $fallback--text; color: $fallback--text;
@ -269,12 +196,6 @@
overflow: hidden; overflow: hidden;
text-overflow: ellipsis; text-overflow: ellipsis;
white-space: nowrap; white-space: nowrap;
box-sizing: border-box;
a {
display: inline-block;
padding: .5em 1em;
}
} }
.count { .count {
@ -289,8 +210,4 @@
} }
} }
.more-statuses-button {
height: 3.5em;
line-height: 3.5em;
}
</style> </style>

View file

@ -1,116 +0,0 @@
<template>
<Popover
v-if="showFilters"
trigger="click"
class="SearchFilters"
placement="bottom"
:bound-to="{ x: 'container' }"
>
<template #content>
<div class="dropdown-menu">
<button
v-if="canSearchFollowing"
class="button-default dropdown-item"
@click="toggleFilter('followingOnly')"
>
<span
class="menu-checkbox"
:class="{ 'menu-checkbox-checked': currentFilter.followingOnly }"
/>{{ $t('lists.following_only') }}
</button>
<button
v-if="canSearchLocal"
class="button-default dropdown-item"
@click="toggleFilter('localOnly')"
>
<span
class="menu-checkbox"
:class="{ 'menu-checkbox-checked': currentFilter.localOnly }"
/>{{ $t('search.local_only') }}
</button>
</div>
</template>
<template #trigger>
<button class="filter-trigger-button button-unstyled">
<FAIcon icon="filter" />
</button>
</template>
</Popover>
</template>
<script>
import Popover from '../popover/popover.vue'
import { library } from '@fortawesome/fontawesome-svg-core'
import { faFilter } from '@fortawesome/free-solid-svg-icons'
library.add(
faFilter
)
export default {
components: { Popover },
props: [
'modelValue'
],
emits: [
'update:modelValue'
],
data () {
return {
currentFilter: {
followingOnly: false,
localOnly: false
}
}
},
computed: {
showFilters () {
return this.canSearchFollowing || this.canSearchLocal
},
isLoggedIn () {
return !!this.$store.state.users.currentUser
},
canSearchFollowing () {
return this.isLoggedIn &&
this.$store.state.instance.searchOptionFollowingEnabled
},
canSearchLocal () {
return this.$store.state.instance.searchOptionLocalEnabled
},
},
created () {
for (const filterName of Object.entries(this.currentFilter)) {
if (this.modelValue && filterName in this.modelValue) {
this.currentFilter[filterName] = this.modelValue[filterName]
}
}
},
methods: {
toggleFilter (filterName) {
if (filterName in this.currentFilter) {
this.currentFilter[filterName] = !this.currentFilter[filterName]
} else {
this.currentFilter[filterName] = true
}
this.$emit('update:modelValue', this.currentFilter)
}
}
}
</script>
<style lang="scss">
.SearchFilters {
align-self: stretch;
> button {
line-height: 100%;
height: 100%;
width: var(--__panel-heading-height-inner);
text-align: center;
svg {
font-size: 1.2em;
}
}
}
</style>

View file

@ -10,12 +10,6 @@ library.add(
) )
const SearchBar = { const SearchBar = {
mounted () {
window.addEventListener('keydown', this.autoFocus)
},
beforeDestroy () {
window.removeEventListener('keydown', this.autoFocus)
},
data: () => ({ data: () => ({
searchTerm: undefined, searchTerm: undefined,
hidden: true, hidden: true,
@ -42,38 +36,6 @@ const SearchBar = {
this.$refs.searchInput.focus() this.$refs.searchInput.focus()
} }
}) })
},
autoFocus (event) {
if (
event.target.tagName !== 'BODY' ||
event.altKey ||
event.ctrlKey ||
event.isComposing ||
event.metaKey ||
event.repeat ||
event.shiftKey ||
// not very vue-esque, but as long as it works
document.querySelector('.modal-view.modal-background.open')
) {
return
}
if (event.key === '/') {
if (this.hidden) {
this.toggleHidden()
} else {
this.$refs.searchInput.focus()
}
event.preventDefault()
} else if (event.key === 'Escape') {
if (!this.hidden) {
this.toggleHidden()
}
event.preventDefault()
}
},
blur () {
this.$refs.searchInput.blur()
} }
} }
} }

View file

@ -24,12 +24,7 @@
class="search-bar-input" class="search-bar-input"
:placeholder="$t('nav.search')" :placeholder="$t('nav.search')"
type="text" type="text"
@keydown.enter=" @keyup.enter="find(searchTerm)"
$event.isComposing || $event.keyCode === 229 || find(searchTerm)
"
@keydown.escape="
$event.isComposing || blur()
"
> >
<button <button
class="button-default search-button" class="button-default search-button"
@ -63,7 +58,8 @@
.SearchBar { .SearchBar {
display: inline-flex; display: inline-flex;
align-items: center; align-items: baseline;
vertical-align: baseline;
justify-content: flex-end; justify-content: flex-end;
&.-expanded { &.-expanded {
@ -75,21 +71,13 @@
height: 29px; height: 29px;
} }
.search-button {
margin-left: 0.5em;
width: 3em;
display: flex;
justify-content: center;
align-items: center;
}
.search-bar-input { .search-bar-input {
flex: 1 0 auto; flex: 1 0 auto;
margin-left: 0.5em; margin-left: 0.5em;
} }
.cancel-search { .cancel-search {
height: 100%; height: 50px;
} }
.cancel-icon { .cancel-icon {

View file

@ -24,7 +24,7 @@
:items="items" :items="items"
:get-key="getKey" :get-key="getKey"
> >
<template #item="{item}"> <template v-slot:item="{item}">
<div <div
class="selectable-list-item-inner" class="selectable-list-item-inner"
:class="{ 'selectable-list-item-selected-inner': isSelected(item) }" :class="{ 'selectable-list-item-selected-inner': isSelected(item) }"
@ -41,7 +41,7 @@
/> />
</div> </div>
</template> </template>
<template #empty> <template v-slot:empty>
<slot name="empty" /> <slot name="empty" />
</template> </template>
</List> </List>

View file

@ -6,7 +6,7 @@
<Checkbox <Checkbox
:model-value="state" :model-value="state"
:disabled="disabled" :disabled="disabled"
@update:model-value="update" @update:modelValue="update"
> >
<span <span
v-if="!!$slots.default" v-if="!!$slots.default"

View file

@ -8,7 +8,7 @@
<Select <Select
:model-value="state" :model-value="state"
:disabled="disabled" :disabled="disabled"
@update:model-value="update" @update:modelValue="update"
> >
<option <option
v-for="option in options" v-for="option in options"

View file

@ -6,14 +6,14 @@
<Popover <Popover
trigger="hover" trigger="hover"
> >
<template #trigger> <template v-slot:trigger>
&nbsp; &nbsp;
<FAIcon <FAIcon
icon="wrench" icon="wrench"
:aria-label="$t('settings.setting_changed')" :aria-label="$t('settings.setting_changed')"
/> />
</template> </template>
<template #content> <template v-slot:content>
<div class="modified-tooltip"> <div class="modified-tooltip">
{{ $t('settings.setting_changed') }} {{ $t('settings.setting_changed') }}
</div> </div>

View file

@ -6,14 +6,14 @@
<Popover <Popover
trigger="hover" trigger="hover"
> >
<template #trigger> <template v-slot:trigger>
&nbsp; &nbsp;
<FAIcon <FAIcon
icon="server" icon="server"
:aria-label="$t('settings.setting_server_side')" :aria-label="$t('settings.setting_server_side')"
/> />
</template> </template>
<template #content> <template v-slot:content>
<div class="serverside-tooltip"> <div class="serverside-tooltip">
{{ $t('settings.setting_server_side') }} {{ $t('settings.setting_server_side') }}
</div> </div>

View file

@ -69,7 +69,7 @@ const SettingsModal = {
this.$store.dispatch('closeSettingsModal') this.$store.dispatch('closeSettingsModal')
}, },
logout () { logout () {
this.$router.replace(this.$store.state.instance.redirectRootNoLogin || '/main/all') this.$router.replace('/main/public')
this.$store.dispatch('closeSettingsModal') this.$store.dispatch('closeSettingsModal')
this.$store.dispatch('logout') this.$store.dispatch('logout')
}, },

View file

@ -4,7 +4,6 @@
class="settings-modal" class="settings-modal"
:class="{ peek: modalPeeked }" :class="{ peek: modalPeeked }"
:no-background="modalPeeked" :no-background="modalPeeked"
@backdropClicked="closeModal"
> >
<div class="settings-modal-panel panel"> <div class="settings-modal-panel panel">
<div class="panel-heading"> <div class="panel-heading">
@ -109,7 +108,7 @@
<Checkbox <Checkbox
:model-value="!!expertLevel" :model-value="!!expertLevel"
class="expertMode" class="expertMode"
@update:model-value="expertLevel = Number($event)" @update:modelValue="expertLevel = Number($event)"
> >
{{ $t("settings.expert_mode") }} {{ $t("settings.expert_mode") }}
</Checkbox> </Checkbox>

View file

@ -72,7 +72,7 @@ const DataImportExportTab = {
// check is it's a local user // check is it's a local user
if (user && user.is_local) { if (user && user.is_local) {
// append the instance address // append the instance address
// eslint-disable-next-line no-undef
return user.screen_name + '@' + location.hostname return user.screen_name + '@' + location.hostname
} }
return user.screen_name return user.screen_name

View file

@ -4,14 +4,12 @@ import ScopeSelector from 'src/components/scope_selector/scope_selector.vue'
import IntegerSetting from '../helpers/integer_setting.vue' import IntegerSetting from '../helpers/integer_setting.vue'
import InterfaceLanguageSwitcher from 'src/components/interface_language_switcher/interface_language_switcher.vue' import InterfaceLanguageSwitcher from 'src/components/interface_language_switcher/interface_language_switcher.vue'
import { usePostLanguageOptions } from 'src/lib/post_language'
import SharedComputedObject from '../helpers/shared_computed_object.js' import SharedComputedObject from '../helpers/shared_computed_object.js'
import ServerSideIndicator from '../helpers/server_side_indicator.vue' import ServerSideIndicator from '../helpers/server_side_indicator.vue'
import { library } from '@fortawesome/fontawesome-svg-core' import { library } from '@fortawesome/fontawesome-svg-core'
import { import {
faGlobe, faSync faGlobe, faSync
} from '@fortawesome/free-solid-svg-icons' } from '@fortawesome/free-solid-svg-icons'
import iso6391 from 'iso-639-1'
library.add( library.add(
faGlobe, faGlobe,
@ -19,11 +17,6 @@ library.add(
) )
const GeneralTab = { const GeneralTab = {
setup() {
const {postLanguageOptions} = usePostLanguageOptions()
return {postLanguageOptions}
},
data () { data () {
return { return {
subjectLineOptions: ['email', 'noop', 'masto'].map(mode => ({ subjectLineOptions: ['email', 'noop', 'masto'].map(mode => ({
@ -87,15 +80,6 @@ const GeneralTab = {
label: this.$t(`post_status.content_type["${format}"]`) label: this.$t(`post_status.content_type["${format}"]`)
})) }))
}, },
postLanguages () {
return iso6391.getLanguages(iso6391.getAllCodes())
.map(lang => ({
key: lang.code,
value: lang.code,
label: lang.nativeName,
}))
.sort((a, b) => a.label.localeCompare(b.label));
},
instanceSpecificPanelPresent () { return this.$store.state.instance.showInstanceSpecificPanel }, instanceSpecificPanelPresent () { return this.$store.state.instance.showInstanceSpecificPanel },
instanceWallpaperUsed () { instanceWallpaperUsed () {
return this.$store.state.instance.background && return this.$store.state.instance.background &&
@ -134,12 +118,6 @@ const GeneralTab = {
this.$store.dispatch('setOption', { name: 'translationLanguage', value: val }) this.$store.dispatch('setOption', { name: 'translationLanguage', value: val })
} }
}, },
postLanguage: {
get: function () { return this.$store.getters.mergedConfig.postLanguage },
set: function (val) {
this.$store.dispatch('setOption', { name: 'postLanguage', value: val })
}
},
...SharedComputedObject() ...SharedComputedObject()
}, },
methods: { methods: {

View file

@ -44,6 +44,7 @@
<template <template
v-if="profilesExpanded" v-if="profilesExpanded"
> >
<div <div
v-for="profile in settingsProfiles" v-for="profile in settingsProfiles"
:key="profile.id" :key="profile.id"
@ -72,24 +73,15 @@
</button> </button>
</template> </template>
</div> </div>
<button <button class="btn button-default" @click="refreshProfiles()">
class="btn button-default"
@click="refreshProfiles()"
>
{{ $t('settings.settings_profiles_refresh') }} {{ $t('settings.settings_profiles_refresh') }}
<FAIcon <FAIcon icon="sync" @click="refreshProfiles()" />
icon="sync"
@click="refreshProfiles()"
/>
</button> </button>
<h3>{{ $t('settings.settings_profile_creation') }}</h3> <h3>{{ $t('settings.settings_profile_creation') }}</h3>
<label for="settings-profile-new-name"> <label for="settings-profile-new-name">
{{ $t('settings.settings_profile_creation_new_name_label') }} {{ $t('settings.settings_profile_creation_new_name_label') }}
</label> </label>
<input <input v-model="newProfileName" id="settings-profile-new-name">
id="settings-profile-new-name"
v-model="newProfileName"
>
<button <button
class="btn button-default" class="btn button-default"
@click="createSettingsProfile" @click="createSettingsProfile"
@ -154,21 +146,6 @@
{{ $t('settings.show_wider_shortcuts') }} {{ $t('settings.show_wider_shortcuts') }}
</BooleanSetting> </BooleanSetting>
</li> </li>
<li>
<BooleanSetting path="displayPageBackgrounds">
{{ $t('settings.show_page_backgrounds') }}
</BooleanSetting>
</li>
<li>
<BooleanSetting path="centerAlignBio">
{{ $t('settings.center_align_bio') }}
</BooleanSetting>
</li>
<li>
<BooleanSetting path="compactUserInfo">
{{ $t('settings.compact_user_info') }}
</BooleanSetting>
</li>
<li> <li>
<BooleanSetting path="stopGifs"> <BooleanSetting path="stopGifs">
{{ $t('settings.stop_gifs') }} {{ $t('settings.stop_gifs') }}
@ -506,6 +483,14 @@
</BooleanSetting> </BooleanSetting>
</li> </li>
</ul> </ul>
<li>
<BooleanSetting
path="useAtIcon"
expert="1"
>
{{ $t('settings.use_at_icon') }}
</BooleanSetting>
</li>
<li> <li>
<BooleanSetting path="mentionLinkShowAvatar"> <BooleanSetting path="mentionLinkShowAvatar">
{{ $t('settings.mention_link_show_avatar') }} {{ $t('settings.mention_link_show_avatar') }}
@ -594,15 +579,6 @@
{{ $t('settings.subject_line_behavior') }} {{ $t('settings.subject_line_behavior') }}
</ChoiceSetting> </ChoiceSetting>
</li> </li>
<li>
<ChoiceSetting
id="postLanguage"
path="postLanguage"
:options="postLanguages"
>
{{ $t('settings.post_status_language') }}
</ChoiceSetting>
</li>
<li v-if="postFormats.length > 0"> <li v-if="postFormats.length > 0">
<ChoiceSetting <ChoiceSetting
id="postContentType" id="postContentType"
@ -612,15 +588,6 @@
{{ $t('settings.post_status_content_type') }} {{ $t('settings.post_status_content_type') }}
</ChoiceSetting> </ChoiceSetting>
</li> </li>
<li>
<ChoiceSetting
id="postLanguage"
path="postLanguage"
:options="postLanguageOptions"
>
{{ $t('settings.post_language') }}
</ChoiceSetting>
</li>
<li> <li>
<BooleanSetting <BooleanSetting
path="alwaysShowNewPostButton" path="alwaysShowNewPostButton"

View file

@ -85,7 +85,7 @@ const MutesAndBlocks = {
// check is it's a local user // check is it's a local user
if (user && user.is_local) { if (user && user.is_local) {
// append the instance address // append the instance address
// eslint-disable-next-line no-undef
return user.screen_name + '@' + location.hostname return user.screen_name + '@' + location.hostname
} }
return user.screen_name return user.screen_name

View file

@ -10,7 +10,7 @@
:query="queryUserIds" :query="queryUserIds"
:placeholder="$t('settings.search_user_to_block')" :placeholder="$t('settings.search_user_to_block')"
> >
<template #default="row"> <template v-slot="row">
<BlockCard <BlockCard
:user-id="row.item" :user-id="row.item"
/> />
@ -21,7 +21,7 @@
:refresh="true" :refresh="true"
:get-key="i => i" :get-key="i => i"
> >
<template #header="{selected}"> <template v-slot:header="{selected}">
<div class="bulk-actions"> <div class="bulk-actions">
<ProgressButton <ProgressButton
v-if="selected.length > 0" v-if="selected.length > 0"
@ -29,7 +29,7 @@
:click="() => blockUsers(selected)" :click="() => blockUsers(selected)"
> >
{{ $t('user_card.block') }} {{ $t('user_card.block') }}
<template #progress> <template v-slot:progress>
{{ $t('user_card.block_progress') }} {{ $t('user_card.block_progress') }}
</template> </template>
</ProgressButton> </ProgressButton>
@ -39,16 +39,16 @@
:click="() => unblockUsers(selected)" :click="() => unblockUsers(selected)"
> >
{{ $t('user_card.unblock') }} {{ $t('user_card.unblock') }}
<template #progress> <template v-slot:progress>
{{ $t('user_card.unblock_progress') }} {{ $t('user_card.unblock_progress') }}
</template> </template>
</ProgressButton> </ProgressButton>
</div> </div>
</template> </template>
<template #item="{item}"> <template v-slot:item="{item}">
<BlockCard :user-id="item" /> <BlockCard :user-id="item" />
</template> </template>
<template #empty> <template v-slot:empty>
{{ $t('settings.no_blocks') }} {{ $t('settings.no_blocks') }}
</template> </template>
</BlockList> </BlockList>
@ -63,7 +63,7 @@
:query="queryUserIds" :query="queryUserIds"
:placeholder="$t('settings.search_user_to_mute')" :placeholder="$t('settings.search_user_to_mute')"
> >
<template #default="row"> <template v-slot="row">
<MuteCard <MuteCard
:user-id="row.item" :user-id="row.item"
/> />
@ -74,7 +74,7 @@
:refresh="true" :refresh="true"
:get-key="i => i" :get-key="i => i"
> >
<template #header="{selected}"> <template v-slot:header="{selected}">
<div class="bulk-actions"> <div class="bulk-actions">
<ProgressButton <ProgressButton
v-if="selected.length > 0" v-if="selected.length > 0"
@ -82,7 +82,7 @@
:click="() => muteUsers(selected)" :click="() => muteUsers(selected)"
> >
{{ $t('user_card.mute') }} {{ $t('user_card.mute') }}
<template #progress> <template v-slot:progress>
{{ $t('user_card.mute_progress') }} {{ $t('user_card.mute_progress') }}
</template> </template>
</ProgressButton> </ProgressButton>
@ -92,16 +92,16 @@
:click="() => unmuteUsers(selected)" :click="() => unmuteUsers(selected)"
> >
{{ $t('user_card.unmute') }} {{ $t('user_card.unmute') }}
<template #progress> <template v-slot:progress>
{{ $t('user_card.unmute_progress') }} {{ $t('user_card.unmute_progress') }}
</template> </template>
</ProgressButton> </ProgressButton>
</div> </div>
</template> </template>
<template #item="{item}"> <template v-slot:item="{item}">
<MuteCard :user-id="item" /> <MuteCard :user-id="item" />
</template> </template>
<template #empty> <template v-slot:empty>
{{ $t('settings.no_mutes') }} {{ $t('settings.no_mutes') }}
</template> </template>
</MuteList> </MuteList>
@ -114,7 +114,7 @@
:query="queryKnownDomains" :query="queryKnownDomains"
:placeholder="$t('settings.type_domains_to_mute')" :placeholder="$t('settings.type_domains_to_mute')"
> >
<template #default="row"> <template v-slot="row">
<DomainMuteCard <DomainMuteCard
:domain="row.item" :domain="row.item"
/> />
@ -125,7 +125,7 @@
:refresh="true" :refresh="true"
:get-key="i => i" :get-key="i => i"
> >
<template #header="{selected}"> <template v-slot:header="{selected}">
<div class="bulk-actions"> <div class="bulk-actions">
<ProgressButton <ProgressButton
v-if="selected.length > 0" v-if="selected.length > 0"
@ -133,16 +133,16 @@
:click="() => unmuteDomains(selected)" :click="() => unmuteDomains(selected)"
> >
{{ $t('domain_mute_card.unmute') }} {{ $t('domain_mute_card.unmute') }}
<template #progress> <template v-slot:progress>
{{ $t('domain_mute_card.unmute_progress') }} {{ $t('domain_mute_card.unmute_progress') }}
</template> </template>
</ProgressButton> </ProgressButton>
</div> </div>
</template> </template>
<template #item="{item}"> <template v-slot:item="{item}">
<DomainMuteCard :domain="item" /> <DomainMuteCard :domain="item" />
</template> </template>
<template #empty> <template v-slot:empty>
{{ $t('settings.no_mutes') }} {{ $t('settings.no_mutes') }}
</template> </template>
</DomainMuteList> </DomainMuteList>

View file

@ -33,7 +33,6 @@ const ProfileTab = {
newName: this.$store.state.users.currentUser.name_unescaped, newName: this.$store.state.users.currentUser.name_unescaped,
newBio: unescape(this.$store.state.users.currentUser.description), newBio: unescape(this.$store.state.users.currentUser.description),
newLocked: this.$store.state.users.currentUser.locked, newLocked: this.$store.state.users.currentUser.locked,
newPermitFollowback: this.$store.state.users.currentUser.permit_followback,
newFields: this.$store.state.users.currentUser.fields.map(field => ({ name: field.name, value: field.value })), newFields: this.$store.state.users.currentUser.fields.map(field => ({ name: field.name, value: field.value })),
showRole: this.$store.state.users.currentUser.show_role, showRole: this.$store.state.users.currentUser.show_role,
role: this.$store.state.users.currentUser.role, role: this.$store.state.users.currentUser.role,
@ -130,15 +129,14 @@ const ProfileTab = {
note: this.newBio, note: this.newBio,
locked: this.newLocked, locked: this.newLocked,
// Backend notation. // Backend notation.
/* eslint-disable camelcase */
display_name: this.newName, display_name: this.newName,
fields_attributes: this.newFields.filter(el => el != null), fields_attributes: this.newFields.filter(el => el != null),
bot: this.bot, bot: this.bot,
show_role: this.showRole, show_role: this.showRole,
status_ttl_days: this.expirePosts ? this.newPostTTLDays : -1, status_ttl_days: this.expirePosts ? this.newPostTTLDays : -1,
permit_followback: this.permit_followback,
accepts_direct_messages_from: this.userAcceptsDirectMessagesFrom accepts_direct_messages_from: this.userAcceptsDirectMessagesFrom
/* eslint-enable camelcase */
} }
if (this.emailLanguage) { if (this.emailLanguage) {
@ -187,7 +185,7 @@ const ProfileTab = {
}) })
return return
} }
// eslint-disable-next-line no-undef
const reader = new FileReader() const reader = new FileReader()
reader.onload = ({ target }) => { reader.onload = ({ target }) => {
const img = target.result const img = target.result

View file

@ -110,9 +110,11 @@
max="730" max="730"
class="expire-posts-days" class="expire-posts-days"
:placeholder="$t('settings.expire_posts_input_placeholder')" :placeholder="$t('settings.expire_posts_input_placeholder')"
> />
</p>
<p>
</p> </p>
<p />
<p> <p>
<interface-language-switcher <interface-language-switcher
:prompt-text="$t('settings.email_language')" :prompt-text="$t('settings.email_language')"
@ -257,19 +259,6 @@
<BooleanSetting path="serverSide_locked"> <BooleanSetting path="serverSide_locked">
{{ $t('settings.lock_account_description') }} {{ $t('settings.lock_account_description') }}
</BooleanSetting> </BooleanSetting>
<ul
class="setting-list suboptions"
:class="[{disabled: !serverSide_locked}]"
>
<li>
<BooleanSetting
path="serverSide_permitFollowback"
:disabled="!serverSide_locked"
>
{{ $t('settings.permit_followback_description') }}
</BooleanSetting>
</li>
</ul>
</li> </li>
<li> <li>
<BooleanSetting path="serverSide_discoverable"> <BooleanSetting path="serverSide_discoverable">

View file

@ -21,11 +21,9 @@
</div> </div>
<div class="panel-body theme-preview-content"> <div class="panel-body theme-preview-content">
<div class="post"> <div class="post">
<div class="Avatar post-avatar"> <div class="avatar still-image">
<div class="still-image avatar -better-shadow">
( ͡° ͜ʖ ͡°) ( ͡° ͜ʖ ͡°)
</div> </div>
</div>
<div class="content"> <div class="content">
<h4> <h4>
{{ $t('settings.style.preview.content') }} {{ $t('settings.style.preview.content') }}

View file

@ -1,25 +1,22 @@
import { extractCommit } from 'src/services/version/version.service' import { extractCommit } from 'src/services/version/version.service'
function joinURL(base, subpath) { const pleromaFeCommitUrl = 'https://akkoma.dev/AkkomaGang/pleroma-fe/commit/'
return URL.parse(subpath, base)?.href || "invalid base URL" const pleromaBeCommitUrl = 'https://akkoma.dev/AkkomaGang/akkoma/commit/'
}
const VersionTab = { const VersionTab = {
data () { data () {
const instance = this.$store.state.instance const instance = this.$store.state.instance
return { return {
backendCommitUrl: instance.backendCommitUrl,
backendVersion: instance.backendVersion, backendVersion: instance.backendVersion,
frontendCommitUrl: instance.frontendCommitUrl,
frontendVersion: instance.frontendVersion frontendVersion: instance.frontendVersion
} }
}, },
computed: { computed: {
frontendVersionLink () { frontendVersionLink () {
return joinURL(this.frontendCommitUrl, this.frontendVersion) return pleromaFeCommitUrl + this.frontendVersion
}, },
backendVersionLink () { backendVersionLink () {
return joinURL(this.backendCommitUrl, extractCommit(this.backendVersion)) return pleromaBeCommitUrl + extractCommit(this.backendVersion)
} }
} }
} }

View file

@ -299,7 +299,7 @@ const Status = {
if (this.statusoid.user.id === this.currentUser.id) return false if (this.statusoid.user.id === this.currentUser.id) return false
const reasonsToMute = this.userIsMuted || const reasonsToMute = this.userIsMuted ||
// Thread is muted // Thread is muted
this.status.thread_muted || status.thread_muted ||
// Wordfiltered // Wordfiltered
this.muteWordHits.length > 0 || this.muteWordHits.length > 0 ||
// bot status // bot status

View file

@ -94,21 +94,20 @@
text-overflow: ellipsis; text-overflow: ellipsis;
--_still_image-label-scale: 0.25; --_still_image-label-scale: 0.25;
--emoji-size: 18px; --emoji-size: 14px;
} }
.status-favicon { .status-favicon {
height: 18px; height: 18px;
width: 18px; width: 18px;
margin-right: 0.4em; margin-right: 0.4em;
vertical-align: middle;
} }
.status-heading { .status-heading {
margin-bottom: 0.5em; margin-bottom: 0.5em;
.emoji { .emoji {
--emoji-size: 18px; --emoji-size: 16px;
} }
} }
@ -118,6 +117,7 @@
line-height: 1.3; line-height: 1.3;
a { a {
display: inline-block;
word-break: break-all; word-break: break-all;
} }
} }
@ -136,6 +136,10 @@
min-width: 0; min-width: 0;
flex-wrap: wrap; flex-wrap: wrap;
img {
aspect-ratio: 1 / 1;
}
.nowrap { .nowrap {
white-space: nowrap; white-space: nowrap;
} }
@ -262,16 +266,6 @@
color: $fallback--cGreen; color: $fallback--cGreen;
color: var(--cGreen, $fallback--cGreen); color: var(--cGreen, $fallback--cGreen);
} }
.right-side {
display: flex;
align-items: center;
gap: 0.3em;
}
.repeat-tooltip {
flex-shrink: 0;
}
} }
.repeater-avatar { .repeater-avatar {
@ -284,6 +278,13 @@
.repeater-name { .repeater-name {
text-overflow: ellipsis; text-overflow: ellipsis;
margin-right: 0; margin-right: 0;
.emoji {
width: 14px;
height: 14px;
vertical-align: middle;
object-fit: contain;
}
} }
.status-fadein { .status-fadein {

View file

@ -83,7 +83,7 @@
:user="statusoid.user" :user="statusoid.user"
/> />
<div class="right-side faint"> <div class="right-side faint">
<div <span
class="status-username repeater-name" class="status-username repeater-name"
:title="retweeter" :title="retweeter"
> >
@ -100,12 +100,8 @@
v-else v-else
:to="retweeterProfileLink" :to="retweeterProfileLink"
>{{ retweeter }}</router-link> >{{ retweeter }}</router-link>
</div> </span>
{{ ' ' }} {{ ' ' }}
<div
class="repeat-tooltip"
>
<FAIcon <FAIcon
icon="retweet" icon="retweet"
class="repeat-icon" class="repeat-icon"
@ -114,7 +110,6 @@
{{ $t('timeline.repeated') }} {{ $t('timeline.repeated') }}
</div> </div>
</div> </div>
</div>
<div <div
v-if="!deleted" v-if="!deleted"
@ -195,7 +190,7 @@
> >
<Timeago <Timeago
:time="status.created_at" :time="status.created_at"
:with-direction="!compact" :with-direction="true"
:auto-update="60" :auto-update="60"
/> />
</router-link> </router-link>
@ -373,7 +368,7 @@
:controlled-toggle-showing-long-subject="controlledToggleShowingLongSubject" :controlled-toggle-showing-long-subject="controlledToggleShowingLongSubject"
@mediaplay="addMediaPlaying($event)" @mediaplay="addMediaPlaying($event)"
@mediapause="removeMediaPlaying($event)" @mediapause="removeMediaPlaying($event)"
@parse-ready="setHeadTailLinks" @parseReady="setHeadTailLinks"
/> />
</div> </div>
@ -481,8 +476,8 @@
/> />
<extra-buttons <extra-buttons
:status="status" :status="status"
@on-error="showError" @onError="showError"
@on-success="clearError" @onSuccess="clearError"
/> />
</div> </div>
</div> </div>

View file

@ -41,8 +41,7 @@ const StatusContent = {
postLength: this.status.text.length, postLength: this.status.text.length,
parseReadyDone: false, parseReadyDone: false,
renderMisskeyMarkdown, renderMisskeyMarkdown,
translateFrom: null, translateFrom: null
translating: false
} }
}, },
computed: { computed: {
@ -136,10 +135,7 @@ const StatusContent = {
}, },
translateStatus () { translateStatus () {
const translateTo = this.$store.getters.mergedConfig.translationLanguage || this.$store.state.instance.interfaceLanguage const translateTo = this.$store.getters.mergedConfig.translationLanguage || this.$store.state.instance.interfaceLanguage
this.translating = true this.$store.dispatch('translateStatus', { id: this.status.id, language: translateTo, from: this.translateFrom })
this.$store.dispatch(
'translateStatus', { id: this.status.id, language: translateTo, from: this.translateFrom }
).finally(() => { this.translating = false })
} }
} }
} }

View file

@ -54,7 +54,7 @@
:mfm="renderMisskeyMarkdown && (status.media_type === 'text/x.misskeymarkdown')" :mfm="renderMisskeyMarkdown && (status.media_type === 'text/x.misskeymarkdown')"
:greentext="mergedConfig.greentext" :greentext="mergedConfig.greentext"
:attentions="status.attentions" :attentions="status.attentions"
@parse-ready="onParseReady" @parseReady="onParseReady"
/> />
<div <div
v-if="status.translation" v-if="status.translation"
@ -70,7 +70,7 @@
:mfm="renderMisskeyMarkdown && (status.media_type === 'text/x.misskeymarkdown')" :mfm="renderMisskeyMarkdown && (status.media_type === 'text/x.misskeymarkdown')"
:greentext="mergedConfig.greentext" :greentext="mergedConfig.greentext"
:attentions="status.attentions" :attentions="status.attentions"
@parse-ready="onParseReady" @parseReady="onParseReady"
/> />
<div> <div>
<label class="label">{{ $t('status.override_translation_source_language') }}</label> <label class="label">{{ $t('status.override_translation_source_language') }}</label>
@ -89,11 +89,7 @@
</option> </option>
</Select> </Select>
{{ ' ' }} {{ ' ' }}
<button <button @click="translateStatus" class="btn button-default">
class="btn button-default"
:disabled="translating"
@click="translateStatus"
>
{{ $t('status.translate') }} {{ $t('status.translate') }}
</button> </button>
</div> </div>

View file

@ -14,7 +14,7 @@
:toggle-showing-tall="toggleShowingTall" :toggle-showing-tall="toggleShowingTall"
:toggle-expanding-subject="toggleExpandingSubject" :toggle-expanding-subject="toggleExpandingSubject"
:toggle-showing-long-subject="toggleShowingLongSubject" :toggle-showing-long-subject="toggleShowingLongSubject"
@parse-ready="$emit('parseReady', $event)" @parseReady="$emit('parseReady', $event)"
> >
<div v-if="status.poll && status.poll.options && !compact"> <div v-if="status.poll && status.poll.options && !compact">
<Poll <Poll

View file

@ -2,7 +2,7 @@
<Modal <Modal
v-if="modalActivated" v-if="modalActivated"
class="status-history-modal-view" class="status-history-modal-view"
@backdrop-clicked="closeModal" @backdropClicked="closeModal"
> >
<div class="status-history-modal-panel panel"> <div class="status-history-modal-panel panel">
<div class="panel-heading"> <div class="panel-heading">
@ -17,7 +17,7 @@
v-for="status in history" v-for="status in history"
:key="status.id" :key="status.id"
:statusoid="status" :statusoid="status"
:is-preview="true" :isPreview="true"
class="conversation-status status-fadein panel-body" class="conversation-status status-fadein panel-body"
/> />
</div> </div>

View file

@ -5,10 +5,10 @@
:bound-to="{ x: 'container' }" :bound-to="{ x: 'container' }"
@show="enter" @show="enter"
> >
<template #trigger> <template v-slot:trigger>
<slot /> <slot />
</template> </template>
<template #content> <template v-slot:content>
<Status <Status
v-if="status" v-if="status"
:is-preview="true" :is-preview="true"

View file

@ -1,6 +1,5 @@
const StillImage = { const StillImage = {
props: [ props: [
'loading',
'src', 'src',
'referrerpolicy', 'referrerpolicy',
'mimetype', 'mimetype',
@ -14,7 +13,6 @@ const StillImage = {
return { return {
stopGifs: this.$store.getters.mergedConfig.stopGifs || window.matchMedia('(prefers-reduced-motion: reduce)').matches, stopGifs: this.$store.getters.mergedConfig.stopGifs || window.matchMedia('(prefers-reduced-motion: reduce)').matches,
isAnimated: false, isAnimated: false,
imageTypeLabel: ''
} }
}, },
computed: { computed: {
@ -41,24 +39,27 @@ const StillImage = {
this.imageLoadError && this.imageLoadError() this.imageLoadError && this.imageLoadError()
}, },
detectAnimation (image) { detectAnimation (image) {
// If there are no file extensions, the mimetype isn't set, and no mediaproxy is available, we can't figure out
// the mimetype of the image.
const hasFileExtension = this.src.split('/').pop().includes('.') // TODO: Better check?
const mediaProxyAvailable = this.$store.state.instance.mediaProxyAvailable const mediaProxyAvailable = this.$store.state.instance.mediaProxyAvailable
if (!hasFileExtension && this.mimetype === undefined && !mediaProxyAvailable) {
if (!mediaProxyAvailable) {
// It's a bit aggressive to assume all images we can't find the mimetype of is animated, but necessary for // It's a bit aggressive to assume all images we can't find the mimetype of is animated, but necessary for
// people in need of reduced motion accessibility. As such, we'll consider those images animated if the user // people in need of reduced motion accessibility. As such, we'll consider those images animated if the user
// agent is set to prefer reduced motion. Otherwise, it'll just be used as an early exit. // agent is set to prefer reduced motion. Otherwise, it'll just be used as an early exit.
if (window.matchMedia('(prefers-reduced-motion: reduce)').matches) { if (window.matchMedia('(prefers-reduced-motion: reduce)').matches)
// Since the canvas and images are not pixel-perfect matching (due to scaling),
// It makes the images jiggle on hover, which is not ideal for accessibility, methinks
this.isAnimated = true this.isAnimated = true
return return
} }
this.detectWithoutMediaProxy(image)
} else { if (this.mimetype === 'image/gif' || this.src.endsWith('.gif')) {
this.detectWithMediaProxy(image) this.isAnimated = true
return
} }
}, // harmless CORS errors without-- clean console with
detectAnimationWithFetch (image) { if (!mediaProxyAvailable) return
// Animated JPEGs?
if (!(this.src.endsWith('.webp') || this.src.endsWith('.png'))) return
// Browser Cache should ensure image doesn't get loaded twice if cache exists // Browser Cache should ensure image doesn't get loaded twice if cache exists
fetch(image.src, { fetch(image.src, {
referrerPolicy: 'same-origin' referrerPolicy: 'same-origin'
@ -67,20 +68,12 @@ const StillImage = {
// We don't need to read the whole file so only call it once // We don't need to read the whole file so only call it once
data.body.getReader().read() data.body.getReader().read()
.then(reader => { .then(reader => {
// Ordered from least to most intensive if (this.src.endsWith('.webp') && this.isAnimatedWEBP(reader.value)) {
if (this.isGIF(reader.value)) {
this.isAnimated = true this.isAnimated = true
this.setLabel('GIF')
return return
} }
if (this.isAnimatedWEBP(reader.value)) { if (this.src.endsWith('.png') && this.isAnimatedPNG(reader.value)) {
this.isAnimated = true this.isAnimated = true
this.setLabel('WEBP')
return
}
if (this.isAnimatedPNG(reader.value)) {
this.isAnimated = true
this.setLabel('APNG')
} }
}) })
}) })
@ -88,53 +81,6 @@ const StillImage = {
// this.imageLoadError && this.imageLoadError() // this.imageLoadError && this.imageLoadError()
}) })
}, },
detectWithMediaProxy (image) {
this.detectAnimationWithFetch(image)
},
detectWithoutMediaProxy (image) {
// We'll just assume that gifs and webp are animated
const extension = image.src.split('.').pop().toLowerCase()
if (extension === 'gif') {
this.isAnimated = true
this.setLabel('GIF')
return
}
if (extension === 'webp') {
this.isAnimated = true
this.setLabel('WEBP')
return
}
// Beware the apng! use this if ye dare
// if (extension === 'png') {
// this.isAnimated = true
// this.setLabel('PNG')
// return
// }
// Hail mary for extensionless
if (extension.includes('/')) {
// Don't mind the CORS error barrage
this.detectAnimationWithFetch(image)
}
},
setLabel (name) {
this.imageTypeLabel = name;
},
isGIF (data) {
// I am a perfectly sane individual
//
// GIF HEADER CHUNK
// === START HEADER ===
// 47 49 46 38 ("GIF8")
const gifHeader = [0x47, 0x49, 0x46];
for (let i = 0; i < 3; i++) {
if (data[i] !== gifHeader[i]) {
return false;
}
}
return true
},
isAnimatedWEBP (data) { isAnimatedWEBP (data) {
/** /**
* WEBP HEADER CHUNK * WEBP HEADER CHUNK
@ -169,53 +115,14 @@ const StillImage = {
return (str.substring(0, idatPos > 0 ? idatPos : 0).indexOf('acTL') > 0) return (str.substring(0, idatPos > 0 ? idatPos : 0).indexOf('acTL') > 0)
}, },
drawThumbnail () { drawThumbnail () {
const canvas = this.$refs.canvas; const canvas = this.$refs.canvas
if (!canvas) return; if (!this.$refs.canvas) return
const image = this.$refs.src
const context = canvas.getContext('2d'); const width = image.naturalWidth
const image = this.$refs.src; const height = image.naturalHeight
const parentElement = canvas.parentElement; canvas.width = width
canvas.height = height
// Draw the quick, unscaled version first canvas.getContext('2d').drawImage(image, 0, 0, width, height)
context.drawImage(image, 0, 0, parentElement.clientWidth, parentElement.clientHeight);
// Use requestAnimationFrame to schedule the scaling to the next frame
requestAnimationFrame(() => {
// Compute scaling ratio between the natural dimensions of the image and its display dimensions
const scalingRatioWidth = parentElement.clientWidth / image.naturalWidth;
const scalingRatioHeight = parentElement.clientHeight / image.naturalHeight;
// Adjust for high-DPI displays
const ratio = window.devicePixelRatio || 1;
canvas.width = image.naturalWidth * scalingRatioWidth * ratio;
canvas.height = image.naturalHeight * scalingRatioHeight * ratio;
canvas.style.width = `${parentElement.clientWidth}px`;
canvas.style.height = `${parentElement.clientHeight}px`;
context.scale(ratio, ratio);
// Maintain the aspect ratio of the image
const imgAspectRatio = image.naturalWidth / image.naturalHeight;
const canvasAspectRatio = parentElement.clientWidth / parentElement.clientHeight;
let drawWidth, drawHeight;
if (imgAspectRatio > canvasAspectRatio) {
drawWidth = parentElement.clientWidth;
drawHeight = parentElement.clientWidth / imgAspectRatio;
} else {
drawHeight = parentElement.clientHeight;
drawWidth = parentElement.clientHeight * imgAspectRatio;
}
context.clearRect(0, 0, canvas.width, canvas.height); // Clear the previous unscaled image
context.imageSmoothingEnabled = true;
context.imageSmoothingQuality = 'high';
// Draw the good one for realsies
const dx = (parentElement.clientWidth - drawWidth) / 2;
const dy = (parentElement.clientHeight - drawHeight) / 2;
context.drawImage(image, dx, dy, drawWidth, drawHeight);
});
} }
}, },
updated () { updated () {

View file

@ -1,16 +1,9 @@
<template> <template>
<div <div
ref="still-image"
class="still-image" class="still-image"
:class="{ animated: animated }" :class="{ animated: animated }"
:style="style" :style="style"
> >
<div
v-if="animated && imageTypeLabel"
class="image-type-label"
>
{{ imageTypeLabel }}
</div>
<canvas <canvas
v-if="animated" v-if="animated"
ref="canvas" ref="canvas"
@ -19,7 +12,6 @@
<img <img
ref="src" ref="src"
:key="src" :key="src"
:loading="loading"
:alt="alt" :alt="alt"
:title="alt" :title="alt"
:src="src" :src="src"
@ -56,8 +48,8 @@
} }
img { img {
width: 100%;
height: 100%; height: 100%;
min-width: 100%;
object-fit: contain; object-fit: contain;
&::before { &::before {
@ -65,26 +57,30 @@
} }
} }
.image-type-label { &.animated {
&::before {
zoom: var(--_still_image-label-scale, 1);
content: 'gif';
position: absolute; position: absolute;
top: 0.25em;
left: 0.25em;
line-height: 1; line-height: 1;
font-size: 0.6em; font-size: 0.7em;
top: 0.5em;
left: 0.5em;
background: rgba(127, 127, 127, 0.5); background: rgba(127, 127, 127, 0.5);
color: #fff; color: #fff;
display: block;
padding: 2px 4px; padding: 2px 4px;
border-radius: $fallback--tooltipRadius;
border-radius: var(--tooltipRadius, $fallback--tooltipRadius); border-radius: var(--tooltipRadius, $fallback--tooltipRadius);
z-index: 2; z-index: 2;
visibility: var(--_still-image-label-visibility, visible); visibility: var(--_still-image-label-visibility, visible);
} }
&.animated {
&:hover canvas { &:hover canvas {
display: none; display: none;
} }
&:hover .image-type-label { &:hover::before {
visibility: var(--_still-image-label-visibility, hidden); visibility: var(--_still-image-label-visibility, hidden);
} }

View file

@ -32,7 +32,7 @@
:dive="dive ? () => dive(status.id) : undefined" :dive="dive ? () => dive(status.id) : undefined"
@goto="setHighlight" @goto="setHighlight"
@toggle-expanded="toggleExpanded" @toggleExpanded="toggleExpanded"
/> />
<div <div
v-if="currentReplies.length && threadShowing" v-if="currentReplies.length && threadShowing"

View file

@ -28,7 +28,4 @@
} }
} }
} }
.timeline {
min-height: 1em;
}
} }

View file

@ -4,7 +4,7 @@
class="TimelineQuickSettings" class="TimelineQuickSettings"
:bound-to="{ x: 'container' }" :bound-to="{ x: 'container' }"
> >
<template #content> <template v-slot:content>
<div class="dropdown-menu"> <div class="dropdown-menu">
<div v-if="loggedIn"> <div v-if="loggedIn">
<button <button
@ -80,7 +80,7 @@
</button> </button>
</div> </div>
</template> </template>
<template #trigger> <template v-slot:trigger>
<button class="button-unstyled"> <button class="button-unstyled">
<FAIcon icon="filter" /> <FAIcon icon="filter" />
</button> </button>

View file

@ -9,14 +9,14 @@
@show="openMenu" @show="openMenu"
@close="() => isOpen = false" @close="() => isOpen = false"
> >
<template #content> <template v-slot:content>
<div class="timeline-menu-popover popover-default"> <div class="timeline-menu-popover popover-default">
<TimelineMenuContent /> <TimelineMenuContent />
</div> </div>
</template> </template>
<template #trigger> <template v-slot:trigger>
<button class="button-unstyled title timeline-menu-title"> <button class="button-unstyled title timeline-menu-title">
<span class="timeline-menu-name">{{ timelineName() }}</span> <span class="timeline-title">{{ timelineName() }}</span>
<span> <span>
<FAIcon <FAIcon
size="sm" size="sm"
@ -42,7 +42,6 @@
margin-right: auto; margin-right: auto;
min-width: 0; min-width: 0;
width: 24rem; width: 24rem;
max-width: 100%;
.popover-trigger-button { .popover-trigger-button {
vertical-align: bottom; vertical-align: bottom;

View file

@ -62,6 +62,7 @@
:title="$t('nav.twkn_timeline_description')" :title="$t('nav.twkn_timeline_description')"
:aria-label="$t('nav.twkn_timeline_description')" :aria-label="$t('nav.twkn_timeline_description')"
>{{ $t("nav.twkn") }}</span> >{{ $t("nav.twkn") }}</span>
</router-link> </router-link>
</li> </li>
<li v-if="currentUser"> <li v-if="currentUser">

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