Page MenuHomePhorge

No OneTemporary

Size
1005 KB
Referenced Files
None
Subscribers
None
This file is larger than 256 KB, so syntax highlighting was skipped.
diff --git a/.woodpecker/build.yaml b/.woodpecker/build-package.yaml
similarity index 77%
copy from .woodpecker/build.yaml
copy to .woodpecker/build-package.yaml
index af0bb98e34..d06a267cd6 100644
--- a/.woodpecker/build.yaml
+++ b/.woodpecker/build-package.yaml
@@ -1,43 +1,36 @@
when:
- - event: pull_request
- evaluate: 'CI_COMMIT_SOURCE_BRANCH != "weblate" && not(CI_COMMIT_SOURCE_BRANCH startsWith "renovate/")'
- event: push
branch: ${CI_REPO_DEFAULT_BRANCH}
- event: manual
branch: ${CI_REPO_DEFAULT_BRANCH}
depends_on:
- test
- test-e2e
labels:
platform: linux/amd64
memory: 'high'
steps:
build:
image: docker.io/node:20-alpine
commands:
- apk add --no-cache zip git
- yarn --frozen-lockfile
- yarn build
- if [ "${CI_PIPELINE_EVENT}" = "push" ] || [ "${CI_PIPELINE_EVENT}" = "manual" ]; then zip -9qr ${CI_REPO_DEFAULT_BRANCH}.zip dist/; fi
upload-artifacts:
image: docker.io/woodpeckercommunity/plugin-gitea-package:0.5.0
- when:
- - event: push
- branch: ${CI_REPO_DEFAULT_BRANCH}
- - event: manual
- branch: ${CI_REPO_DEFAULT_BRANCH}
settings:
user:
from_secret: pleroma-ci-user
password:
from_secret: pleroma-ci-password
update: true
owner: 'pleroma'
package_name: 'pleroma-fe-builds'
package_version: ${CI_REPO_DEFAULT_BRANCH}
file_source: ./${CI_REPO_DEFAULT_BRANCH}.zip
file_name: latest.zip
diff --git a/.woodpecker/build.yaml b/.woodpecker/build.yaml
index af0bb98e34..d6bd36f3eb 100644
--- a/.woodpecker/build.yaml
+++ b/.woodpecker/build.yaml
@@ -1,43 +1,17 @@
when:
- event: pull_request
evaluate: 'CI_COMMIT_SOURCE_BRANCH != "weblate" && not(CI_COMMIT_SOURCE_BRANCH startsWith "renovate/")'
- event: push
branch: ${CI_REPO_DEFAULT_BRANCH}
- event: manual
branch: ${CI_REPO_DEFAULT_BRANCH}
-depends_on:
- - test
- - test-e2e
-
labels:
platform: linux/amd64
- memory: 'high'
steps:
build:
image: docker.io/node:20-alpine
commands:
- - apk add --no-cache zip git
- yarn --frozen-lockfile
- yarn build
- - if [ "${CI_PIPELINE_EVENT}" = "push" ] || [ "${CI_PIPELINE_EVENT}" = "manual" ]; then zip -9qr ${CI_REPO_DEFAULT_BRANCH}.zip dist/; fi
-
- upload-artifacts:
- image: docker.io/woodpeckercommunity/plugin-gitea-package:0.5.0
- when:
- - event: push
- branch: ${CI_REPO_DEFAULT_BRANCH}
- - event: manual
- branch: ${CI_REPO_DEFAULT_BRANCH}
- settings:
- user:
- from_secret: pleroma-ci-user
- password:
- from_secret: pleroma-ci-password
- update: true
- owner: 'pleroma'
- package_name: 'pleroma-fe-builds'
- package_version: ${CI_REPO_DEFAULT_BRANCH}
- file_source: ./${CI_REPO_DEFAULT_BRANCH}.zip
- file_name: latest.zip
diff --git a/.woodpecker/code-analisys.yaml b/.woodpecker/code-analisys.yaml
new file mode 100644
index 0000000000..11ee853ee6
--- /dev/null
+++ b/.woodpecker/code-analisys.yaml
@@ -0,0 +1,45 @@
+when:
+ - event: push
+ branch: ${CI_REPO_DEFAULT_BRANCH}
+ - event: manual
+ branch: ${CI_REPO_DEFAULT_BRANCH}
+
+labels:
+ platform: linux/amd64
+ memory: 'high'
+
+depends_on:
+ - build
+ - test
+ - test-e2e
+
+clone:
+ - name: git
+ image: woodpeckerci/plugin-git
+ settings:
+ depth: 0
+ partial: false
+
+variables:
+ script_file_entrypoint: &script_file_entrypoint
+ - /bin/sh
+ - -c
+ - 'printf "%s" "$CI_SCRIPT" | base64 -d > /tmp/ci-script.sh && /bin/sh -xe /tmp/ci-script.sh'
+
+steps:
+ generate-coverage:
+ image: mcr.microsoft.com/playwright:v1.61.0-jammy
+ environment:
+ FF_NETWORK_PER_BUILD: "true"
+ PLAYWRIGHT_SKIP_BROWSER_DOWNLOAD: "1"
+ entrypoint: *script_file_entrypoint
+ commands:
+ - yarn --frozen-lockfile
+ - yarn unit-ci-coverage
+
+ code-analysis:
+ image: sonarsource/sonar-scanner-cli:11
+ environment:
+ SONAR_TOKEN:
+ from_secret: sonarqube-token
+ entrypoint: sonar-scanner
diff --git a/.woodpecker/test-e2e.yaml b/.woodpecker/test-e2e.yaml
index 2a7d1511dc..8f7e407f87 100644
--- a/.woodpecker/test-e2e.yaml
+++ b/.woodpecker/test-e2e.yaml
@@ -1,101 +1,105 @@
when:
- event: pull_request
evaluate: 'CI_COMMIT_SOURCE_BRANCH != "weblate" && not(CI_COMMIT_SOURCE_BRANCH startsWith "renovate/")'
- event: push
branch: ${CI_REPO_DEFAULT_BRANCH}
- event: manual
branch: ${CI_REPO_DEFAULT_BRANCH}
labels:
platform: linux/amd64
memory: 'high'
+depends_on:
+ - build
+ - test
+
variables:
artifacts_uploader_settings: &artifacts_uploader_settings
user:
from_secret: pleroma-ci-user
password:
from_secret: pleroma-ci-password
owner: 'pleroma'
package_name: 'pleroma-fe-test-artifacts'
script_file_entrypoint: &script_file_entrypoint
- /bin/sh
- -c
- 'printf "%s" "$CI_SCRIPT" | base64 -d > /tmp/ci-script.sh && /bin/sh -xe /tmp/ci-script.sh'
steps:
test:
image: mcr.microsoft.com/playwright:v1.61.0-jammy
entrypoint: *script_file_entrypoint
environment:
APT_CACHE_DIR: apt-cache
DEBIAN_FRONTEND: noninteractive
E2E_BASE_URL: http://localhost:8099
FF_NETWORK_PER_BUILD: "true"
PLAYWRIGHT_SKIP_BROWSER_DOWNLOAD: "1"
VITE_PROXY_ORIGIN: "http://pleroma:4000"
VITE_PROXY_TARGET: "http://pleroma:4000"
commands:
- |
if [ "${CI_PIPELINE_EVENT}" != "pull_request" ]; then
mkdir -pv $APT_CACHE_DIR && apt-get -qq update
apt-get install -y zip
fi
- npm install -g yarn@1.22.22
- yarn --frozen-lockfile
- |
echo "-- Waiting for Pleroma API..."
api_ok="false"
for _i in $(seq 1 120); do
if wget -qO- http://pleroma:4000/api/v1/instance >/dev/null 2>&1; then
api_ok="true"
break
fi
sleep 1s
done
if [ "$api_ok" != "true" ]; then
echo "Timed out waiting for Pleroma API to become available"
exit 1
fi
- |
if ! yarn e2e:pw; then
[ "${CI_PIPELINE_EVENT}" = "pull_request" ] || zip -9qr ${CI_COMMIT_SHA:0:8}-e2e.zip ./test/e2e-playwright/test-results ./test/e2e-playwright/playwright-report
exit 1
fi
upload-artifacts:
image: docker.io/woodpeckercommunity/plugin-gitea-package:0.5.0
when:
- event: push
branch: ${CI_REPO_DEFAULT_BRANCH}
status: [failure]
- event: manual
branch: ${CI_REPO_DEFAULT_BRANCH}
status: [failure]
settings:
<<: *artifacts_uploader_settings
package_version: ${CI_REPO_DEFAULT_BRANCH}-${CI_COMMIT_SHA:0:8}
file_source: ./${CI_COMMIT_SHA:0:8}-e2e.zip
file_name: ${CI_COMMIT_SHA:0:8}-e2e.zip
services:
postgres:
image: docker.io/postgres:13-alpine
environment:
POSTGRES_DB: pleroma_test
POSTGRES_USER: postgres
POSTGRES_PASSWORD: postgres
pleroma:
image: git.pleroma.social/pleroma/pleroma:stable-e2e
environment:
ADMIN_EMAIL: "admin@example.com"
NOTIFY_EMAIL: "admin@example.com"
DB_USER: postgres
DB_PASS: postgres
DB_NAME: pleroma_test
DB_HOST: postgres
INSTANCE_NAME: Pleroma E2E
E2E_ADMIN_USERNAME: admin
E2E_ADMIN_PASSWORD: adminadmin
E2E_ADMIN_EMAIL: "admin@example.com"
diff --git a/.woodpecker/test.yaml b/.woodpecker/test.yaml
index 9c3c24a533..c3f2c37938 100644
--- a/.woodpecker/test.yaml
+++ b/.woodpecker/test.yaml
@@ -1,75 +1,64 @@
when:
- event: pull_request
evaluate: 'CI_COMMIT_SOURCE_BRANCH != "weblate" && not(CI_COMMIT_SOURCE_BRANCH startsWith "renovate/")'
- event: push
branch: ${CI_REPO_DEFAULT_BRANCH}
- event: manual
branch: ${CI_REPO_DEFAULT_BRANCH}
labels:
platform: linux/amd64
memory: 'high'
+depends_on:
+ - build
+
variables:
artifacts_uploader_settings: &artifacts_uploader_settings
user:
from_secret: pleroma-ci-user
password:
from_secret: pleroma-ci-password
owner: 'pleroma'
package_name: 'pleroma-fe-test-artifacts'
script_file_entrypoint: &script_file_entrypoint
- /bin/sh
- -c
- 'printf "%s" "$CI_SCRIPT" | base64 -d > /tmp/ci-script.sh && /bin/sh -xe /tmp/ci-script.sh'
steps:
test:
image: mcr.microsoft.com/playwright:v1.61.0-jammy
environment:
APT_CACHE_DIR: apt-cache
DEBIAN_FRONTEND: noninteractive
FF_NETWORK_PER_BUILD: "true"
PLAYWRIGHT_SKIP_BROWSER_DOWNLOAD: "1"
entrypoint: *script_file_entrypoint
commands:
- |
if [ "${CI_PIPELINE_EVENT}" != "pull_request" ]; then
mkdir -pv $APT_CACHE_DIR && apt-get -qq update
apt-get -y install zip
fi
- yarn --frozen-lockfile
- |
if ! yarn unit-ci; then
[ "${CI_PIPELINE_EVENT}" = "pull_request" ] || zip -9qr ${CI_COMMIT_SHA:0:8}-screenshots.zip $(find . -type d -name __screenshots__)
exit 1
fi
- code-analysis:
- image: sonarsource/sonar-scanner-cli:11
- when:
- - event: push
- branch: ${CI_REPO_DEFAULT_BRANCH}
- - event: manual
- branch: ${CI_REPO_DEFAULT_BRANCH}
- environment:
- SONAR_TOKEN:
- from_secret: sonarqube-token
- entrypoint: *script_file_entrypoint
- commands:
- - sonar-scanner
-
upload-artifacts:
image: docker.io/woodpeckercommunity/plugin-gitea-package:0.5.0
when:
- event: push
branch: ${CI_REPO_DEFAULT_BRANCH}
status: [failure]
- event: manual
branch: ${CI_REPO_DEFAULT_BRANCH}
status: [failure]
settings:
<<: *artifacts_uploader_settings
package_version: ${CI_REPO_DEFAULT_BRANCH}-${CI_COMMIT_SHA:0:8}
file_source: ./${CI_COMMIT_SHA:0:8}-screenshots.zip
file_name: ${CI_COMMIT_SHA:0:8}-screenshots.zip
diff --git a/README.md b/README.md
index 16d32dcd22..a1dabba359 100644
--- a/README.md
+++ b/README.md
@@ -1,44 +1,51 @@
-# Pleroma-FE
+# Pleroma-FE
> Highly-customizable frontend designed for Pleroma.
![screenshot](./image-1.png)
+[![Pipeline Status](https://ci.pleroma.com/api/badges/2/status.svg)](https://ci.pleroma.com/repos/2) [![Coverage](https://sonarqube.pleroma.dev/api/project_badges/measure?project=Pleroma-FE&metric=coverage&token=sqb_f8a5ec836caede0e2c6c62a9bfe66e62a89e2b01)](https://sonarqube.pleroma.dev/dashboard?id=Pleroma-FE) [![Maintainability Rating](https://sonarqube.pleroma.dev/api/project_badges/measure?project=Pleroma-FE&metric=software_quality_maintainability_rating&token=sqb_f8a5ec836caede0e2c6c62a9bfe66e62a89e2b01)](https://sonarqube.pleroma.dev/dashboard?id=Pleroma-FE) [![Reliability Rating](https://sonarqube.pleroma.dev/api/project_badges/measure?project=Pleroma-FE&metric=software_quality_reliability_rating&token=sqb_f8a5ec836caede0e2c6c62a9bfe66e62a89e2b01)](https://sonarqube.pleroma.dev/dashboard?id=Pleroma-FE) [![Security Rating](https://sonarqube.pleroma.dev/api/project_badges/measure?project=Pleroma-FE&metric=software_quality_security_rating&token=sqb_f8a5ec836caede0e2c6c62a9bfe66e62a89e2b01)](https://sonarqube.pleroma.dev/dashboard?id=Pleroma-FE) <a href="https://translate.pleroma.social/engage/pleroma/"><img src="https://translate.pleroma.social/widget/pleroma/pleroma-fe/svg-badge.svg" alt="Translation status"></a>
+
# For Translators
-To translate Pleroma-FE, use our weblate server: https://translate.pleroma.social/. If you need to add your language it should be added as a json file in [src/i18n/](https://git.pleroma.social/pleroma/pleroma-fe/src/src/i18n/) folder and added in a list within [src/i18n/languages.js](https://git.pleroma.social/pleroma/pleroma-fe/src/src/i18n/languages.js).
+To translate Pleroma-FE, use our weblate server: https://translate.pleroma.social/. If you need to add your language it should be added as a json file in [src/i18n/](https://git.pleroma.social/pleroma/pleroma-fe/src/src/i18n/) folder and added in a list within [src/i18n/languages.js](https://git.pleroma.social/pleroma/pleroma-fe/src/src/i18n/languages.js).
Pleroma-FE will set your language by your browser locale, but you can change language in settings.
# For instance admins
You don't need to build Pleroma-FE yourself. Those using the Pleroma backend will be able to use it out of the box. Information of customizing PleromaFE settings/defaults is in our [guide](https://docs-develop.pleroma.social/frontend/CONFIGURATION/) and in case you want to build your own custom version there's [another](https://docs-develop.pleroma.social/frontend/HACKING/)
# Build Setup
``` bash
# install dependencies
npm install -g yarn
yarn
# serve with hot reload at localhost:8080
yarn dev
# build for production with minification
yarn build
# run unit tests
yarn unit
```
# For Contributors:
+You can make local build proxy requests to specific instance with an environment variable `VITE_PROXY_TARGET`, generally it is a good idea to also set port for that specific proxy, if you intend on testing multiple instances, i.e.:
+
+```bash
+yarn && PORT=8080 VITE_PROXY_TARGET=https://coolinstance.tld yarn dev
+```
+
You can create file `/config/local.json` (see [example](https://git.pleroma.social/pleroma/pleroma-fe/src/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.
* `staticConfigPreference`: makes FE's `/static/config.json` take preference of BE-served `/api/pleroma/frontend_configurations`. 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.
# Configuration
Set configuration settings in AdminFE, additionally you can edit config.json. For more details see [documentation](https://docs-develop.pleroma.social/frontend/CONFIGURATION/).
diff --git a/build/commit_hash.js b/build/commit_hash.js
index 8225817ee0..2b13afb93a 100644
--- a/build/commit_hash.js
+++ b/build/commit_hash.js
@@ -1,18 +1,20 @@
import childProcess from 'node:child_process'
export const getCommitHash = () => {
const subst = '$Format:%h$'
if (!/Format:/.exec(subst)) {
return subst
} else {
try {
return childProcess
- .execSync('git rev-parse --short HEAD')
+ .execSync(
+ 'PATH=/usr/bin:/bin:/usr/local/bin:/sbin:/usr/sbin git rev-parse --short HEAD',
+ )
.toString()
.trim()
} catch (e) {
console.error('Failed run git:', e)
return 'UNKNOWN'
}
}
}
diff --git a/build/emojis_plugin.js b/build/emojis_plugin.js
index 9872f53317..b21c100480 100644
--- a/build/emojis_plugin.js
+++ b/build/emojis_plugin.js
@@ -1,73 +1,73 @@
import { access } from 'node:fs/promises'
import { resolve } from 'node:path'
import { languages } from '../src/i18n/languages.js'
const annotationsImportPrefix = '@kazvmoe-infra/unicode-emoji-json/annotations/'
const specialAnnotationsLocale = {
ja_easy: 'ja',
}
const internalToAnnotationsLocale = (internal) =>
specialAnnotationsLocale[internal] || internal
// This gets all the annotations that are accessible (whose language
// can be chosen in the settings). Data for other languages are
// discarded because there is no way for it to be fetched.
const getAllAccessibleAnnotations = async (projectRoot) => {
const imports = (
await Promise.all(
languages.map(async (lang) => {
const destLang = internalToAnnotationsLocale(lang)
const importModule = `${annotationsImportPrefix}${destLang}.json`
const importFile = resolve(projectRoot, 'node_modules', importModule)
try {
await access(importFile)
return `'${lang}': () => import('${importModule}')`
} catch (e) {
if (e.message.match(/ENOENT/)) {
console.warn(`Missing emoji annotations locale: ${destLang}`)
} else {
console.error('test', e.message)
}
return
}
}),
)
)
- .filter((k) => k)
+ .filter(Boolean)
.join(',\n')
return `
export const annotationsLoader = {
${imports}
}
`
}
const emojiAnnotationsId = 'virtual:pleroma-fe/emoji-annotations'
const emojiAnnotationsIdResolved = '\0' + emojiAnnotationsId
const emojisPlugin = () => {
let projectRoot
return {
name: 'emojis-plugin',
configResolved(conf) {
projectRoot = conf.root
},
resolveId(id) {
if (id === emojiAnnotationsId) {
return emojiAnnotationsIdResolved
}
return null
},
async load(id) {
if (id === emojiAnnotationsIdResolved) {
return await getAllAccessibleAnnotations(projectRoot)
}
return null
},
}
}
export default emojisPlugin
diff --git a/build/update-emoji.js b/build/update-emoji.js
index dd965cf662..b2631ec6e2 100644
--- a/build/update-emoji.js
+++ b/build/update-emoji.js
@@ -1,26 +1,26 @@
import fs from 'node:fs'
import emojis from '@kazvmoe-infra/unicode-emoji-json/data-by-group.json' with {
type: 'json',
}
-Object.keys(emojis).map((k) => {
+Object.keys(emojis).forEach((k) => {
emojis[k].forEach((e) => {
delete e.unicode_version
delete e.emoji_version
delete e.skin_tone_support_unicode_version
})
})
const res = {}
Object.keys(emojis).forEach((k) => {
const groupId = k.replace('&', 'and').replaceAll(' ', '-').toLowerCase()
res[groupId] = emojis[k]
})
console.info('Updating emojis...')
try {
fs.writeFileSync('src/assets/emoji.json', JSON.stringify(res))
console.info('Done.')
} catch (e) {
console.error('Failed updating emoji', e)
}
diff --git a/changelog.d/server-mute-render-modify.fix b/changelog.d/server-mute-render-modify.fix
new file mode 100644
index 0000000000..cb94bb8261
--- /dev/null
+++ b/changelog.d/server-mute-render-modify.fix
@@ -0,0 +1 @@
+Fixed server-side domain mutes rendering/api calls
diff --git a/changelog.d/theme2-font-cache.fix b/changelog.d/theme2-font-cache.fix
new file mode 100644
index 0000000000..d8782b4676
--- /dev/null
+++ b/changelog.d/theme2-font-cache.fix
@@ -0,0 +1 @@
+Fix Theme 2 fonts falling back to serif after upgrade
diff --git a/changelog.d/timeline_rendering_status_index_approx.fix b/changelog.d/timeline_rendering_status_index_approx.fix
new file mode 100644
index 0000000000..717464c580
--- /dev/null
+++ b/changelog.d/timeline_rendering_status_index_approx.fix
@@ -0,0 +1 @@
+Fixed status index approxmiation in timeline rendering for dynamically changing viewport geometries
diff --git a/package.json b/package.json
index f8bacaf154..50138e6a2d 100644
--- a/package.json
+++ b/package.json
@@ -1,131 +1,132 @@
{
"name": "pleroma_fe",
"version": "2.11.1",
"description": "Pleroma frontend, the default frontend of Pleroma social network server",
"author": "Pleroma contributors <https://git.pleroma.social/pleroma/pleroma-fe/src/CONTRIBUTORS.md>",
"private": false,
"scripts": {
"dev": "node build/update-emoji.js && vite dev",
"build": "node build/update-emoji.js && vite build",
"unit": "node build/update-emoji.js && vitest --run --coverage",
- "unit-ci": "node build/update-emoji.js && vitest --run --coverage --browser.headless",
+ "unit-ci": "node build/update-emoji.js && vitest --run --browser.headless",
+ "unit-ci-coverage": "node build/update-emoji.js && vitest --run --coverage --browser.headless",
"unit:watch": "node build/update-emoji.js && vitest --coverage",
"e2e:pw": "playwright test --config test/e2e-playwright/playwright.config.mjs",
"e2e": "sh ./tools/e2e/run.sh",
"test": "yarn run unit && yarn run e2e",
"ci-biome": "yarn exec biome check",
"ci-eslint": "yarn exec eslint",
"ci-stylelint": "yarn exec stylelint '**/*.scss' '**/*.vue'",
"lint": "yarn ci-biome; yarn ci-eslint; yarn ci-stylelint",
"lint-fix": "yarn exec eslint -- --fix; yarn exec stylelint '**/*.scss' '**/*.vue' --fix; biome check --write"
},
"dependencies": {
"@babel/runtime": "7.28.4",
"@chenfengyuan/vue-qrcode": "2.0.0",
"@fortawesome/fontawesome-svg-core": "7.1.0",
"@fortawesome/free-regular-svg-icons": "7.1.0",
"@fortawesome/free-solid-svg-icons": "7.1.0",
"@fortawesome/vue-fontawesome": "3.1.2",
"@kazvmoe-infra/pinch-zoom-element": "1.3.0",
"@kazvmoe-infra/unicode-emoji-json": "0.4.0",
"@ruffle-rs/ruffle": "0.1.0-nightly.2025.6.22",
"@vuelidate/core": "2.0.3",
"@vuelidate/validators": "2.0.4",
"@web3-storage/parse-link-header": "^3.1.0",
"body-scroll-lock": "3.1.5",
"chromatism": "3.0.0",
"click-outside-vue3": "4.0.1",
"cropperjs": "2.0.1",
"escape-html": "1.0.3",
"globals": "^16.0.0",
"hash-sum": "^2.0.0",
"js-cookie": "3.0.5",
"localforage": "1.10.0",
"parse-link-header": "2.0.0",
"phoenix": "1.8.1",
"pinia": "^3.0.4",
"punycode.js": "2.3.1",
"qrcode": "1.5.4",
"querystring-es3": "0.2.1",
"url": "0.11.4",
"utf8": "3.0.0",
"uuid": "11.1.0",
"vue": "3.5.22",
"vue-i18n": "11",
"vue-router": "4.6.4",
"vue-virtual-scroller": "^2.0.0-beta.7",
"vuex": "4.1.0"
},
"devDependencies": {
"@babel/core": "7.28.5",
"@babel/eslint-parser": "7.28.5",
"@babel/plugin-transform-runtime": "7.28.5",
"@babel/preset-env": "7.28.5",
"@babel/register": "7.28.3",
"@biomejs/biome": "2.3.11",
"@pinia/testing": "1.0.3",
"@ungap/event-target": "0.2.4",
"@vitejs/devtools": "^0.3.1",
"@vitejs/plugin-vue": "^6.0.7",
"@vitejs/plugin-vue-jsx": "^5.1.5",
"@vitest/browser": "^4.1.7",
"@vitest/browser-playwright": "^4.1.7",
"@vitest/coverage-v8": "^4.1.10",
"@vitest/ui": "^4.1.7",
"@vue/babel-helper-vue-jsx-merge-props": "1.4.0",
"@vue/babel-plugin-jsx": "1.5.0",
"@vue/compiler-sfc": "3.5.22",
"@vue/test-utils": "2.4.6",
"autoprefixer": "10.4.21",
"babel-plugin-lodash": "3.3.4",
"chai": "5.3.3",
"chalk": "5.6.2",
"chromedriver": "135.0.4",
"connect-history-api-fallback": "2.0.0",
"cross-spawn": "7.0.6",
"custom-event-polyfill": "1.0.7",
"eslint": "9.39.2",
"eslint-config-standard": "17.1.0",
"eslint-formatter-friendly": "7.0.0",
"eslint-plugin-import": "2.32.0",
"eslint-plugin-n": "17.23.1",
"eslint-plugin-promise": "7.2.1",
"eslint-plugin-vue": "10.6.2",
"eventsource-polyfill": "0.9.6",
"express": "5.1.0",
"function-bind": "1.1.2",
"http-proxy-middleware": "3.0.5",
"iso-639-1": "3.1.5",
"lodash": "4.17.21",
"msw": "2.14.6",
"nightwatch": "3.12.2",
"oxc": "^1.0.1",
"playwright": "1.61.0",
"postcss": "8.5.6",
"postcss-html": "^1.5.0",
"postcss-scss": "^4.0.6",
"sass-embedded": "^1.100.0",
"selenium-server": "3.141.59",
"semver": "7.7.3",
"serve-static": "2.2.0",
"shelljs": "0.10.0",
"sinon": "20.0.0",
"sinon-chai": "4.0.1",
"stylelint": "16.25.0",
"stylelint-config-html": "^1.1.0",
"stylelint-config-recommended": "^16.0.0",
"stylelint-config-recommended-scss": "^14.0.0",
"stylelint-config-recommended-vue": "^1.6.0",
"stylelint-config-standard": "38.0.0",
"vite": "^8.0.0",
"vite-plugin-eslint2": "^5.1.0",
"vite-plugin-stylelint": "^6.1.0",
"vitest": "^4.1.7",
"vue-eslint-parser": "10.2.0"
},
"type": "module",
"engines": {
"node": ">= 16.0.0"
},
"packageManager": "yarn@1.22.22+sha512.a6b2f7906b721bba3d67d4aff083df04dad64c399707841b7acf00f6b133b7ac24255f2652fa22ae3534329dc6180534e98d17432037ff6fd140556e2bb3137e"
}
diff --git a/sonar-project.properties b/sonar-project.properties
index 15030e3cc0..5fae237b82 100644
--- a/sonar-project.properties
+++ b/sonar-project.properties
@@ -1,3 +1,5 @@
sonar.projectKey=Pleroma-FE
sonar.qualitygatesonar.host.url.wait=true
sonar.host.url=https://sonarqube.pleroma.dev
+sonar.sources=src,build,tools
+sonar.tests=test
diff --git a/src/api/admin.js b/src/api/admin.js
index d4a9e8ff96..8bbee8b4f0 100644
--- a/src/api/admin.js
+++ b/src/api/admin.js
@@ -1,491 +1,491 @@
import { promisedRequest } from './helpers.js'
const REPORTS = '/api/v1/pleroma/admin/reports'
const CONFIG_URL = '/api/v1/pleroma/admin/config'
const DESCRIPTIONS_URL = '/api/v1/pleroma/admin/config/descriptions'
const ANNOUNCEMENTS_URL = (id = '') =>
`/api/v1/pleroma/admin/announcements/${id}`
const FRONTENDS_URL = '/api/v1/pleroma/admin/frontends'
const FRONTENDS_INSTALL_URL = '/api/v1/pleroma/admin/frontends/install'
const USERS_URL = (nickname = '') => `/api/v1/pleroma/admin/users/${nickname}`
const USERS_URL_LIST = ({
page,
pageSize,
filters = {},
query = '',
name = '',
email = '',
}) => {
const {
local = false,
external = false,
active = false,
needApproval = false,
unconfirmed = false,
deactivated = false,
isAdmin = true,
isModerator = true,
} = filters
const filters_str = [
local && 'local',
external && 'external',
active && 'active',
needApproval && 'need_approval',
unconfirmed && 'unconfirmed',
deactivated && 'deactivated',
isAdmin && 'is_admin',
isModerator && 'is_moderator',
]
.filter(Boolean)
.join(',')
return `/api/v1/pleroma/admin/users?page=${page}&page_size=${pageSize}&filters=${filters_str}&query=${query}&name=${name}&email=${email}`
}
const TAG_USER_URL = '/api/pleroma/admin/users/tag'
const PERMISSION_GROUP_URL = (right) =>
`/api/pleroma/admin/users/permission_group/${right}`
const ACTIVATE_USERS_URL = '/api/pleroma/admin/users/activate'
const DEACTIVATE_USERS_URL = '/api/pleroma/admin/users/deactivate'
const SUGGEST_USERS_URL = '/api/pleroma/admin/users/suggest'
const UNSUGGEST_USERS_URL = '/api/pleroma/admin/users/unsuggest'
const APPROVE_USERS_URL = '/api/v1/pleroma/admin/users/approve'
const CONFIRM_USERS_URL = '/api/v1/pleroma/admin/users/confirm_email'
const RESEND_CONFIRMATION_EMAIL_URL =
'/api/v1/pleroma/admin/users/resend_confirmation_email'
const LIST_STATUSES_URL = ({ id, page, pageSize, godmode, withReblogs }) =>
`/api/v1/pleroma/admin/users/${id}/statuses?page_size=${pageSize}&page=${page}&godmode=${godmode}&with_reblogs=${withReblogs}`
const CHANGE_STATUS_SCOPE_URL = (id) => `/api/v1/pleroma/admin/statuses/${id}`
const REQUIRE_PASSWORD_CHANGE_URL =
'/api/v1/pleroma/admin/users/force_password_reset'
const DISABLE_MFA_URL = '/api/v1/pleroma/admin/users/disable_mfa'
const EMOJI_RELOAD_URL = '/api/pleroma/admin/reload_emoji'
const EMOJI_IMPORT_FS_URL = '/api/pleroma/emoji/packs/import'
const EMOJI_PACK_URL = (name) => `/api/v1/pleroma/emoji/pack?name=${name}`
const EMOJI_PACKS_DL_REMOTE_URL = '/api/v1/pleroma/emoji/packs/download'
const EMOJI_PACKS_DL_REMOTE_ZIP_URL = '/api/v1/pleroma/emoji/packs/download_zip'
const EMOJI_PACKS_LS_REMOTE_URL = (url, page, pageSize) =>
`/api/v1/pleroma/emoji/packs/remote?url=${url}&page=${page}&page_size=${pageSize}`
const EMOJI_UPDATE_FILE_URL = (name) =>
`/api/v1/pleroma/emoji/packs/files?name=${name}`
//
export const setUsersTags = ({
tags,
credentials,
value,
screen_names: nicknames,
}) =>
promisedRequest({
url: TAG_USER_URL,
method: value ? 'PUT' : 'DELETE',
credentials,
payload: {
nicknames,
tags,
},
})
export const setUsersRight = ({
right,
credentials,
value,
screen_names: nicknames,
}) =>
promisedRequest({
url: PERMISSION_GROUP_URL(right),
method: value ? 'POST' : 'DELETE',
credentials,
payload: {
nicknames,
},
})
export const setUsersActivationStatus = ({
credentials,
screen_names: nicknames,
value,
}) =>
promisedRequest({
url: value ? ACTIVATE_USERS_URL : DEACTIVATE_USERS_URL,
method: 'PATCH',
credentials,
payload: {
nicknames,
},
}).then((response) => response.users)
export const setUsersApprovalStatus = ({
credentials,
screen_names: nicknames,
}) =>
promisedRequest({
url: APPROVE_USERS_URL,
method: 'PATCH',
credentials,
payload: {
nicknames,
},
}).then((response) => response.users)
export const setUsersConfirmationStatus = ({
credentials,
screen_names: nicknames,
}) =>
promisedRequest({
url: CONFIRM_USERS_URL,
method: 'PATCH',
credentials,
payload: {
nicknames,
},
}).then((response) => response.users)
export const setUsersSuggestionStatus = ({
credentials,
screen_names: nicknames,
value,
}) =>
promisedRequest({
url: value ? SUGGEST_USERS_URL : UNSUGGEST_USERS_URL,
method: 'PATCH',
credentials,
payload: {
nicknames,
},
}).then((response) => response.users)
export const getUserData = ({ credentials, screen_name: nickname }) =>
promisedRequest({
url: USERS_URL(nickname),
method: 'GET',
credentials,
})
export const deleteAccounts = ({ credentials, screen_names: nicknames }) =>
promisedRequest({
url: USERS_URL(),
method: 'DELETE',
credentials,
payload: {
nicknames,
},
})
export const getAnnouncements = ({ id, credentials }) =>
promisedRequest({ url: ANNOUNCEMENTS_URL(id), credentials })
// the reported list is hardly useful because standards are for dating i guess,
// so make sure to fetchIfMissing right afterward using this call
export const listUsers = ({ opts, credentials }) =>
promisedRequest({
url: USERS_URL_LIST(opts),
credentials,
method: 'GET',
})
export const resendConfirmationEmail = ({
screen_names: nicknames,
credentials,
}) =>
promisedRequest({
url: RESEND_CONFIRMATION_EMAIL_URL,
credentials,
method: 'PATCH',
payload: {
nicknames,
},
})
export const requirePasswordChange = ({
screen_names: nicknames,
credentials,
}) =>
promisedRequest({
url: REQUIRE_PASSWORD_CHANGE_URL,
credentials,
method: 'PATCH',
payload: {
nicknames,
},
})
export const disableMFA = ({ screen_name: nickname, credentials }) =>
promisedRequest({
url: DISABLE_MFA_URL,
credentials,
method: 'PUT',
payload: {
nickname,
},
})
export const listStatuses = ({ opts, credentials }) =>
promisedRequest({
url: LIST_STATUSES_URL(opts),
credentials,
method: 'GET',
})
export const changeStatusScope = ({
opts: { id, sensitive, visibility },
credentials,
}) => {
const payload = {}
- if (typeof sensitive !== 'undefined') {
+ if (sensitive !== undefined) {
payload['sensitive'] = sensitive
}
- if (typeof visibility !== 'undefined') {
+ if (visibility !== undefined) {
payload['visibility'] = visibility
}
return promisedRequest({
url: CHANGE_STATUS_SCOPE_URL(id),
credentials,
method: 'PUT',
payload,
})
}
export const announcementToPayload = ({
content,
startsAt,
endsAt,
allDay,
}) => {
const payload = { content }
- if (typeof startsAt !== 'undefined') {
+ if (startsAt !== undefined) {
payload.starts_at = startsAt ? new Date(startsAt).toISOString() : null
}
- if (typeof endsAt !== 'undefined') {
+ if (endsAt !== undefined) {
payload.ends_at = endsAt ? new Date(endsAt).toISOString() : null
}
- if (typeof allDay !== 'undefined') {
+ if (allDay !== undefined) {
payload.all_day = allDay
}
return payload
}
export const postAnnouncement = ({
credentials,
content,
startsAt,
endsAt,
allDay,
}) =>
promisedRequest({
url: ANNOUNCEMENTS_URL(),
credentials,
method: 'POST',
payload: announcementToPayload({ content, startsAt, endsAt, allDay }),
})
export const editAnnouncement = ({
id,
credentials,
content,
startsAt,
endsAt,
allDay,
}) =>
promisedRequest({
url: ANNOUNCEMENTS_URL(id),
credentials,
method: 'PATCH',
payload: announcementToPayload({ content, startsAt, endsAt, allDay }),
})
export const deleteAnnouncement = ({ id, credentials }) =>
promisedRequest({
url: ANNOUNCEMENTS_URL(id),
credentials,
method: 'DELETE',
})
export const setReportState = ({ id, state, credentials }) => {
return promisedRequest({
url: REPORTS,
credentials,
method: 'PATCH',
payload: {
reports: [
{
id,
state,
},
],
},
})
}
export const getInstanceDBConfig = ({ credentials }) =>
promisedRequest({
url: CONFIG_URL,
credentials,
})
export const getInstanceConfigDescriptions = ({ credentials }) =>
promisedRequest({
url: DESCRIPTIONS_URL,
credentials,
})
export const getAvailableFrontends = ({ credentials }) =>
promisedRequest({
url: FRONTENDS_URL,
credentials,
})
export const pushInstanceDBConfig = ({ credentials, payload }) =>
promisedRequest({
url: CONFIG_URL,
method: 'POST',
credentials,
payload,
})
export const installFrontend = ({ credentials, payload }) =>
promisedRequest({
url: FRONTENDS_INSTALL_URL,
credentials,
method: 'POST',
payload,
})
// Emoji packs
export const deleteEmojiPack = ({ name }) =>
promisedRequest({
url: EMOJI_PACK_URL(name),
method: 'DELETE',
})
export const reloadEmoji = ({ credentials }) =>
promisedRequest({
url: EMOJI_RELOAD_URL,
method: 'POST',
credentials,
})
export const importEmojiFromFS = ({ credentials }) =>
promisedRequest({
url: EMOJI_IMPORT_FS_URL,
credentials,
})
export const createEmojiPack = ({ name, credentials }) =>
promisedRequest({
url: EMOJI_PACK_URL(name),
method: 'POST',
credentials,
})
export const listRemoteEmojiPacks = ({
instance,
page,
pageSize,
credentials,
}) => {
if (!instance.startsWith('http')) {
instance = 'https://' + instance
}
return promisedRequest({
url: EMOJI_PACKS_LS_REMOTE_URL(instance, page, pageSize),
credentials,
})
}
export const downloadRemoteEmojiPack = ({
instance,
packName,
as,
credentials,
}) =>
promisedRequest({
url: EMOJI_PACKS_DL_REMOTE_URL,
credentials,
method: 'POST',
payload: {
url: instance,
name: packName,
as,
},
})
export const downloadRemoteEmojiPackZIP = ({
url,
packName,
file,
credentials,
}) => {
const data = new FormData()
if (file) data.set('file', file)
if (url) data.set('url', url)
data.set('name', packName)
return promisedRequest({
url: EMOJI_PACKS_DL_REMOTE_ZIP_URL,
method: 'POST',
formData: data,
})
}
export const saveEmojiPackMetadata = ({ name, newData, credentials }) =>
promisedRequest({
url: EMOJI_PACK_URL(name),
credentials,
method: 'PATCH',
payload: { metadata: newData },
})
export const addNewEmojiFile = ({ packName, file, shortcode, filename }) => {
const data = new FormData()
if (filename.trim() !== '') {
data.set('filename', filename)
}
if (shortcode.trim() !== '') {
data.set('shortcode', shortcode)
}
data.set('file', file)
return promisedRequest({
url: EMOJI_UPDATE_FILE_URL(packName),
method: 'POST',
formData: data,
})
}
export const updateEmojiFile = ({
packName,
shortcode,
newShortcode,
newFilename,
credentials,
force,
}) =>
promisedRequest({
url: EMOJI_UPDATE_FILE_URL(packName),
credentials,
method: 'PATCH',
payload: {
shortcode,
new_shortcode: newShortcode,
new_filename: newFilename,
force,
},
})
export const deleteEmojiFile = ({ packName, shortcode }) =>
promisedRequest({
url: `${EMOJI_UPDATE_FILE_URL(packName)}&shortcode=${shortcode}`,
method: 'DELETE',
})
diff --git a/src/api/chats.js b/src/api/chats.js
index 5d766dec40..b212380de7 100644
--- a/src/api/chats.js
+++ b/src/api/chats.js
@@ -1,94 +1,94 @@
import { paramsString, promisedRequest } from './helpers.js'
import {
parseChat,
parseChatMessage,
} from 'src/services/entity_normalizer/entity_normalizer.service.js'
const PLEROMA_CHATS_URL = '/api/v1/pleroma/chats'
const PLEROMA_CHAT_URL = (id) => `/api/v1/pleroma/chats/by-account-id/${id}`
const PLEROMA_CHAT_MESSAGES_URL = (id, { maxId, sinceId, limit } = {}) =>
`/api/v1/pleroma/chats/${id}/messages${paramsString({ maxId, sinceId, limit })}`
const PLEROMA_CHAT_READ_URL = (id) => `/api/v1/pleroma/chats/${id}/read`
const PLEROMA_DELETE_CHAT_MESSAGE_URL = (chatId, messageId) =>
`/api/v1/pleroma/chats/${chatId}/messages/${messageId}`
export const chats = ({ credentials }) =>
promisedRequest({
url: PLEROMA_CHATS_URL,
credentials,
}).then(({ data }) => ({
- data: data.map(parseChat).filter((c) => c),
+ data: data.map(parseChat).filter(Boolean),
}))
export const getOrCreateChat = ({ accountId, credentials }) =>
promisedRequest({
url: PLEROMA_CHAT_URL(accountId),
method: 'POST',
credentials,
}).then(({ data }) => ({ data: parseChat(data) }))
export const chatMessages = ({
id,
credentials,
maxId,
sinceId,
limit = 20,
}) => {
return promisedRequest({
url: PLEROMA_CHAT_MESSAGES_URL(id, { maxId, sinceId, limit }),
method: 'GET',
credentials,
}).then(({ data }) => ({
- data: data.map(parseChatMessage).filter((c) => c),
+ data: data.map(parseChatMessage).filter(Boolean),
}))
}
export const sendChatMessage = ({
id,
content,
mediaId = null,
idempotencyKey,
credentials,
}) => {
const payload = {
content,
}
if (mediaId) {
payload.media_id = mediaId
}
const headers = {}
if (idempotencyKey) {
headers['idempotency-key'] = idempotencyKey
}
return promisedRequest({
url: PLEROMA_CHAT_MESSAGES_URL(id),
method: 'POST',
payload,
credentials,
headers,
}).then(({ data }) => ({
data: parseChatMessage(data),
}))
}
export const readChat = ({ id, lastReadId, credentials }) =>
promisedRequest({
url: PLEROMA_CHAT_READ_URL(id),
method: 'POST',
payload: {
last_read_id: lastReadId,
},
credentials,
})
export const deleteChatMessage = ({ chatId, messageId, credentials }) =>
promisedRequest({
url: PLEROMA_DELETE_CHAT_MESSAGE_URL(chatId, messageId),
method: 'DELETE',
credentials,
})
diff --git a/src/api/helpers.js b/src/api/helpers.js
index b033d80b88..3262754957 100644
--- a/src/api/helpers.js
+++ b/src/api/helpers.js
@@ -1,143 +1,139 @@
import { snakeCase } from 'lodash'
import { StatusCodeError } from 'src/services/errors/errors'
export const paramsString = (params = {}) => {
if (params == null || params === undefined) return ''
if (typeof params !== 'object' || Array.isArray(params)) {
throw new TypeError('Params are not an object!')
}
const entries = (() => {
if (params instanceof Map) {
return [...params.entries()]
} else {
return Object.entries(params)
}
})()
const arrays = []
const nonArrays = []
entries.forEach(([k, v]) => {
if (v == null) return // Drop nulls
if (
(typeof v === 'object' && !Array.isArray(v)) ||
typeof v === 'function'
) {
throw new TypeError('Param cannot be non-primitive!')
}
if (Array.isArray(v)) {
arrays.push([k, v])
} else {
nonArrays.push([k, v])
}
})
arrays.forEach(([k, array]) => {
array.forEach((v) => {
- if (
- typeof v === 'object' ||
- typeof v === 'function' ||
- typeof v === 'undefined'
- )
+ if (typeof v === 'object' || typeof v === 'function' || v === undefined)
throw new TypeError('Array param cannot contain non-primitives!')
})
})
if (nonArrays.length + arrays.length === 0) return ''
return (
'?' +
[
...nonArrays.map(([k, v]) => [snakeCase(k), v]),
// turning [a,[1,2,3]] into [[a[],1],[a[],2],[a[],3]]
...arrays.reduce(
(acc, [k, arrayValue]) => [
...acc,
...arrayValue.map((v) => [snakeCase(k) + '[]', v]),
],
[],
),
]
.map(([k, v]) => `${k}=${window.encodeURIComponent(v)}`)
.join('&')
)
}
export const promisedRequest = async ({
method,
url,
payload,
formData,
forceContentType,
cache,
credentials,
headers = {},
}) => {
const options = {
method,
credentials: 'same-origin',
headers: {
Accept: forceContentType ?? 'application/json',
...headers,
},
}
if (!formData) {
options.headers['Content-Type'] = 'application/json'
}
if (cache) {
options.cache = cache
}
if (formData || payload) {
options.body = formData || JSON.stringify(payload)
}
if (credentials) {
options.headers = {
...options.headers,
...authHeaders(credentials),
}
}
const response = await fetch(url, options)
const data = await (async () => {
const [contentType] = response.headers
.get('content-type')
.split(';')
.map((x) => x.toLowerCase().trim())
const contentLength = Number.parseInt(
response.headers.get('content-length'),
)
if (contentLength === 0) return null
switch (forceContentType ?? contentType) {
case 'text/plain':
return await response.text()
case 'application/json':
return await response.json()
default:
return await response.bytes()
}
})()
const { ok, status } = response
if (ok) {
return { response, status, data }
} else {
throw new StatusCodeError(response.status, data, { url, options }, response)
}
}
const authHeaders = (accessToken) => {
if (accessToken) {
return { Authorization: `Bearer ${accessToken}` }
} else {
return {}
}
}
diff --git a/src/components/announcement/announcement.js b/src/components/announcement/announcement.js
index ee427533ff..6b45b2b909 100644
--- a/src/components/announcement/announcement.js
+++ b/src/components/announcement/announcement.js
@@ -1,126 +1,125 @@
import { mapState } from 'vuex'
import AnnouncementEditor from 'src/components/announcement_editor/announcement_editor.vue'
import localeService from '../../services/locale/locale.service.js'
import { useAnnouncementsStore } from 'src/stores/announcements.js'
const Announcement = {
components: {
AnnouncementEditor,
},
data() {
return {
editing: false,
editedAnnouncement: {
content: '',
startsAt: undefined,
endsAt: undefined,
allDay: undefined,
},
editError: '',
}
},
props: {
announcement: Object,
},
computed: {
...mapState({
currentUser: (state) => state.users.currentUser,
}),
canEditAnnouncement() {
- return (
- this.currentUser &&
- this.currentUser.privileges.has('announcements_manage_announcements')
+ return this.currentUser?.privileges.has(
+ 'announcements_manage_announcements',
)
},
content() {
return this.announcement.content
},
isRead() {
return this.announcement.read
},
publishedAt() {
const time = this.announcement.published_at
if (!time) {
return
}
return this.formatTimeOrDate(
time,
localeService.internalToBrowserLocale(this.$i18n.locale),
)
},
startsAt() {
const time = this.announcement.starts_at
if (!time) {
return
}
return this.formatTimeOrDate(
time,
localeService.internalToBrowserLocale(this.$i18n.locale),
)
},
endsAt() {
const time = this.announcement.ends_at
if (!time) {
return
}
return this.formatTimeOrDate(
time,
localeService.internalToBrowserLocale(this.$i18n.locale),
)
},
inactive() {
return this.announcement.inactive
},
},
methods: {
markAsRead() {
if (!this.isRead) {
return useAnnouncementsStore().markAnnouncementAsRead(
this.announcement.id,
)
}
},
deleteAnnouncement() {
return useAnnouncementsStore().deleteAnnouncement(this.announcement.id)
},
formatTimeOrDate(time, locale) {
const d = new Date(time)
return this.announcement.all_day
? d.toLocaleDateString(locale)
: d.toLocaleString(locale)
},
enterEditMode() {
this.editedAnnouncement.content = this.announcement.pleroma.raw_content
this.editedAnnouncement.startsAt = this.announcement.starts_at
this.editedAnnouncement.endsAt = this.announcement.ends_at
this.editedAnnouncement.allDay = this.announcement.all_day
this.editing = true
},
submitEdit() {
useAnnouncementsStore()
.editAnnouncement({
id: this.announcement.id,
...this.editedAnnouncement,
})
.then(() => {
this.editing = false
})
.catch((error) => {
this.editError = error.error
})
},
cancelEdit() {
this.editing = false
},
clearError() {
this.editError = undefined
},
},
}
export default Announcement
diff --git a/src/components/announcements_page/announcements_page.js b/src/components/announcements_page/announcements_page.js
index 0f7933e5f8..802e90cf68 100644
--- a/src/components/announcements_page/announcements_page.js
+++ b/src/components/announcements_page/announcements_page.js
@@ -1,65 +1,64 @@
import { mapState } from 'vuex'
import Announcement from 'src/components/announcement/announcement.vue'
import AnnouncementEditor from 'src/components/announcement_editor/announcement_editor.vue'
import { useAnnouncementsStore } from 'src/stores/announcements.js'
const AnnouncementsPage = {
components: {
Announcement,
AnnouncementEditor,
},
data() {
return {
newAnnouncement: {
content: '',
startsAt: undefined,
endsAt: undefined,
allDay: false,
},
posting: false,
error: undefined,
}
},
mounted() {
useAnnouncementsStore().fetchAnnouncements()
},
computed: {
...mapState({
currentUser: (state) => state.users.currentUser,
}),
announcements() {
return useAnnouncementsStore().announcements
},
canPostAnnouncement() {
- return (
- this.currentUser &&
- this.currentUser.privileges.has('announcements_manage_announcements')
+ return this.currentUser?.privileges.has(
+ 'announcements_manage_announcements',
)
},
},
methods: {
postAnnouncement() {
this.posting = true
useAnnouncementsStore()
.postAnnouncement(this.newAnnouncement)
.then(() => {
this.newAnnouncement.content = ''
this.startsAt = undefined
this.endsAt = undefined
})
.catch((error) => {
this.error = error.error
})
.finally(() => {
this.posting = false
})
},
clearError() {
this.error = undefined
},
},
}
export default AnnouncementsPage
diff --git a/src/components/attachment/attachment.js b/src/components/attachment/attachment.js
index f7d6ebf107..6556b07a0b 100644
--- a/src/components/attachment/attachment.js
+++ b/src/components/attachment/attachment.js
@@ -1,218 +1,218 @@
import { mapState } from 'pinia'
import { defineAsyncComponent } from 'vue'
import Popover from 'src/components/popover/popover.vue'
import nsfwImage from '../../assets/nsfw.png'
import { useInstanceStore } from 'src/stores/instance.js'
import { useInstanceCapabilitiesStore } from 'src/stores/instance_capabilities.js'
import { useMediaViewerStore } from 'src/stores/media_viewer'
import { useMergedConfigStore } from 'src/stores/merged_config.js'
import { library } from '@fortawesome/fontawesome-svg-core'
import {
faAlignRight,
faFile,
faImage,
faMusic,
faPencilAlt,
faPlayCircle,
faSearchPlus,
faStop,
faTimes,
faTrashAlt,
faVideo,
} from '@fortawesome/free-solid-svg-icons'
library.add(
faFile,
faMusic,
faImage,
faVideo,
faPlayCircle,
faTimes,
faStop,
faSearchPlus,
faTrashAlt,
faPencilAlt,
faAlignRight,
)
const Attachment = {
props: [
'attachment',
'compact',
'description',
'hideDescription',
'nsfw',
'size',
'setMedia',
'remove',
'shiftUp',
'shiftDn',
'edit',
],
emits: ['play', 'pause', 'naturalSizeLoad'],
data() {
return {
localDescription: this.description || this.attachment.description,
nsfwImage:
useInstanceStore().instanceIdentity.nsfwCensorImage || nsfwImage,
hideNsfwLocal: useMergedConfigStore().mergedConfig.hideNsfw,
preloadImage: useMergedConfigStore().mergedConfig.preloadImage,
loading: false,
img: this.attachment.type === 'image' && document.createElement('img'),
modalOpen: false,
showHidden: false,
flashLoaded: false,
}
},
components: {
Flash: defineAsyncComponent(() => import('src/components/flash/flash.vue')),
VideoAttachment: defineAsyncComponent(
() => import('src/components/video_attachment/video_attachment.vue'),
),
Popover,
},
computed: {
classNames() {
return [
{
'-loading': this.loading,
'-nsfw-placeholder': this.hidden,
'-editable': this.edit !== undefined,
'-compact': this.compact,
},
'-type-' + this.attachment.type,
this.size && '-size-' + this.size,
`-${this.useContainFit ? 'contain' : 'cover'}-fit`,
]
},
usePlaceholder() {
return this.size === 'hide'
},
useContainFit() {
return this.mergedConfig.useContainFit
},
placeholderName() {
if (this.attachment.description === '' || !this.attachment.description) {
return this.attachment.type.toUpperCase()
}
return this.attachment.description
},
placeholderIconClass() {
if (this.attachment.type === 'image') return 'image'
if (this.attachment.type === 'video') return 'video'
if (this.attachment.type === 'audio') return 'music'
return 'file'
},
referrerpolicy() {
return useInstanceCapabilitiesStore().mediaProxyAvailable
? ''
: 'no-referrer'
},
hidden() {
return this.nsfw && this.hideNsfwLocal && !this.showHidden
},
isEmpty() {
return this.attachment.type === 'html' && !this.attachment.oembed
},
useModal() {
let modalTypes = []
switch (this.size) {
case 'hide':
case 'small':
modalTypes = ['image', 'video', 'audio', 'flash']
break
default:
modalTypes = this.mergedConfig.playVideosInModal
? ['image', 'video', 'flash']
: ['image']
break
}
return modalTypes.includes(this.attachment.type)
},
videoTag() {
return this.useModal ? 'button' : 'span'
},
...mapState(useMergedConfigStore, ['mergedConfig']),
},
watch: {
'attachment.description'(newVal) {
this.localDescription = newVal
},
localDescription(newVal) {
this.onEdit(newVal)
},
},
methods: {
linkClicked({ target }) {
if (target.tagName === 'A') {
window.open(target.href, '_blank')
}
},
openModal() {
if (this.useModal) {
this.$emit('setMedia')
useMediaViewerStore().setCurrentMedia(this.attachment)
} else if (this.attachment.type === 'unknown') {
window.open(this.attachment.url)
}
},
openModalForce() {
this.$emit('setMedia')
useMediaViewerStore().setCurrentMedia(this.attachment)
},
onEdit(event) {
- this.edit && this.edit(this.attachment, event)
+ this.edit?.(this.attachment, event)
},
onRemove() {
- this.remove && this.remove(this.attachment)
+ this.remove?.(this.attachment)
},
onShiftUp() {
- this.shiftUp && this.shiftUp(this.attachment)
+ this.shiftUp?.(this.attachment)
},
onShiftDn() {
- this.shiftDn && this.shiftDn(this.attachment)
+ this.shiftDn?.(this.attachment)
},
stopFlash() {
this.$refs.flash.closePlayer()
},
setFlashLoaded(event) {
this.flashLoaded = event
},
toggleHidden(event) {
if (
this.mergedConfig.useOneClickNsfw &&
!this.showHidden &&
(this.attachment.type !== 'video' ||
this.mergedConfig.playVideosInModal)
) {
this.openModal(event)
return
}
if (this.img && !this.preloadImage) {
if (this.img.onload) {
this.img.onload()
} else {
this.loading = true
this.img.src = this.attachment.url
this.img.onload = () => {
this.loading = false
this.showHidden = !this.showHidden
}
}
} else {
this.showHidden = !this.showHidden
}
},
onImageLoad(image) {
const width = image.naturalWidth
const height = image.naturalHeight
this.$emit('naturalSizeLoad', { id: this.attachment.id, width, height })
},
},
}
export default Attachment
diff --git a/src/components/chat_message/chat_message.vue b/src/components/chat_message/chat_message.vue
index 394cce4b76..88ab2fc0a4 100644
--- a/src/components/chat_message/chat_message.vue
+++ b/src/components/chat_message/chat_message.vue
@@ -1,241 +1,252 @@
<template>
<div
v-if="isMessage"
+ :id="`chatmessage-${message.id}`"
class="chat-message-wrapper"
:class="[classnames, { 'hovered-message-chain': hoveredMessageChain }]"
- :id="`chatmessage-${message.id}`"
@mouseover="onHover(true)"
@mouseleave="onHover(false)"
>
<i18n-t
v-if="isStatus && (isCustomReply || isBrokenReply)"
keypath="status.reply_to_with_arg"
scope="global"
tag="small"
class="reply-to-header faint"
>
<template #replyToWithIcon>
<div class="avatar-spacer" />
<StatusPopover
v-if="!isBrokenReply"
:status-id="customReplyTo?.id"
class="reply-to-popover"
:class="{ '-strikethrough': !message.parent_visible }"
>
<i18n-t
keypath="status.reply_to_with_icon"
scope="global"
>
<template #icon>
<FAIcon
class="fa-scale-110"
icon="reply"
flip="horizontal"
/>
</template>
<template #replyTo>
<span class="reply-label">
{{ $t('status.reply_to') }}
</span>
</template>
</i18n-t>
</StatusPopover>
- <span v-else class="reply-label">
+ <span
+ v-else
+ class="reply-label"
+ >
{{ $t('status.broken_reply') }}
</span>
</template>
<template #user>
<MentionLink
class="reply-body"
:content="replyToName"
:url="replyProfileLink"
:user-id="message.in_reply_to_user_id"
:user-screen-name="message.in_reply_to_screen_name"
/>
<!-- v-if is there because status might not be loaded yet -->
<template v-if="customReplyTo?.text.trim().length > 0">
:
<StatusBody
class="reply-body faint"
:status="customReplyTo"
collapse
single-line
ignore-subject
/>
</template>
</template>
</i18n-t>
<div
class="chat-message"
:class="classnames"
>
<div
v-if="!isCurrentUser"
class="avatar-wrapper"
>
<UserPopover
v-if="chatItem.isHead"
:user-id="authorId"
>
<UserAvatar
v-if="author"
:compact="true"
:user="author"
/>
</UserPopover>
- <div v-else class="avatar-spacer" />
+ <div
+ v-else
+ class="avatar-spacer"
+ />
</div>
<div class="chat-message-inner">
<div class="message-bubble-wrapper">
<div
class="status-body"
:style="{ 'min-width': message.attachment ? '80%' : '' }"
>
<div
class="media status"
:class="{ 'without-attachment': !hasAttachment, 'pending': chatItem.data.pending, 'error': chatItem.data.error }"
style="position: relative;"
@mouseenter="hovered = true"
@mouseleave="hovered = false"
>
-
<StatusActionButtons
v-if="isStatus"
class="chat-message-toolbar"
:class="{ '-visible': hovered || menuOpened }"
:status="message"
:pinned="new Set(['reply', 'emoji'])"
fixed-pinned
use-default-buttons
hide-labels
in-chat-view
@toggle-replying="$emit('replyRequested', message)"
/>
<div
+ v-else
class="chat-message-toolbar"
:class="{ '-visible': hovered || menuOpened }"
- v-else
>
<Popover
trigger="click"
:trigger-attrs="{ 'class': 'button-default menu-icon simple-button', title: $t('chats.more') }"
placement="top"
:margin="popoverMarginStyle"
@show="menuOpened = true"
@close="menuOpened = false"
>
<template #content>
<div class="dropdown-menu">
<div class="menu-item dropdown-item -icon">
<button
class="main-button"
@click="deleteMessage"
>
<FAIcon icon="times" /> {{ $t("chats.delete") }}
</button>
</div>
</div>
</template>
<template #trigger>
<FAIcon icon="ellipsis-h" />
</template>
</Popover>
</div>
<StatusContent
class="message-content"
:class="{ faint: message.pending }"
:status="messageForStatusContent"
:full-content="true"
>
<template #footer>
<EmojiReactions
v-if="isStatus"
:status="message"
/>
<Quote
v-if="isStatus"
class="quoted-post"
:status-id="quoteId"
:status-url="quoteUrl"
:status-visible="quoteVisible"
initially-expanded
/>
<span
class="created-at"
>
<span
v-if="message.favorited"
>
<FAIcon
class="fa-scale-110"
icon="star"
fixed-width
/>
</span>
<span
v-if="message.repeated"
>
<FAIcon
class="fa-scale-110"
icon="retweet"
fixed-width
/>
</span>
<span
v-if="message.visibility"
class="visibility-icon"
:title="visibilityLocalized"
>
<FAIcon
class="fa-scale-110"
:icon="visibilityIcon(message.visibility)"
fixed-width
/>
</span>
<span
v-if="message.pending"
class="loading-spinner"
>
<FAIcon
class="fa-old-padding"
icon="circle-notch"
spin
/>
</span>
{{ ' ' }}
<router-link
class="timeago faint"
:to="{ name: 'conversation2', params: { statusId: message.id } }"
>
<Timeago
:time="message.created_at"
:auto-update="60"
/>
</router-link>
</span>
</template>
</StatusContent>
</div>
</div>
<div
v-if="isStatus && repliedTo"
class="reply-indicator"
>
- <FAIcon class="icon" icon="reply" />
+ <FAIcon
+ class="icon"
+ icon="reply"
+ />
</div>
<div class="end-spacer" />
</div>
</div>
</div>
</div>
<div
v-else
class="chat-message-date-separator"
>
- <ChatMessageDate :date="chatItem.date" :show-time="chatItem.isTime" />
+ <ChatMessageDate
+ :date="chatItem.date"
+ :show-time="chatItem.isTime"
+ />
</div>
</template>
<script src="./chat_message.js"></script>
<style src="./chat_message.scss" lang="scss" />
diff --git a/src/components/chat_message_list/chat_message_list.vue b/src/components/chat_message_list/chat_message_list.vue
index 5cdbf68719..7833730e7c 100644
--- a/src/components/chat_message_list/chat_message_list.vue
+++ b/src/components/chat_message_list/chat_message_list.vue
@@ -1,19 +1,19 @@
<template>
<div class="ChatMessageList">
<ChatMessage
v-for="(chatItem, index) in chatItems"
:key="chatItem.id"
:chat-item="chatItem"
:previous-item="getPreviousItem(index)"
:hovered-message-chain="chatItem.messageChainId === hoveredMessageChainId"
:focused="chatItem.id === focusedId"
- :repliedTo="chatItem.id === repliedId"
+ :replied-to="chatItem.id === repliedId"
@hover="onMessageHover"
@delete="onMessageDelete"
@reply-requested="onReplyRequested"
/>
</div>
</template>
<script src="./chat_message_list.js"></script>
<style src="./chat_message_list.scss" lang="scss" />
diff --git a/src/components/chat_view/chat_view.js b/src/components/chat_view/chat_view.js
index f48b75c653..16b2ae1d72 100644
--- a/src/components/chat_view/chat_view.js
+++ b/src/components/chat_view/chat_view.js
@@ -1,647 +1,647 @@
import { get, maxBy, minBy, sortBy, throttle } from 'lodash'
import { mapState as mapPiniaState } from 'pinia'
import { nextTick } from 'vue'
import { mapState } from 'vuex'
import ChatMessageList from 'src/components/chat_message_list/chat_message_list.vue'
import ChatTitle from 'src/components/chat_title/chat_title.vue'
import PostStatusForm from 'src/components/post_status_form/post_status_form.vue'
import { buildFakeMessage } from '../../services/chat_utils/chat_utils.js'
import { promiseInterval } from '../../services/promise_interval/promise_interval.js'
import {
getNewTopPosition,
getScrollPosition,
isBottomedOut,
isScrollable,
} from './chat_layout_utils.js'
import { useChatsStore } from 'src/stores/chats.js'
import { useInterfaceStore } from 'src/stores/interface.js'
import { useMergedConfigStore } from 'src/stores/merged_config.js'
import { useOAuthStore } from 'src/stores/oauth.js'
import {
chatMessages,
deleteChatMessage,
getOrCreateChat,
readChat,
sendChatMessage,
} from 'src/api/chats.js'
import { fetchConversation, fetchStatus } from 'src/api/public.js'
import { WSConnectionStatus } from 'src/api/websocket.js'
import { library } from '@fortawesome/fontawesome-svg-core'
import { faChevronDown, faChevronLeft } from '@fortawesome/free-solid-svg-icons'
library.add(faChevronDown, faChevronLeft)
const BOTTOMED_OUT_OFFSET = 10
const JUMP_TO_BOTTOM_BUTTON_VISIBILITY_OFFSET = 10
const SAFE_RESIZE_TIME_OFFSET = 100
const MARK_AS_READ_DELAY = 1500
const MAX_RETRIES = 10
const isConfirmation = (storage, message) => {
if (!message.idempotency_key) return
return storage.idempotencyKeyIndex[message.idempotency_key]
}
const Chat = {
components: {
ChatMessageList,
ChatTitle,
PostStatusForm,
},
props: {
statusId: {
type: String,
default: null,
},
chatUserId: {
type: String,
default: null,
},
testMode: Boolean,
},
data() {
return {
// Main info
chat: null,
messages: [],
messagesIndex: {},
pendingMessages: [],
pendingMessagesIndex: {},
minId: undefined,
maxId: undefined,
// Conversation stuff
explicitReplyStatus: null,
// Unread stuff
newMessageCount: 0,
lastReadMessageId: null,
lastScrollPosition: {},
jumpToBottomButtonVisible: false,
// Internal network stuff
fetcher: null,
errorLoadingChat: false,
messageRetriers: {},
idempotencyKeyIndex: {},
}
},
created() {
if (this.testMode) return
this.startFetching()
},
mounted() {
window.addEventListener('resize', this.handleResize)
window.addEventListener('scroll', this.handleScroll)
- if (typeof document.hidden !== 'undefined') {
+ if (document.hidden !== undefined) {
document.addEventListener(
'visibilitychange',
this.handleVisibilityChange,
false,
)
}
this.$nextTick(() => {
this.handleResize()
})
},
unmounted() {
window.removeEventListener('scroll', this.handleScroll)
window.removeEventListener('resize', this.handleResize)
- if (typeof document.hidden !== 'undefined')
+ if (document.hidden !== undefined)
document.removeEventListener(
'visibilitychange',
this.handleVisibilityChange,
false,
)
},
computed: {
conversationId() {
const status = this.$store.state.statuses.allStatusesObject[this.statusId]
return get(
status,
'retweeted_status.statusnet_conversation_id',
get(status, 'statusnet_conversation_id'),
)
},
isConversation() {
return this.statusId !== null
},
recipient() {
return this.chat?.account
},
formPlaceholder() {
if (this.recipient) {
return this.$t('chats.message_user', {
nickname: this.recipient.screen_name_ui,
})
} else {
return ''
}
},
// Conversation stuff
lastStatus() {
return this.messages[this.messages.length - 1]
},
replyStatus() {
return this.explicitReplyStatus ?? this.lastStatus
},
// Global Stuff
streamingEnabled() {
if (this.isConversation) return false // Unsupported
return (
this.mergedConfig.useStreamingApi &&
this.mastoUserSocketStatus === WSConnectionStatus.JOINED
)
},
...mapPiniaState(useInterfaceStore, {
mobileLayout: (store) => store.layoutType === 'mobile',
}),
...mapPiniaState(useMergedConfigStore, ['mergedConfig']),
...mapState({
mastoUserSocketStatus: (state) => state.api.mastoUserSocketStatus,
currentUser: (state) => state.users.currentUser,
}),
},
watch: {
messages(old, neu) {
if (old.length === neu.length) return
// We don't want to scroll to the bottom on a new message when the user is viewing older messages.
// Therefore we need to know whether the scroll position was at the bottom before the DOM update.
const bottomedOutBeforeUpdate = isBottomedOut(BOTTOMED_OUT_OFFSET)
this.$nextTick(() => {
if (bottomedOutBeforeUpdate) {
this.scrollDown()
}
})
},
async replyStatus(newVal) {
await nextTick() // wait for changes to propagate to postStatusForm
if (this.testMode) return
this.$refs.postStatusForm.update()
},
$route: async function (newVal) {
if (this.messagesIndex[newVal.params.statusId]) {
const focused = document.getElementById(
`chatmessage-${this.$route.params.statusId}`,
)
if (focused?.getBoundingClientRect == null) return
const bottomBoundary =
window.innerHeight - this.$refs.footer.clientHeight
const topBoundary =
this.$refs.header.clientHeight +
document.getElementById('nav').clientHeight
const margin = Number(
window
.getComputedStyle(this.$refs.messageList.$el)
.gap.replace('px', ''),
)
const rect = focused.getBoundingClientRect()
const scrollAmount = (() => {
if (rect.top < topBoundary) {
// Post is above screen, match its top to screen top
return rect.top - topBoundary - margin
} else if (rect.height >= bottomBoundary) {
// Post we want to see is taller than screen so match its top to screen top
return rect.top - topBoundary - margin
} else if (rect.bottom > bottomBoundary) {
// Post is below screen, match its bottom to screen bottom
return rect.bottom - bottomBoundary + margin
} else {
return 0
}
})()
if (scrollAmount !== 0) {
window.scrollBy(0, scrollAmount)
}
return
}
this.clear()
this.startFetching()
},
mastoUserSocketStatus(newValue) {
if (newValue === WSConnectionStatus.JOINED) {
this.fetchChat({ isFirstFetch: true })
}
},
},
methods: {
// Actions
async readChat() {
if (this.conversationId) return // Unsupported
if (!this.maxId || document.hidden) {
return
}
const lastReadId = this.maxId
const isNewMessage = this.lastReadMessageId !== lastReadId
if (!isNewMessage) return
if (!this.testMode) {
await readChat({
id: this.chat.id,
lastReadId,
credentials: useOAuthStore().token,
})
}
useChatsStore().readChat(this.chat.id)
this.lastReadMessageId = this.maxId
this.newMessageCount = 0
},
scrollDown(options = {}) {
const { behavior = 'auto', forceRead = false } = options
this.$nextTick(() => {
window.scrollTo({
top: document.documentElement.scrollHeight,
behavior,
})
})
if (forceRead) {
this.readChat()
}
},
cullOlder() {
const maxIndex = this.messages.length
const minIndex = maxIndex - 50
if (maxIndex <= 50) return
this.messages = sortBy(this.messages, ['id'])
this.minId = this.messages[minIndex].id
for (const message of this.messages) {
if (message.id < this.minId) {
delete this.messagesIndex[message.id]
delete this.idempotencyKeyIndex[message.idempotency_key]
}
}
this.messages = this.messages.slice(minIndex, maxIndex)
},
clear() {
this.messages = this.messages.filter((m) => m.error)
this.messagesIndex = this.messages.reduce(
(acc, m) => ({
...acc,
[m.id]: m,
}),
{},
)
this.newMessageCount = 0
this.lastReadMessageId = null
this.minId = undefined
this.maxId = undefined
},
async fetchChat({ isFirstFetch = false, fetchLatest = false, maxId }) {
if (fetchLatest && this.streamingEnabled) {
return
}
let messages
if (this.isConversation) {
const [
{ data: status },
{
data: { ancestors, descendants },
},
] = await Promise.all([
fetchStatus({
id: this.statusId,
credentials: useOAuthStore().token,
}),
fetchConversation({
id: this.statusId,
credentials: useOAuthStore().token,
}),
])
messages = [...ancestors, status, ...descendants]
} else {
const { data } = await chatMessages({
id: this.chat.id,
maxId,
sinceId: fetchLatest ? this.maxId : null,
credentials: useOAuthStore().token,
})
messages = data
}
// Clear the current chat in case we're recovering from a ws connection loss.
if (isFirstFetch) {
this.clear()
}
const positionBeforeUpdate = getScrollPosition()
this.addMessages({ messages })
await nextTick()
if (isFirstFetch) {
this.scrollDown()
}
const fetchOlderMessages = !!maxId
if (fetchOlderMessages) {
this.handleScrollUp(positionBeforeUpdate)
}
// In vertical screens, the first batch of fetched messages may not always take the
// full height of the scrollable container.
// If this is the case, we want to fetch the messages until the scrollable container
// is fully populated so that the user has the ability to scroll up and load the history.
//
// Conversation fetching doesn't support pagination and spews out everything at once
// so we both can't and don't need to fetch previous posts
if (!this.isConversation && !isScrollable() && messages.length > 0) {
this.fetchChat({
maxId: this.minId,
})
}
},
async startFetching() {
if (!this.isConversation) {
try {
const { data } = await getOrCreateChat({
accountId: this.chatUserId,
credentials: useOAuthStore().token,
})
this.$store.commit('addNewUsers', [data.account])
data.account = this.$store.getters.findUser(data.account.id)
this.chat = data
} catch (e) {
console.error('Error creating or getting a chat', e)
this.errorLoadingChat = true
}
}
if (this.isConversation || this.chat) {
this.$nextTick(() => {
this.scrollDown({ forceRead: true })
})
this.doStartFetching()
}
},
doStartFetching() {
this.fetcher = promiseInterval(
() => this.fetchChat({ fetchLatest: true }),
5000,
)
this.fetchChat({ isFirstFetch: true })
},
addMessages({ messages: newMessages }) {
for (let i = 0; i < newMessages.length; i++) {
const message = newMessages[i]
// Sanity check
if (!this.isConversation && message.chat_id !== this.chat.id) {
console.warn(
`Chat message doesn't belong to current chat (id: ${this.chat.id})!!`,
message,
)
return
}
// Clear any known pending messages
if (message.idempotency_key) {
if (this.pendingMessagesIndex[message.idempotencyKeyIndex]) {
delete this.pendingMessagesIndex[message.idempotencyKeyIndex]
this.pendingMessages = this.pendingMessages.filter(
({ idempotency_key }) =>
idempotency_key !== message.idempotency_key,
)
}
}
if (!this.minId || (!message.pending && message.id < this.minId)) {
this.minId = message.id
}
if (!this.maxId || message.id > this.maxId) {
this.maxId = message.id
}
if (!this.messagesIndex[message.id] && !isConfirmation(this, message)) {
if (this.lastReadMessageId < message.id) {
this.newMessageCount++
}
this.messagesIndex[message.id] = message
this.messages.push(this.messagesIndex[message.id])
this.idempotencyKeyIndex[message.idempotency_key] = true
}
}
},
goBack() {
this.$router.back()
},
// Optimistic posting (chats only)
async sendMessage({ status, media, idempotencyKey }) {
const params = {
id: this.chat.id,
content: status,
idempotencyKey,
}
if (media[0]) {
params.mediaId = media[0].id
}
const fakeMessage = buildFakeMessage({
attachments: media,
chatId: this.chat.id,
content: status,
userId: this.currentUser.id,
idempotencyKey,
})
this.pendingMessages.push(fakeMessage)
this.pendingMessagesIndex[idempotencyKey] = fakeMessage
this.handleAttachmentPosting()
return this.doSendMessage({
params,
retriesLeft: MAX_RETRIES,
})
},
async doSendMessage({ params, retriesLeft = MAX_RETRIES }) {
if (retriesLeft <= 0) return
try {
const { data } = await sendChatMessage({
...params,
credentials: useOAuthStore().token,
})
this.addMessages({
messages: [{ ...data }],
})
} catch (error) {
if (
error.name !== 'StatusCodeError' ||
error.message === 'Failed to fetch'
)
throw error
console.error('Error sending message', error)
this.handleMessageError({
chatId: this.chat.id,
idempotencyKey: params.idempotencyKey,
isRetry: retriesLeft !== MAX_RETRIES,
})
if (
(error.statusCode >= 500 && error.statusCode < 600) ||
error.message === 'Failed to fetch'
) {
this.messageRetriers[params.idempotencyKey] = setTimeout(
() => {
this.doSendMessage({
params,
retriesLeft: retriesLeft - 1,
})
},
1000 * 2 ** (MAX_RETRIES - retriesLeft),
)
}
}
},
handleMessageError(idempotencyKey, isRetry) {
const fakeMessage = this.pendingMessagesIndex[idempotencyKey]
if (fakeMessage) {
fakeMessage.error = true
fakeMessage.pending = false
}
},
// Checks
hasReachedTop() {
return window.scrollY <= 0
},
cullOlderCheck() {
if (this.conversationId) return
window.setTimeout(() => {
if (isBottomedOut(JUMP_TO_BOTTOM_BUTTON_VISIBILITY_OFFSET)) {
this.cullOlder()
}
}, 5000)
},
// Event handlers
onPosted(data) {
this.explicitReplyStatus = null
this.$router.push({
name: 'conversation2',
params: { statusId: data.id },
})
},
handleVisibilityChange() {
this.$nextTick(() => {
if (!document.hidden && isBottomedOut(BOTTOMED_OUT_OFFSET)) {
this.scrollDown({ forceRead: true })
}
})
},
onFilesDropped() {
this.$nextTick(() => {
this.handleResize()
})
},
handleResize(opts = {}) {
// "Sticks" scroll to bottom instead of top, helps with OSK resizing the viewport
const { delayed = false } = opts
if (delayed) {
setTimeout(() => {
this.handleResize({ ...opts, delayed: false })
}, SAFE_RESIZE_TIME_OFFSET)
return
}
this.$nextTick(() => {
const { offsetHeight = undefined } = getScrollPosition()
const diff = offsetHeight - this.lastScrollPosition.offsetHeight
if (diff !== 0 && !isBottomedOut()) {
this.$nextTick(() => {
window.scrollBy({ top: -Math.trunc(diff) })
})
}
this.lastScrollPosition = getScrollPosition()
})
},
handleScroll: throttle(function () {
if (!this.chat) {
return
}
this.lastScrollPosition = getScrollPosition()
if (this.hasReachedTop()) {
this.fetchChat({ maxId: this.minId })
} else if (isBottomedOut(JUMP_TO_BOTTOM_BUTTON_VISIBILITY_OFFSET)) {
this.jumpToBottomButtonVisible = false
this.cullOlderCheck()
if (this.newMessageCount > 0) {
// Use a delay before marking as read to prevent situation where new messages
// arrive just as you're leaving the view and messages that you didn't actually
// get to see get marked as read.
window.setTimeout(() => {
// Don't mark as read if the element doesn't exist, user has left chat view
if (this.$el) this.readChat()
}, MARK_AS_READ_DELAY)
}
} else {
this.jumpToBottomButtonVisible = true
}
}, 200),
handleScrollUp(positionBeforeLoading) {
const positionAfterLoading = getScrollPosition()
window.scrollTo({
top: getNewTopPosition(positionBeforeLoading, positionAfterLoading),
})
},
handleAttachmentPosting() {
this.$nextTick(() => {
this.handleResize()
// When the posting form size changes because of a media attachment, we need an extra resize
// to account for the potential delay in the DOM update.
this.scrollDown({ forceRead: true })
})
},
// Ugly
// TODO move to ChatMessage
async deleteChatMessage({ chatId, messageId }) {
if (!this.testMode)
await deleteChatMessage({
chatId,
messageId,
credentials: useOAuthStore().token,
})
this.messages = this.messages.filter((m) => m.id !== messageId)
delete this.messagesIndex[messageId]
if (this.maxId === messageId) {
const lastMessage = maxBy(this.messages, 'id')
this.maxId = lastMessage.id
}
if (this.minId === messageId) {
const firstMessage = minBy(this.messages, 'id')
this.minId = firstMessage.id
}
},
},
}
export default Chat
diff --git a/src/components/chat_view/chat_view.vue b/src/components/chat_view/chat_view.vue
index e6bef0f84c..06862ab3c1 100644
--- a/src/components/chat_view/chat_view.vue
+++ b/src/components/chat_view/chat_view.vue
@@ -1,124 +1,124 @@
<template>
<div class="chat-view">
<div class="chat-view-inner">
<div
ref="inner"
class="panel-default panel chat-view-body"
>
<div
ref="header"
class="panel-heading -sticky chat-view-heading"
>
<button
class="button-unstyled go-back-button"
@click="goBack"
>
<FAIcon
size="lg"
icon="chevron-left"
/>
</button>
<div class="title">
<template v-if="isConversation">
<RichContent
v-if="messages[0]?.summary_raw_html"
:html="messages[0].summary_raw_html"
:emoji="messages[0].emojis"
- />
+ />
<template v-else>
{{ $t('timeline.conversation') }}
</template>
</template>
<ChatTitle
v-else
:user="recipient"
:with-avatar="true"
/>
</div>
</div>
<div class="chat-list-wrapper panel-body">
<div class="top-spacer" />
<ChatMessageList
ref="messageList"
header-date
:messages="messages"
:pending-messages="pendingMessages"
:replied-id="replyStatus?.id"
:focused-id="statusId"
@message-delete="deleteChatMessage"
@reply-requested="e => explicitReplyStatus = e"
/>
</div>
<div
ref="footer"
class="panel-footer -flexible-height footer"
>
<div
class="jump-to-bottom-button"
:class="{ 'visible': jumpToBottomButtonVisible }"
@click="scrollDown({ behavior: 'smooth' })"
>
<span>
<FAIcon icon="chevron-down" />
<div
v-if="newMessageCount"
class="badge -notification unread-chat-count unread-message-count"
>
{{ newMessageCount }}
</div>
</span>
</div>
<div
v-if="isConversation"
class="auto-reply-to-section"
>
<div class="reply-to-text">
{{ explicitReplyStatus ? $t('status.reply_to_selected') : $t('status.reply_to_last') }}
<button
v-if="explicitReplyStatus"
class="button-default"
@click="explicitReplyStatus = null"
>
<FAIcon icon="times" />
{{ $t('general.cancel') }}
</button>
</div>
</div>
<PostStatusForm
ref="postStatusForm"
:replied-status="replyStatus"
:mentions-line="isConversation"
mentions-line-read-only
disable-quotes
disable-notice
disable-lock-warning
:disable-subject="!isConversation"
:disable-scope-selector="!isConversation"
:disable-polls="!isConversation"
:disable-sensitivity-checkbox="!isConversation"
:disable-preview="!isConversation"
:disable-draft="!isConversation"
:disable-submit="isConversation ? !replyStatus : (errorLoadingChat || !chat)"
:optimistic-posting="!isConversation"
chat-view
preserve-focus
:auto-focus="!mobileLayout"
:placeholder="formPlaceholder"
:file-limit="isConversation ? null : 1"
:max-height="160"
emoji-picker-placement="top"
:post-handler="isConversation ? null : sendMessage"
@resize="handleResize"
@posted="onPosted"
/>
</div>
</div>
</div>
</div>
</template>
<script src="./chat_view.js"></script>
<style src="./chat_view.scss" lang="scss" />
diff --git a/src/components/color_input/color_input.vue b/src/components/color_input/color_input.vue
index d6667c28ff..53396f5327 100644
--- a/src/components/color_input/color_input.vue
+++ b/src/components/color_input/color_input.vue
@@ -1,159 +1,159 @@
<template>
<div
class="color-input style-control"
:class="{ disabled: !present || disabled, '-compact': compact }"
>
<label
:for="name"
class="label"
:class="{ faint: !present || disabled }"
>
{{ label }}
</label>
<Checkbox
- v-if="typeof fallback !== 'undefined' && showOptionalCheckbox && !hideOptionalCheckbox"
+ v-if="fallback !== undefined && showOptionalCheckbox && !hideOptionalCheckbox"
:model-value="present"
:disabled="disabled"
class="opt"
- @update:model-value="updateValue(typeof modelValue === 'undefined' ? fallback : undefined)"
+ @update:model-value="updateValue(modelValue === undefined ? fallback : undefined)"
/>
<div
class="input color-input-field"
:class="{ disabled: !present || disabled, unstyled }"
>
<input
:id="name + '-t'"
class="textColor unstyled"
:class="{ disabled: !present || disabled }"
type="text"
:value="modelValue ?? fallback"
:disabled="!present || disabled"
@input="updateValue($event.target.value)"
>
<div
v-if="validColor"
class="validIndicator"
:style="{backgroundColor: modelValue || fallback}"
/>
<div
v-else-if="transparentColor"
class="transparentIndicator"
/>
<div
v-else-if="computedColor"
class="computedIndicator"
:style="{backgroundColor: fallback}"
/>
<div
v-else
class="invalidIndicator"
/>
<label class="nativeColor">
<FAIcon icon="eye-dropper" />
<input
:id="name"
class="unstyled"
type="color"
:value="modelValue || fallback"
:disabled="!present || disabled"
:class="{ disabled: !present || disabled }"
@input="updateValue($event.target.value)"
>
</label>
</div>
</div>
</template>
<script>
import { throttle } from 'lodash'
import Checkbox from 'src/components/checkbox/checkbox.vue'
import { hex2rgb } from '../../services/color_convert/color_convert.js'
import { library } from '@fortawesome/fontawesome-svg-core'
import { faEyeDropper } from '@fortawesome/free-solid-svg-icons'
library.add(faEyeDropper)
export default {
components: {
Checkbox,
},
props: {
// Name of color, used for identifying
name: {
required: true,
type: String,
},
// Readable label
label: {
required: false,
type: String,
default: '',
},
// use unstyled, uh, style
unstyled: {
required: false,
type: Boolean,
},
// Color value, should be required but vue cannot tell the difference
// between "property missing" and "property set to undefined"
modelValue: {
required: false,
type: String,
default: undefined,
},
// Color fallback to use when value is not defeind
fallback: {
required: false,
type: String,
default: undefined,
},
// Disable the control
disabled: {
required: false,
type: Boolean,
default: false,
},
// Show "optional" tickbox, for when value might become mandatory
showOptionalCheckbox: {
required: false,
type: Boolean,
default: true,
},
// Force "optional" tickbox to hide
hideOptionalCheckbox: {
required: false,
type: Boolean,
default: false,
},
compact: {
required: false,
type: Boolean,
},
},
emits: ['update:modelValue'],
computed: {
present() {
return this.modelValue !== undefined
},
validColor() {
return hex2rgb(this.modelValue || this.fallback)
},
transparentColor() {
return this.modelValue === 'transparent'
},
computedColor() {
return (
this.modelValue &&
(this.modelValue.startsWith('--') || this.modelValue.startsWith('$'))
)
},
},
methods: {
updateValue: throttle(function (value) {
this.$emit('update:modelValue', value)
}, 100),
},
}
</script>
<style lang="scss" src="./color_input.scss"></style>
diff --git a/src/components/conversation/conversation.js b/src/components/conversation/conversation.js
index 0bbf01e6ab..ea56379446 100644
--- a/src/components/conversation/conversation.js
+++ b/src/components/conversation/conversation.js
@@ -1,636 +1,636 @@
import { clone, filter, findIndex, get, reduce } from 'lodash'
import { mapState as mapPiniaState } from 'pinia'
import { mapState } from 'vuex'
import ChatMessageList from 'src/components/chat_message_list/chat_message_list.vue'
import PostStatusForm from 'src/components/post_status_form/post_status_form.vue'
import QuickFilterSettings from 'src/components/quick_filter_settings/quick_filter_settings.vue'
import QuickViewSettings from 'src/components/quick_view_settings/quick_view_settings.vue'
import RichContent from 'src/components/rich_content/rich_content.jsx'
import ThreadTree from 'src/components/thread_tree/thread_tree.vue'
import { useInterfaceStore } from 'src/stores/interface'
import { useMergedConfigStore } from 'src/stores/merged_config.js'
import { useOAuthStore } from 'src/stores/oauth.js'
import { fetchConversation, fetchStatus } from 'src/api/public.js'
import { WSConnectionStatus } from 'src/api/websocket.js'
import { library } from '@fortawesome/fontawesome-svg-core'
import {
faAngleDoubleDown,
faAngleDoubleLeft,
faChevronLeft,
faReply,
faTimes,
} from '@fortawesome/free-solid-svg-icons'
library.add(
faAngleDoubleDown,
faAngleDoubleLeft,
faChevronLeft,
faReply,
faTimes,
)
const sortById = (a, b) => {
const idA = a.type === 'retweet' ? a.retweeted_status.id : a.id
const idB = b.type === 'retweet' ? b.retweeted_status.id : b.id
const seqA = Number(idA)
const seqB = Number(idB)
const isSeqA = !Number.isNaN(seqA)
const isSeqB = !Number.isNaN(seqB)
if (isSeqA && isSeqB) {
return seqA < seqB ? -1 : 1
} else if (isSeqA && !isSeqB) {
return -1
} else if (!isSeqA && isSeqB) {
return 1
} else {
return idA < idB ? -1 : 1
}
}
const sortAndFilterConversation = (conversation, statusoid) => {
if (statusoid.type === 'retweet') {
conversation = filter(
conversation,
(status) =>
status.type === 'retweet' ||
status.id !== statusoid.retweeted_status.id,
)
} else {
conversation = filter(conversation, (status) => status.type !== 'retweet')
}
- return conversation.filter((_) => _).sort(sortById)
+ return conversation.filter(Boolean).sort(sortById)
}
const conversation = {
props: {
statusId: {
// Main thing
type: String,
required: true,
},
collapsable: {
// Whether conversation can be collapsed
// i.e. when it's not a page
type: Boolean,
default: false,
},
isPage: {
// Whether conversation is rendered as a standalone page
// as opposed to embedded into a timeline
type: Boolean,
default: false,
},
pinnedStatusIdsObject: {
// Used for user profile, map of pinned statuses
type: Object,
default: null,
},
inProfile: {
// Whether conversation is rendered in a user profile
// used for overriding muted status
type: Boolean,
default: false,
},
profileUserId: {
// used with inProfile, user id of the profile
type: String,
default: null,
},
virtualHidden: {
// Whether conversation is suspended. Controls rendering of statuses
type: Boolean,
default: false,
},
},
data() {
return {
focused: null,
expanded: false,
threadDisplayStatusObject: {}, // id => 'showing' | 'hidden'
inlineDivePosition: null,
loadStatusError: null,
unsuspendibleIds: new Set(),
}
},
created() {
if (this.isPage) {
this.fetchConversation()
}
},
computed: {
maxDepthToShowByDefault() {
// maxDepthInThread = max number of depths that is *visible*
// since our depth starts with 0 and "showing" means "showing children"
// there is a -2 here
const maxDepth = this.mergedConfig.maxDepthInThread - 2
return maxDepth >= 1 ? maxDepth : 1
},
streamingEnabled() {
return (
this.mergedConfig.useStreamingApi &&
this.mastoUserSocketStatus === WSConnectionStatus.JOINED
)
},
displayStyle() {
return this.mergedConfig.conversationDisplay
},
treeViewIsSimple() {
return !this.mergedConfig.conversationTreeAdvanced
},
isTreeView() {
return this.displayStyle === 'tree'
},
isLinearView() {
return this.displayStyle !== 'tree'
},
shouldFadeAncestors() {
return this.mergedConfig.conversationTreeFadeAncestors
},
otherRepliesButtonPosition() {
return this.mergedConfig.conversationOtherRepliesButton
},
showOtherRepliesButtonBelowStatus() {
return this.otherRepliesButtonPosition === 'below'
},
showOtherRepliesButtonInsideStatus() {
return this.otherRepliesButtonPosition === 'inside'
},
suspendable() {
return this.unsuspendibleIds.size > 0
},
hideStatus() {
return this.virtualHidden && this.suspendable
},
status() {
return this.$store.state.statuses.allStatusesObject[this.statusId]
},
originalStatusId() {
if (this.status.retweeted_status) {
return this.status.retweeted_status.id
} else {
return this.statusId
}
},
conversationId() {
return this.getConversationId(this.statusId)
},
conversation() {
if (!this.status) {
return []
}
if (!this.isExpanded) {
return [this.status]
}
const conversation = clone(
this.$store.state.statuses.conversationsObject[this.conversationId],
)
const statusIndex = findIndex(conversation, { id: this.originalStatusId })
if (statusIndex !== -1) {
conversation[statusIndex] = this.status
}
return sortAndFilterConversation(conversation, this.status)
},
statusMap() {
return this.conversation.reduce((res, s) => {
res[s.id] = s
return res
}, {})
},
threadTree() {
const reverseLookupTable = this.conversation.reduce(
(table, status, index) => {
table[status.id] = index
return table
},
{},
)
const threads = this.conversation.reduce(
(a, cur) => {
const id = cur.id
a.forest[id] = this.getReplies(id).map((s) => s.id)
return a
},
{
forest: {},
},
)
const walk = (forest, topLevel, depth = 0, processed = {}) =>
topLevel
.map((id) => {
if (processed[id]) {
return []
}
processed[id] = true
return [
{
status: this.conversation[reverseLookupTable[id]],
id,
depth,
},
walk(forest, forest[id], depth + 1, processed),
- ].reduce((a, b) => a.concat(b), [])
+ ].flat()
})
- .reduce((a, b) => a.concat(b), [])
+ .flat()
const linearized = walk(
threads.forest,
this.topLevel.map((k) => k.id),
)
return linearized
},
replyIds() {
return this.conversation
.map((k) => k.id)
.reduce((res, id) => {
res[id] = (this.replies[id] || []).map((k) => k.id)
return res
}, {})
},
totalReplyCount() {
const sizes = {}
const subTreeSizeFor = (id) => {
if (sizes[id]) {
return sizes[id]
}
sizes[id] =
1 +
this.replyIds[id]
.map((cid) => subTreeSizeFor(cid))
.reduce((a, b) => a + b, 0)
return sizes[id]
}
this.conversation.map((k) => k.id).map(subTreeSizeFor)
return Object.keys(sizes).reduce((res, id) => {
res[id] = sizes[id] - 1 // exclude itself
return res
}, {})
},
totalReplyDepth() {
const depths = {}
const subTreeDepthFor = (id) => {
if (depths[id]) {
return depths[id]
}
depths[id] =
1 +
this.replyIds[id]
.map((cid) => subTreeDepthFor(cid))
.reduce((a, b) => (a > b ? a : b), 0)
return depths[id]
}
this.conversation.map((k) => k.id).map(subTreeDepthFor)
return Object.keys(depths).reduce((res, id) => {
res[id] = depths[id] - 1 // exclude itself
return res
}, {})
},
depths() {
return this.threadTree.reduce((a, k) => {
a[k.id] = k.depth
return a
}, {})
},
topLevel() {
const topLevel = this.conversation.reduce(
(tl, cur) =>
tl.filter(
(k) =>
- this.getReplies(cur.id)
+ !this.getReplies(cur.id)
.map((v) => v.id)
- .indexOf(k.id) === -1,
+ .includes(k.id),
),
this.conversation,
)
return topLevel
},
otherTopLevelCount() {
return this.topLevel.length - 1
},
showingTopLevel() {
if (this.canDive && this.diveRoot) {
return [this.statusMap[this.diveRoot]]
}
return this.topLevel
},
diveRoot() {
const statusId = this.inlineDivePosition || this.statusId
const isTopLevel = !this.parentOf(statusId)
return isTopLevel ? null : statusId
},
diveDepth() {
return this.canDive && this.diveRoot ? this.depths[this.diveRoot] : 0
},
diveMode() {
return this.canDive && !!this.diveRoot
},
shouldShowAllConversationButton() {
// The "show all conversation" button tells the user that there exist
// other toplevel statuses, so do not show it if there is only a single root
return (
this.isTreeView &&
this.isExpanded &&
this.diveMode &&
this.topLevel.length > 1
)
},
shouldShowAncestors() {
return (
this.isTreeView &&
this.isExpanded &&
this.ancestorsOf(this.diveRoot).length
)
},
replies() {
let i = 1
return reduce(
this.conversation,
(result, { id, in_reply_to_status_id: irid }) => {
if (irid) {
result[irid] = result[irid] || []
result[irid].push({
name: `#${i}`,
id,
})
}
i++
return result
},
{},
)
},
isExpanded() {
return !!(this.expanded || this.isPage)
},
hiddenStyle() {
- const height = (this.status && this.status.virtualHeight) || '120px'
+ const height = this.status?.virtualHeight || '120px'
return this.virtualHidden ? { height } : {}
},
threadDisplayStatus() {
return this.conversation.reduce((a, k) => {
const id = k.id
const depth = this.depths[id]
const status = (() => {
if (this.threadDisplayStatusObject[id]) {
return this.threadDisplayStatusObject[id]
}
if (depth - this.diveDepth <= this.maxDepthToShowByDefault) {
return 'showing'
} else {
return 'hidden'
}
})()
a[id] = status
return a
}, {})
},
canDive() {
return this.isTreeView && this.isExpanded
},
maybeFocused() {
return this.isExpanded ? this.focused : null
},
...mapPiniaState(useMergedConfigStore, ['mergedConfig']),
...mapState({
mastoUserSocketStatus: (state) => state.api.mastoUserSocketStatus,
}),
...mapPiniaState(useInterfaceStore, {
mobileLayout: (store) => store.layoutType === 'mobile',
}),
},
components: {
ThreadTree,
QuickFilterSettings,
QuickViewSettings,
ChatMessageList,
PostStatusForm,
RichContent,
},
watch: {
statusId(newVal, oldVal) {
const newConversationId = this.getConversationId(newVal)
const oldConversationId = this.getConversationId(oldVal)
if (
newConversationId &&
oldConversationId &&
newConversationId === oldConversationId
) {
this.setFocused(this.originalStatusId)
} else {
this.fetchConversation()
}
},
expanded(value) {
if (value) {
this.fetchConversation()
} else {
this.resetDisplayState()
}
},
virtualHidden() {
this.$store.dispatch('setVirtualHeight', {
statusId: this.statusId,
height: `${this.$el.clientHeight}px`,
})
},
},
methods: {
fetchConversation() {
if (this.status) {
fetchConversation({
id: this.statusId,
credentials: useOAuthStore().token,
}).then(({ data: { ancestors, descendants } }) => {
this.$store.dispatch('addNewStatuses', { statuses: ancestors })
this.$store.dispatch('addNewStatuses', { statuses: descendants })
this.setFocused(this.originalStatusId)
})
} else {
this.loadStatusError = null
fetchStatus({
id: this.statusId,
credentials: useOAuthStore().token,
})
.then(({ data: status }) => {
this.$store.dispatch('addNewStatuses', { statuses: [status] })
this.fetchConversation()
})
.catch((error) => {
console.error(error)
this.loadStatusError = error
})
}
},
getReplies(id) {
return this.replies[id] || []
},
setFocused(id) {
if (!id) return
this.focused = id
if (!this.streamingEnabled) {
this.$store.dispatch('fetchStatus', id)
}
this.$store.dispatch('fetchFavsAndRepeats', id)
this.$store.dispatch('fetchEmojiReactionsBy', id)
},
toggleExpanded() {
this.expanded = !this.expanded
},
getConversationId(statusId) {
const status = this.$store.state.statuses.allStatusesObject[statusId]
return get(
status,
'retweeted_status.statusnet_conversation_id',
get(status, 'statusnet_conversation_id'),
)
},
setThreadDisplay(id, nextStatus) {
this.threadDisplayStatusObject = {
...this.threadDisplayStatusObject,
[id]: nextStatus,
}
},
toggleThreadDisplay(id) {
const curStatus = this.threadDisplayStatus[id]
const nextStatus = curStatus === 'showing' ? 'hidden' : 'showing'
this.setThreadDisplay(id, nextStatus)
},
setThreadDisplayRecursively(id, nextStatus) {
this.setThreadDisplay(id, nextStatus)
this.getReplies(id)
.map((k) => k.id)
.map((id) => this.setThreadDisplayRecursively(id, nextStatus))
},
showThreadRecursively(id) {
this.setThreadDisplayRecursively(id, 'showing')
},
leastVisibleAncestor(id) {
let cur = id
let parent = this.parentOf(cur)
while (cur) {
// if the parent is showing it means cur is visible
if (this.threadDisplayStatus[parent] === 'showing') {
return cur
}
parent = this.parentOf(parent)
cur = this.parentOf(cur)
}
// nothing found, fall back to toplevel
return this.topLevel[0] ? this.topLevel[0].id : undefined
},
diveIntoStatus(id) {
this.tryScrollTo(id)
},
diveToTopLevel() {
this.tryScrollTo(
this.topLevelAncestorOrSelfId(this.diveRoot) || this.topLevel[0].id,
)
},
// only used when we are not on a page
undive() {
this.inlineDivePosition = null
this.setFocused(this.statusId)
},
tryScrollTo(id) {
if (!id) {
return
}
if (this.isPage) {
// set statusId
this.$router.push({ name: 'conversation', params: { id } })
} else {
this.inlineDivePosition = id
}
// Because the conversation can be unmounted when out of sight
// and mounted again when it comes into sight,
// the `mounted` or `created` function in `status` should not
// contain scrolling calls, as we do not want the page to jump
// when we scroll with an expanded conversation.
//
// Now the method is to rely solely on the `focused` watcher
// in `status` components.
// In linear views, all statuses are rendered at all times, but
// in tree views, it is possible that a change in active status
// removes and adds status components (e.g. an originally child
// status becomes an ancestor status, and thus they will be
// different).
// Here, let the components be rendered first, in order to trigger
// the `focused` watcher.
this.$nextTick(() => {
this.setFocused(id)
})
},
goToCurrent() {
this.tryScrollTo(this.diveRoot || this.topLevel[0].id)
},
statusById(id) {
return this.statusMap[id]
},
parentOf(id) {
const status = this.statusById(id)
if (!status) {
return undefined
}
const { in_reply_to_status_id: parentId } = status
if (!this.statusMap[parentId]) {
return undefined
}
return parentId
},
parentOrSelf(id) {
return this.parentOf(id) || id
},
// Ancestors of some status, from top to bottom
ancestorsOf(id) {
const ancestors = []
let cur = this.parentOf(id)
while (cur) {
ancestors.unshift(this.statusMap[cur])
cur = this.parentOf(cur)
}
return ancestors
},
topLevelAncestorOrSelfId(id) {
let cur = id
let parent = this.parentOf(id)
while (parent) {
cur = this.parentOf(cur)
parent = this.parentOf(parent)
}
return cur
},
resetDisplayState() {
this.undive()
this.threadDisplayStatusObject = {}
},
onStatusSuspendStateChange({ id, suspend }) {
if (!suspend) {
this.unsuspendibleIds.add(id)
} else {
this.unsuspendibleIds.delete(id)
}
},
onPosted(data) {
if (this.isPage) {
this.$router.push({ name: 'conversation', params: { id: data.id } })
}
},
},
}
export default conversation
diff --git a/src/components/conversation/conversation.vue b/src/components/conversation/conversation.vue
index 170ab41d6f..0833c4f89e 100644
--- a/src/components/conversation/conversation.vue
+++ b/src/components/conversation/conversation.vue
@@ -1,216 +1,216 @@
<template>
<div
v-if="!hideStatus"
:style="hiddenStyle"
class="Conversation"
:class="{ '-expanded' : isExpanded, 'panel' : isExpanded }"
>
<div
v-if="isExpanded"
class="panel-heading conversation-heading -sticky"
>
<h1 class="title">
<RichContent
v-if="conversation[0]?.summary_raw_html"
:html="conversation[0].summary_raw_html"
:emoji="conversation[0].emojis"
- />
+ />
<template v-else>
- {{ $t('timeline.conversation') }}
+ {{ $t('timeline.conversation') }}
</template>
</h1>
<button
v-if="collapsable"
class="button-unstyled -link"
@click.prevent="toggleExpanded"
>
{{ $t('timeline.collapse') }}
</button>
<QuickFilterSettings
v-if="!collapsable && mobileLayout"
:conversation="true"
class="rightside-button"
/>
<QuickViewSettings
v-if="!collapsable"
:conversation="true"
class="rightside-button"
/>
</div>
<div
v-if="isPage && !status"
class="conversation-body"
:class="{ 'panel-body': isExpanded }"
>
<p v-if="!loadStatusError">
<FAIcon
spin
icon="circle-notch"
/>
{{ $t('status.loading') }}
</p>
<p v-else>
{{ $t('status.load_error', { error: loadStatusError }) }}
</p>
</div>
<div
v-else
class="conversation-body"
:class="{ 'panel-body': isExpanded }"
>
<div
v-if="isTreeView"
class="thread-body"
>
<div
v-if="shouldShowAllConversationButton"
class="conversation-dive-to-top-level-box"
>
<i18n-t
keypath="status.show_all_conversation_with_icon"
tag="button"
class="button-unstyled -link"
scope="global"
@click.prevent="diveToTopLevel"
>
<template #icon>
<FAIcon
icon="angle-double-left"
/>
</template>
<template #text>
<span>
{{ $t('status.show_all_conversation', { numStatus: otherTopLevelCount }, otherTopLevelCount) }}
</span>
</template>
</i18n-t>
</div>
<div
v-if="shouldShowAncestors"
class="thread-ancestors"
>
<article
v-for="status in ancestorsOf(diveRoot)"
:key="status.id"
class="thread-ancestor"
:class="{'thread-ancestor-has-other-replies': getReplies(status.id).length > 1, '-faded': shouldFadeAncestors}"
>
<Status
ref="statusComponent"
class="conversation-status status-fadein panel-body"
:statusoid="status"
:replies="getReplies(status.id)"
:expandable="!isExpanded"
:focused="maybeFocused === status.id"
:inline-expanded="collapsable && isExpanded"
:show-pinned="pinnedStatusIdsObject && pinnedStatusIdsObject[status.id]"
:in-profile="inProfile"
:in-conversation="isExpanded"
:profile-user-id="profileUserId"
:simple-tree="treeViewIsSimple"
:show-other-replies-as-button="showOtherRepliesButtonInsideStatus"
can-dive
@goto="setFocused"
@dive="() => diveIntoStatus(status.id)"
@suspendable-state-change="onStatusSuspendStateChange"
/>
<div
v-if="showOtherRepliesButtonBelowStatus && getReplies(status.id).length > 1"
class="thread-ancestor-dive-box"
>
<div
class="thread-ancestor-dive-box-inner"
>
<i18n-t
tag="button"
scope="global"
keypath="status.ancestor_follow_with_icon"
class="button-unstyled -link thread-tree-show-replies-button"
@click.prevent="diveIntoStatus(status.id)"
>
<template #icon>
<FAIcon
icon="angle-double-right"
/>
</template>
<template #text>
<span>
{{ $t('status.ancestor_follow', { numReplies: getReplies(status.id, getReplies(status.id).length - 1).length - 1 }) }}
</span>
</template>
</i18n-t>
</div>
</div>
</article>
</div>
<ThreadTree
v-for="status in showingTopLevel"
:key="status.id"
ref="statusComponent"
:depth="0"
:status="status"
:in-profile="inProfile"
:conversation="conversation"
:collapsable="collapsable"
:is-expanded="isExpanded"
:pinned-status-ids-object="pinnedStatusIdsObject"
:profile-user-id="profileUserId"
:get-replies="getReplies"
:focused="maybeFocused"
:toggle-expanded="toggleExpanded"
:simple="treeViewIsSimple"
:thread-display-status="threadDisplayStatus"
:show-thread-recursively="showThreadRecursively"
:total-reply-count="totalReplyCount"
:total-reply-depth="totalReplyDepth"
:can-dive="canDive"
@goto="setFocused"
@dive="diveIntoStatus"
@suspendable-state-change="onStatusSuspendStateChange"
/>
</div>
<div
v-else-if="isLinearView"
class="thread-body"
>
<article>
<Status
v-for="status in conversation"
:key="status.id"
ref="statusComponent"
class="conversation-status status-fadein panel-body"
:statusoid="status"
:replies="getReplies(status.id)"
:expandable="!isExpanded"
:focused="maybeFocused === status.id || maybeFocused === status.retweeted_status?.id"
:inline-expanded="collapsable && isExpanded"
:show-pinned="pinnedStatusIdsObject && pinnedStatusIdsObject[status.id]"
:in-profile="inProfile"
:in-conversation="isExpanded"
:profile-user-id="profileUserId"
@goto="setFocused"
@toggle-expanded="toggleExpanded"
@suspendable-state-change="onStatusSuspendStateChange"
/>
</article>
</div>
</div>
</div>
<div
v-else
class="Conversation -hidden"
:style="hiddenStyle"
/>
</template>
<script src="./conversation.js"></script>
<style src="./conversation.scss" />
diff --git a/src/components/desktop_nav/desktop_nav.js b/src/components/desktop_nav/desktop_nav.js
index fb23299d21..cc2c5aca91 100644
--- a/src/components/desktop_nav/desktop_nav.js
+++ b/src/components/desktop_nav/desktop_nav.js
@@ -1,129 +1,128 @@
import SearchBar from 'components/search_bar/search_bar.vue'
import { mapActions, mapState } from 'pinia'
import { defineAsyncComponent } from 'vue'
import { useInstanceStore } from 'src/stores/instance.js'
import { useInterfaceStore } from 'src/stores/interface'
import { useMergedConfigStore } from 'src/stores/merged_config.js'
import { library } from '@fortawesome/fontawesome-svg-core'
import {
faBell,
faBullhorn,
faCog,
faComments,
faHome,
faInfoCircle,
faSearch,
faSignInAlt,
faSignOutAlt,
faTachometerAlt,
faUserPlus,
} from '@fortawesome/free-solid-svg-icons'
library.add(
faSignInAlt,
faSignOutAlt,
faHome,
faComments,
faBell,
faUserPlus,
faBullhorn,
faSearch,
faTachometerAlt,
faCog,
faInfoCircle,
)
export default {
components: {
SearchBar,
ConfirmModal: defineAsyncComponent(
() => import('src/components/confirm_modal/confirm_modal.vue'),
),
},
data: () => ({
searchBarHidden: true,
supportsMask:
- window.CSS &&
- window.CSS.supports &&
+ window.CSS?.supports &&
(window.CSS.supports('mask-size', 'contain') ||
window.CSS.supports('-webkit-mask-size', 'contain') ||
window.CSS.supports('-moz-mask-size', 'contain') ||
window.CSS.supports('-ms-mask-size', 'contain') ||
window.CSS.supports('-o-mask-size', 'contain')),
showingConfirmLogout: false,
}),
computed: {
enableMask() {
return this.supportsMask && this.logoMask
},
logoStyle() {
return {
visibility: this.enableMask ? 'hidden' : 'visible',
}
},
logoMaskStyle() {
return this.enableMask
? {
'mask-image': `url(${this.logo})`,
}
: {
'background-color': this.enableMask ? '' : 'transparent',
}
},
logoBgStyle() {
const mask = this.enableMask
? {}
: { 'background-color': this.enableMask ? '' : 'transparent' }
return {
margin: `${this.logoMargin} 0`,
opacity: this.searchBarHidden ? 1 : 0,
...mask,
}
},
...mapState(useInstanceStore, ['privateMode']),
...mapState(useInstanceStore, {
logoMask: (store) => store.instanceIdentity.logoMask,
logo: (store) => store.instanceIdentity.logo,
logoLeft: (store) => store.instanceIdentity.logoLeft,
logoMargin: (store) => store.instanceIdentity.logoMargin,
sitename: (store) => store.instanceIdentity.name,
hideSitename: (store) => store.instanceIdentity.hideSitename,
}),
currentUser() {
return this.$store.state.users.currentUser
},
shouldConfirmLogout() {
return useMergedConfigStore().mergedConfig.modalOnLogout
},
},
methods: {
scrollToTop() {
window.scrollTo(0, 0)
},
showConfirmLogout() {
this.showingConfirmLogout = true
},
hideConfirmLogout() {
this.showingConfirmLogout = false
},
logout() {
if (!this.shouldConfirmLogout) {
this.doLogout()
} else {
this.showConfirmLogout()
}
},
doLogout() {
this.$router.replace('/main/public')
this.$store.dispatch('logout')
this.hideConfirmLogout()
},
onSearchBarToggled(hidden) {
this.searchBarHidden = hidden
},
...mapActions(useInterfaceStore, ['openSettingsModal']),
},
}
diff --git a/src/components/edit_status_modal/edit_status_modal.js b/src/components/edit_status_modal/edit_status_modal.js
index c3ba7e4cb3..59c142d081 100644
--- a/src/components/edit_status_modal/edit_status_modal.js
+++ b/src/components/edit_status_modal/edit_status_modal.js
@@ -1,61 +1,59 @@
import { get } from 'lodash'
import { defineAsyncComponent } from 'vue'
import Modal from 'src/components/modal/modal.vue'
import { useEditStatusStore } from 'src/stores/editStatus.js'
const EditStatusModal = {
components: {
EditStatusForm: defineAsyncComponent(
() => import('src/components/edit_status_form/edit_status_form.vue'),
),
Modal,
},
data() {
return {
resettingForm: false,
}
},
computed: {
isLoggedIn() {
return !!this.$store.state.users.currentUser
},
modalActivated() {
return useEditStatusStore().modalActivated
},
isFormVisible() {
return this.isLoggedIn && !this.resettingForm && this.modalActivated
},
params() {
return useEditStatusStore().params || {}
},
},
watch: {
params(newVal, oldVal) {
if (get(newVal, 'statusId') !== get(oldVal, 'statusId')) {
this.resettingForm = true
this.$nextTick(() => {
this.resettingForm = false
})
}
},
isFormVisible(val) {
if (val) {
- this.$nextTick(
- () => this.$el && this.$el.querySelector('textarea').focus(),
- )
+ this.$nextTick(() => this.$el?.querySelector('textarea').focus())
}
},
},
methods: {
closeModal() {
this.$refs.editStatusForm.requestClose()
},
doCloseModal() {
useEditStatusStore().closeEditStatusModal()
},
},
}
export default EditStatusModal
diff --git a/src/components/emoji_input/emoji_input.js b/src/components/emoji_input/emoji_input.js
index 0cce2494d3..d8cc7a9196 100644
--- a/src/components/emoji_input/emoji_input.js
+++ b/src/components/emoji_input/emoji_input.js
@@ -1,605 +1,605 @@
import { take } from 'lodash'
import Popover from 'src/components/popover/popover.vue'
import ScreenReaderNotice from 'src/components/screen_reader_notice/screen_reader_notice.vue'
import { ensureFinalFallback } from '../../i18n/languages.js'
import Completion from '../../services/completion/completion.js'
import { findOffset } from '../../services/offset_finder/offset_finder.service.js'
import genRandomSeed from '../../services/random_seed/random_seed.service.js'
import EmojiPicker from '../emoji_picker/emoji_picker.vue'
import UnicodeDomainIndicator from '../unicode_domain_indicator/unicode_domain_indicator.vue'
import { useMergedConfigStore } from 'src/stores/merged_config.js'
import { library } from '@fortawesome/fontawesome-svg-core'
import { faSmileBeam } from '@fortawesome/free-regular-svg-icons'
library.add(faSmileBeam)
/**
* EmojiInput - augmented inputs for emoji and autocomplete support in inputs
* without having to give up the comfort of <input/> and <textarea/> elements
*
* Intended usage is:
* <EmojiInput v-model="something">
* <input v-model="something"/>
* </EmojiInput>
*
* Works only with <input> and <textarea>. Intended to use with only one nested
* input. It will find first input or textarea and work with that, multiple
* nested children not tested. You HAVE TO duplicate v-model for both
* <emoji-input> and <input>/<textarea> otherwise it will not work.
*
* Be prepared for CSS troubles though because it still wraps component in a div
* while TRYING to make it look like nothing happened, but it could break stuff.
*/
const EmojiInput = {
emits: ['update:modelValue', 'shown'],
props: {
suggest: {
/**
* suggest: function (input: String) => Suggestion[]
*
* Function that takes input string which takes string (textAtCaret)
* and returns an array of Suggestions
*
* Suggestion is an object containing following properties:
* displayText: string. Main display text, what actual suggestion
* represents (user's screen name/emoji shortcode)
* replacement: string. Text that should replace the textAtCaret
* detailText: string, optional. Subtitle text, providing additional info
* if present (user's nickname)
* imageUrl: string, optional. Image to display alongside with suggestion,
* currently if no image is provided, replacement will be used (for
* unicode emojis)
*
* TODO: make it asynchronous when adding proper server-provided user
* suggestions
*
* For commonly used suggestors (emoji, users, both) use suggestor.js
*/
required: true,
type: Function,
},
modelValue: {
/**
* Used for v-model
*/
required: true,
type: String,
},
enableEmojiPicker: {
/**
* Enables emoji picker support, this implies that custom emoji are supported
*/
required: false,
type: Boolean,
default: false,
},
hideEmojiButton: {
/**
* intended to use with external picker trigger, i.e. you have a button outside
* input that will open up the picker, see triggerShowPicker()
*/
required: false,
type: Boolean,
default: false,
},
enableStickerPicker: {
/**
* Enables sticker picker support, only makes sense when enableEmojiPicker=true
*/
required: false,
type: Boolean,
default: false,
},
placement: {
/**
* Forces the panel to take a specific position relative to the input element.
* The 'auto' placement chooses either bottom or top depending on which has the available space (when both have available space, bottom is preferred).
*/
required: false,
type: String, // 'auto', 'top', 'bottom'
default: 'auto',
},
newlineOnCtrlEnter: {
required: false,
type: Boolean,
default: false,
},
},
data() {
return {
randomSeed: genRandomSeed(),
input: undefined,
caretEl: undefined,
highlighted: -1,
caret: 0,
focused: false,
blurTimeout: null,
temporarilyHideSuggestions: false,
disableClickOutside: false,
suggestions: [],
overlayStyle: {},
pickerShown: false,
}
},
components: {
Popover,
EmojiPicker,
UnicodeDomainIndicator,
ScreenReaderNotice,
},
computed: {
padEmoji() {
return useMergedConfigStore().mergedConfig.padEmoji
},
defaultCandidateIndex() {
return useMergedConfigStore().mergedConfig.autocompleteSelect ? 0 : -1
},
preText() {
return this.modelValue.slice(0, this.caret)
},
postText() {
return this.modelValue.slice(this.caret)
},
showSuggestions() {
return (
this.focused &&
this.suggestions &&
this.suggestions.length > 0 &&
!this.pickerShown &&
!this.temporarilyHideSuggestions
)
},
textAtCaret() {
return this.wordAtCaret?.word
},
wordAtCaret() {
if (this.modelValue && this.caret) {
const word =
Completion.wordAtPosition(this.modelValue, this.caret - 1) || {}
return word
}
},
languages() {
return ensureFinalFallback(
useMergedConfigStore().mergedConfig.interfaceLanguage,
)
},
maybeLocalizedEmojiNamesAndKeywords() {
return (emoji) => {
const names = [emoji.displayText]
const keywords = []
if (emoji.displayTextI18n) {
names.push(
this.$t(emoji.displayTextI18n.key, emoji.displayTextI18n.args),
)
}
if (emoji.annotations) {
this.languages.forEach((lang) => {
names.push(emoji.annotations[lang]?.name)
keywords.push(...(emoji.annotations[lang]?.keywords || []))
})
}
return {
- names: names.filter((k) => k),
- keywords: keywords.filter((k) => k),
+ names: names.filter(Boolean),
+ keywords: keywords.filter(Boolean),
}
}
},
maybeLocalizedEmojiName() {
return (emoji) => {
if (!emoji.annotations) {
return emoji.displayText
}
if (emoji.displayTextI18n) {
return this.$t(emoji.displayTextI18n.key, emoji.displayTextI18n.args)
}
for (const lang of this.languages) {
if (emoji.annotations[lang]?.name) {
return emoji.annotations[lang].name
}
}
return emoji.displayText
}
},
suggestionListId() {
return `suggestions-${this.randomSeed}`
},
suggestionItemId() {
return (index) => `suggestion-item-${index}-${this.randomSeed}`
},
},
mounted() {
const { root, hiddenOverlayCaret, suggestorPopover } = this.$refs
const input =
root.querySelector('.emoji-input > input') ||
root.querySelector('.emoji-input > textarea')
if (!input) return
this.input = input
this.caretEl = hiddenOverlayCaret
if (suggestorPopover.setAnchorEl) {
suggestorPopover.setAnchorEl(this.caretEl) // unit test compat
this.$refs.picker.setAnchorEl(this.caretEl)
} else {
console.warn('setAnchorEl not found, are we in a unit test?')
}
const style = getComputedStyle(this.input)
this.overlayStyle.padding = style.padding
this.overlayStyle.border = style.border
this.overlayStyle.margin = style.margin
this.overlayStyle.lineHeight = style.lineHeight
this.overlayStyle.fontFamily = style.fontFamily
this.overlayStyle.fontSize = style.fontSize
this.overlayStyle.wordWrap = style.wordWrap
this.overlayStyle.whiteSpace = style.whiteSpace
input.addEventListener('blur', this.onBlur)
input.addEventListener('focus', this.onFocus)
input.addEventListener('paste', this.onPaste)
input.addEventListener('keyup', this.onKeyUp)
input.addEventListener('keydown', this.onKeyDown)
input.addEventListener('click', this.onClickInput)
input.addEventListener('transitionend', this.onTransition)
input.addEventListener('input', this.onInput)
input.addEventListener('scroll', this.onInputScroll)
},
unmounted() {
const { input } = this
if (input) {
input.removeEventListener('blur', this.onBlur)
input.removeEventListener('focus', this.onFocus)
input.removeEventListener('paste', this.onPaste)
input.removeEventListener('keyup', this.onKeyUp)
input.removeEventListener('keydown', this.onKeyDown)
input.removeEventListener('click', this.onClickInput)
input.removeEventListener('transitionend', this.onTransition)
input.removeEventListener('input', this.onInput)
input.removeEventListener('scroll', this.onInputScroll)
}
},
watch: {
showSuggestions: function (newValue) {
this.$emit('shown', newValue)
if (newValue) {
this.$refs.suggestorPopover.showPopover()
} else {
this.$refs.suggestorPopover.hidePopover()
}
},
textAtCaret: async function (newWord) {
if (newWord === undefined) return
const firstchar = newWord.charAt(0)
if (newWord === firstchar) {
this.suggestions = []
return
}
const matchedSuggestions = await this.suggest(
newWord,
this.maybeLocalizedEmojiNamesAndKeywords,
)
// Async: cancel if textAtCaret has changed during wait
if (this.textAtCaret !== newWord || matchedSuggestions.length <= 0) {
this.suggestions = []
return
}
this.suggestions = take(matchedSuggestions, 5).map(
({ imageUrl, ...rest }) => ({
...rest,
img: imageUrl || '',
}),
)
this.highlighted = this.defaultCandidateIndex
this.$refs.screenReaderNotice.announce(
this.$t(
'tool_tip.autocomplete_available',
{ number: this.suggestions.length },
this.suggestions.length,
),
)
},
},
methods: {
onInputScroll(e) {
this.$refs.hiddenOverlay.scrollTo({
top: this.input.scrollTop,
left: this.input.scrollLeft,
})
this.setCaret(e)
},
triggerShowPicker() {
this.$nextTick(() => {
this.$refs.picker.showPicker()
this.scrollIntoView()
})
// This temporarily disables "click outside" handler
// since external trigger also means click originates
// from outside, thus preventing picker from opening
this.disableClickOutside = true
setTimeout(() => {
this.disableClickOutside = false
}, 0)
},
togglePicker() {
this.input.focus()
if (!this.pickerShown) {
this.scrollIntoView()
this.$refs.picker.showPicker()
} else {
this.$refs.picker.hidePicker()
}
},
replace(replacement) {
const newValue = Completion.replaceWord(
this.modelValue,
this.wordAtCaret,
replacement,
)
this.$emit('update:modelValue', newValue)
this.caret = 0
},
insert({ insertion, keepOpen, surroundingSpace = true }) {
const before = this.modelValue.substring(0, this.caret) || ''
const after = this.modelValue.substring(this.caret) || ''
/* Using a bit more smart approach to padding emojis with spaces:
* - put a space before cursor if there isn't one already, unless we
* are at the beginning of post or in spam mode
* - put a space after emoji if there isn't one already unless we are
* in spam mode
*
* The idea is that when you put a cursor somewhere in between sentence
* inserting just ' :emoji: ' will add more spaces to post which might
* break the flow/spacing, as well as the case where user ends sentence
* with a space before adding emoji.
*
* Spam mode is intended for creating multi-part emojis and overall spamming
* them, masto seem to be rendering :emoji::emoji: correctly now so why not
*/
const isSpaceRegex = /\s/
const spaceBefore =
surroundingSpace &&
!isSpaceRegex.exec(before.slice(-1)) &&
before.length &&
this.padEmoji > 0
? ' '
: ''
const spaceAfter =
surroundingSpace && !isSpaceRegex.exec(after[0]) && this.padEmoji
? ' '
: ''
const newValue = [before, spaceBefore, insertion, spaceAfter, after].join(
'',
)
this.$emit('update:modelValue', newValue)
const position =
this.caret + (insertion + spaceAfter + spaceBefore).length
if (!keepOpen) {
this.input.focus()
}
this.$nextTick(function () {
// Re-focus inputbox after clicking suggestion
// Set selection right after the replacement instead of the very end
this.input.setSelectionRange(position, position)
this.caret = position
})
},
replaceText(e, suggestion) {
const len = this.suggestions.length || 0
if (this.textAtCaret.length === 1) {
return
}
if (len > 0 || suggestion) {
const chosenSuggestion =
suggestion || this.suggestions[this.highlighted]
const replacement = chosenSuggestion.replacement
const newValue = Completion.replaceWord(
this.modelValue,
this.wordAtCaret,
replacement,
)
this.$emit('update:modelValue', newValue)
this.highlighted = 0
const position = this.wordAtCaret.start + replacement.length
this.$nextTick(function () {
// Re-focus inputbox after clicking suggestion
this.input.focus()
// Set selection right after the replacement instead of the very end
this.input.setSelectionRange(position, position)
this.caret = position
})
e.preventDefault()
}
},
cycleBackward(e) {
const len = this.suggestions.length || 0
this.highlighted -= 1
if (this.highlighted === -1) {
this.input.focus()
} else if (this.highlighted < -1) {
this.highlighted = len - 1
}
if (len > 0) {
e.preventDefault()
}
},
cycleForward(e) {
const len = this.suggestions.length || 0
this.highlighted += 1
if (this.highlighted >= len) {
this.highlighted = -1
this.input.focus()
}
if (len > 0) {
e.preventDefault()
}
},
scrollIntoView() {
const rootRef = this.$refs.picker.$el
/* Scroller is either `window` (replies in TL), sidebar (main post form,
* replies in notifs) or mobile post form. Note that getting and setting
* scroll is different for `Window` and `Element`s
*/
const scrollerRef =
this.$el.closest('.sidebar-scroller') ||
this.$el.closest('.post-form-modal-view') ||
window
const currentScroll =
scrollerRef === window ? scrollerRef.scrollY : scrollerRef.scrollTop
const scrollerHeight =
scrollerRef === window
? scrollerRef.innerHeight
: scrollerRef.offsetHeight
const scrollerBottomBorder = currentScroll + scrollerHeight
// We check where the bottom border of root element is, this uses findOffset
// to find offset relative to scrollable container (scroller)
const rootBottomBorder =
rootRef.offsetHeight + findOffset(rootRef, scrollerRef).top
const bottomDelta = Math.max(0, rootBottomBorder - scrollerBottomBorder)
// could also check top delta but there's no case for it
const targetScroll = currentScroll + bottomDelta
if (scrollerRef === window) {
scrollerRef.scroll(0, targetScroll)
} else {
scrollerRef.scrollTop = targetScroll
}
this.$nextTick(() => {
const { offsetHeight } = this.input
const { picker } = this.$refs
const pickerBottom = picker.$el.getBoundingClientRect().bottom
if (pickerBottom > window.innerHeight) {
picker.$el.style.top = 'auto'
picker.$el.style.bottom = offsetHeight + 'px'
}
})
},
onPickerShown() {
this.pickerShown = true
},
onPickerClosed() {
this.pickerShown = false
},
onBlur(e) {
// Clicking on any suggestion removes focus from autocomplete,
// preventing click handler ever executing.
this.blurTimeout = setTimeout(() => {
this.focused = false
this.setCaret(e)
}, 200)
},
onClick(e, suggestion) {
this.replaceText(e, suggestion)
},
onFocus(e) {
if (this.blurTimeout) {
clearTimeout(this.blurTimeout)
this.blurTimeout = null
}
this.focused = true
this.setCaret(e)
this.temporarilyHideSuggestions = false
},
onKeyUp(e) {
const { key } = e
this.setCaret(e)
// Setting hider in keyUp to prevent suggestions from blinking
// when moving away from suggested spot
if (key === 'Escape') {
this.temporarilyHideSuggestions = true
} else {
this.temporarilyHideSuggestions = false
}
},
onPaste(e) {
this.setCaret(e)
},
onKeyDown(e) {
const { ctrlKey, shiftKey, key } = e
if (this.newlineOnCtrlEnter && ctrlKey && key === 'Enter') {
this.insert({ insertion: '\n', surroundingSpace: false })
// Ensure only one new line is added on macos
e.stopPropagation()
e.preventDefault()
// Scroll the input element to the position of the cursor
this.$nextTick(() => {
this.input.blur()
this.input.focus()
})
}
// Disable suggestions hotkeys if suggestions are hidden
if (!this.temporarilyHideSuggestions) {
if (key === 'Tab') {
if (shiftKey) {
this.cycleBackward(e)
} else {
this.cycleForward(e)
}
}
if (key === 'ArrowUp') {
this.cycleBackward(e)
} else if (key === 'ArrowDown') {
this.cycleForward(e)
}
if (key === 'Enter') {
if (!ctrlKey) {
this.replaceText(e)
}
}
}
// Probably add optional keyboard controls for emoji picker?
// Escape hides suggestions, if suggestions are hidden it
// de-focuses the element (i.e. default browser behavior)
if (key === 'Escape') {
if (!this.temporarilyHideSuggestions) {
this.input.focus()
}
}
},
onInput(e) {
this.setCaret(e)
this.$emit('update:modelValue', e.target.value)
},
onStickerUploaded(e) {
this.$emit('sticker-uploaded', e)
},
onStickerUploadFailed(e) {
this.$emit('sticker-upload-Failed', e)
},
setCaret({ target: { selectionStart } }) {
this.caret = selectionStart
this.$nextTick(() => {
this.$refs.suggestorPopover?.updateStyles()
})
},
autoCompleteItemLabel(suggestion) {
if (suggestion.user) {
return suggestion.displayText + ' ' + suggestion.detailText
} else {
return this.maybeLocalizedEmojiName(suggestion)
}
},
},
}
export default EmojiInput
diff --git a/src/components/emoji_input/suggestor.js b/src/components/emoji_input/suggestor.js
index d5e83ecb2d..c31cb6717b 100644
--- a/src/components/emoji_input/suggestor.js
+++ b/src/components/emoji_input/suggestor.js
@@ -1,149 +1,149 @@
/**
* suggest - generates a suggestor function to be used by emoji-input
* data: object providing source information for specific types of suggestions:
* data.emoji - optional, an array of all emoji available i.e.
* (useEmojiStore().standardEmojiList + state.instance.customEmoji)
* data.users - optional, an array of all known users
* updateUsersList - optional, a function to search and append to users
*
* Depending on data present one or both (or none) can be present, so if field
* doesn't support user linking you can just provide only emoji.
*/
export default (data) => {
const emojiCurry = suggestEmoji(data.emoji)
const usersCurry = data.store && suggestUsers(data.store)
return (input, nameKeywordLocalizer) => {
const firstChar = input[0]
if (firstChar === ':' && data.emoji) {
return emojiCurry(input, nameKeywordLocalizer)
}
if (firstChar === '@' && usersCurry) {
return usersCurry(input)
}
return []
}
}
export const suggestEmoji = (emojis) => (input, nameKeywordLocalizer) => {
const noPrefix = input.toLowerCase().substring(1)
return emojis
.map((emoji) => ({ ...emoji, ...nameKeywordLocalizer(emoji) }))
.filter(
(emoji) =>
emoji.names
.concat(emoji.keywords)
.filter((kw) => kw.toLowerCase().includes(noPrefix)).length,
)
.map((k) => {
let score = 0
// An exact match always wins
score += Math.max(
...k.names.map((name) => (name.toLowerCase() === noPrefix ? 200 : 0)),
0,
)
// Prioritize custom emoji a lot
score += k.imageUrl ? 100 : 0
// Prioritize prefix matches somewhat
score += Math.max(
...k.names.map((kw) =>
kw.toLowerCase().startsWith(noPrefix) ? 10 : 0,
),
0,
)
// Sort by length
score -= k.displayText.length
k.score = score
return k
})
.sort((a, b) => {
// Break ties alphabetically
const alphabetically = a.displayText > b.displayText ? 0.5 : -0.5
return b.score - a.score + alphabetically
})
}
export const suggestUsers = ({ dispatch, state }) => {
// Keep some persistent values in closure, most importantly for the
// custom debounce to work. Lodash debounce does not return a promise.
let suggestions = []
let previousQuery = ''
let timeout = null
let cancelUserSearch = null
const userSearch = (query) => dispatch('searchUsers', { query })
const debounceUserSearch = (query) => {
- cancelUserSearch && cancelUserSearch()
+ cancelUserSearch?.()
return new Promise((resolve, reject) => {
timeout = setTimeout(() => {
userSearch(query).then(resolve).catch(reject)
}, 300)
cancelUserSearch = () => {
clearTimeout(timeout)
resolve([])
}
})
}
return async (input) => {
const noPrefix = input.toLowerCase().substr(1)
if (previousQuery === noPrefix) return suggestions
suggestions = []
previousQuery = noPrefix
// Fetch more and wait, don't fetch if there's the 2nd @ because
// the backend user search can't deal with it.
// Reference semantics make it so that we get the updated data after
// the await.
if (!noPrefix.includes('@')) {
await debounceUserSearch(noPrefix)
}
const newSuggestions = state.users.users
.filter(
(user) =>
user.screen_name &&
user.name &&
(user.screen_name.toLowerCase().startsWith(noPrefix) ||
user.name.toLowerCase().startsWith(noPrefix)),
)
.slice(0, 20)
.sort((a, b) => {
let aScore = 0
let bScore = 0
// Matches on screen name (i.e. user@instance) makes a priority
aScore += a.screen_name.toLowerCase().startsWith(noPrefix) ? 2 : 0
bScore += b.screen_name.toLowerCase().startsWith(noPrefix) ? 2 : 0
// Matches on name takes second priority
aScore += a.name.toLowerCase().startsWith(noPrefix) ? 1 : 0
bScore += b.name.toLowerCase().startsWith(noPrefix) ? 1 : 0
const diff = (bScore - aScore) * 10
// Then sort alphabetically
const activity = a.last_status_at < b.last_status_at ? 100 : -100
const nameAlphabetically = a.name > b.name ? 1 : -1
const screenNameAlphabetically = a.screen_name > b.screen_name ? 1 : -1
return diff + nameAlphabetically + screenNameAlphabetically + activity
})
.map((user) => ({
user,
displayText: user.screen_name_ui,
detailText: user.name,
imageUrl: user.profile_image_url_original,
replacement: '@' + user.screen_name + ' ',
}))
suggestions = newSuggestions || []
return suggestions
}
}
diff --git a/src/components/emoji_picker/emoji_picker.js b/src/components/emoji_picker/emoji_picker.js
index 6889fb8b05..a0e30bf6de 100644
--- a/src/components/emoji_picker/emoji_picker.js
+++ b/src/components/emoji_picker/emoji_picker.js
@@ -1,443 +1,443 @@
import { chunk, debounce, trim } from 'lodash'
import { defineAsyncComponent } from 'vue'
import Checkbox from 'src/components/checkbox/checkbox.vue'
import Popover from 'src/components/popover/popover.vue'
import { ensureFinalFallback } from '../../i18n/languages.js'
import { useEmojiStore } from 'src/stores/emoji.js'
import { useMergedConfigStore } from 'src/stores/merged_config.js'
import { library } from '@fortawesome/fontawesome-svg-core'
import {
faBasketballBall,
faBoxOpen,
faBus,
faCode,
faFlag,
faIceCream,
faLightbulb,
faPaw,
faSmile,
faSmileBeam,
faStickyNote,
faUser,
} from '@fortawesome/free-solid-svg-icons'
library.add(
faBoxOpen,
faStickyNote,
faSmileBeam,
faSmile,
faUser,
faPaw,
faIceCream,
faBus,
faBasketballBall,
faLightbulb,
faCode,
faFlag,
)
const UNICODE_EMOJI_GROUP_ICON = {
'smileys-and-emotion': 'smile',
'people-and-body': 'user',
'animals-and-nature': 'paw',
'food-and-drink': 'ice-cream',
'travel-and-places': 'bus',
activities: 'basketball-ball',
objects: 'lightbulb',
symbols: 'code',
flags: 'flag',
}
const maybeLocalizedKeywords = (emoji, languages, nameLocalizer) => {
const res = [emoji.displayText, nameLocalizer(emoji)]
if (emoji.annotations) {
languages.forEach((lang) => {
const keywords = emoji.annotations[lang]?.keywords || []
const name = emoji.annotations[lang]?.name
- res.push(...keywords.concat([name]).filter((k) => k))
+ res.push(...keywords.concat([name]).filter(Boolean))
})
}
return res
}
const filterByKeyword = (list, keyword = '', languages, nameLocalizer) => {
if (keyword === '') return list
const keywordLowercase = keyword.toLowerCase()
const orderedEmojiList = []
for (const emoji of list) {
const indices = maybeLocalizedKeywords(emoji, languages, nameLocalizer)
.map((k) => k.toLowerCase().indexOf(keywordLowercase))
.filter((k) => k > -1)
const indexOfKeyword = indices.length ? Math.min(...indices) : -1
if (indexOfKeyword > -1) {
if (!Array.isArray(orderedEmojiList[indexOfKeyword])) {
orderedEmojiList[indexOfKeyword] = []
}
orderedEmojiList[indexOfKeyword].push(emoji)
}
}
return orderedEmojiList.flat()
}
const getOffset = (elem) => {
const style = elem.style.transform
const res = /translateY\((\d+)px\)/.exec(style)
if (!res) {
return 0
}
return res[1]
}
const toHeaderId = (id) => {
return id.replace(/^row-\d+-/, '')
}
const EmojiPicker = {
props: {
enableStickerPicker: {
required: false,
type: Boolean,
default: true,
},
hideCustomEmoji: {
required: false,
type: Boolean,
default: false,
},
},
inject: {
popoversZLayer: {
default: '',
},
},
data() {
return {
keyword: '',
activeGroup: 'custom',
showingStickers: false,
groupsScrolledClass: 'scrolled-top',
keepOpen: false,
customEmojiTimeout: null,
hideCustomEmojiInPicker: false,
// Lazy-load only after the first time `showing` becomes true.
contentLoaded: false,
popoverShown: false,
groupRefs: {},
emojiRefs: {},
filteredEmojiGroups: [],
emojiSize: 0,
width: 0,
}
},
components: {
StickerPicker: defineAsyncComponent(
() => import('src/components/sticker_picker/sticker_picker.vue'),
),
Checkbox,
Popover,
},
methods: {
groupScroll(e) {
e.currentTarget.scrollLeft += e.deltaY + e.deltaX
},
updateEmojiSize() {
const css = window.getComputedStyle(this.$refs.popover.$el)
const fontSize = css.getPropertyValue('font-size') || '1rem'
const emojiSize = css.getPropertyValue('--emojiSize') || '2.2rem'
const fontSizeUnit = fontSize.replace(/[0-9,.]+/, '').trim()
const fontSizeValue = Number(fontSize.replace(/[^0-9,.]+/, ''))
const emojiSizeUnit = emojiSize.replace(/[0-9,.]+/, '').trim()
const emojiSizeValue = Number(emojiSize.replace(/[^0-9,.]+/, ''))
let fontSizeMultiplier
if (fontSizeUnit.endsWith('em')) {
fontSizeMultiplier = fontSizeValue
} else {
fontSizeMultiplier = fontSizeValue / 14
}
let emojiSizeReal
if (emojiSizeUnit.endsWith('em')) {
emojiSizeReal = emojiSizeValue * fontSizeMultiplier * 14
} else {
emojiSizeReal = emojiSizeValue
}
const fullEmojiSize = emojiSizeReal + 2 * 0.2 * fontSizeMultiplier * 14
this.emojiSize = fullEmojiSize
},
togglePicker() {
if (this.popoverShown) {
this.hidePicker()
} else {
this.showPicker()
}
},
showPicker() {
this.$refs.popover.showPopover()
this.$nextTick(() => {
this.onShowing()
})
},
hidePicker() {
this.$refs.popover.hidePopover()
},
setAnchorEl(el) {
this.$refs.popover.setAnchorEl(el)
},
setGroupRef(name) {
return (el) => {
this.groupRefs[name] = el
}
},
onPopoverShown() {
this.popoverShown = true
},
onPopoverClosed() {
this.popoverShown = false
},
onStickerUploaded(e) {
this.$emit('sticker-uploaded', e)
},
onStickerUploadFailed(e) {
this.$emit('sticker-upload-failed', e)
},
onEmoji(emoji) {
const value = emoji.imageUrl
? `:${emoji.displayText}:`
: emoji.replacement
if (!this.keepOpen) {
this.$refs.popover.hidePopover()
}
this.$emit('emoji', {
insertion: value,
insertionUrl: emoji.imageUrl,
keepOpen: this.keepOpen,
})
},
onScroll(startIndex, endIndex, visibleStartIndex, visibleEndIndex) {
const target = this.$refs['emoji-groups'].$el
this.scrolledGroup(target, visibleStartIndex, visibleEndIndex)
},
scrolledGroup(target, start, end) {
const top = target.scrollTop + 5
this.$nextTick(() => {
this.emojiItems.slice(start, end + 1).forEach((group) => {
const headerId = toHeaderId(group.id)
const ref = this.groupRefs['group-' + group.id]
if (!ref) {
return
}
const elem = ref.$el.parentElement
if (!elem) {
return
}
if (elem && getOffset(elem) <= top) {
this.activeGroup = headerId
}
})
this.scrollHeader()
})
},
scrollHeader() {
// Scroll the active tab's header into view
const headerRef = this.groupRefs['group-header-' + this.activeGroup]
const left = headerRef.offsetLeft
const right = left + headerRef.offsetWidth
const headerCont = this.$refs.header
const currentScroll = headerCont.scrollLeft
const currentScrollRight = currentScroll + headerCont.clientWidth
const setScroll = (s) => {
headerCont.scrollLeft = s
}
const margin = 7 // .emoji-tabs-item: padding
if (left - margin < currentScroll) {
setScroll(left - margin)
} else if (right + margin > currentScrollRight) {
setScroll(right + margin - headerCont.clientWidth)
}
},
highlight(groupId) {
this.setShowStickers(false)
const indexInList = this.emojiItems.findIndex((k) => k.id === groupId)
this.$refs['emoji-groups'].scrollToItem(indexInList)
},
updateScrolledClass(target) {
if (target.scrollTop <= 5) {
this.groupsScrolledClass = 'scrolled-top'
} else if (target.scrollTop >= target.scrollTopMax - 5) {
this.groupsScrolledClass = 'scrolled-bottom'
} else {
this.groupsScrolledClass = 'scrolled-middle'
}
},
toggleStickers() {
this.showingStickers = !this.showingStickers
},
setShowStickers(value) {
this.showingStickers = value
},
filterByKeyword(list, keyword) {
return filterByKeyword(
list,
keyword,
this.languages,
this.maybeLocalizedEmojiName,
)
},
onShowing() {
const oldContentLoaded = this.contentLoaded
this.updateEmojiSize()
this.recalculateItemPerRow()
this.$nextTick(() => {
this.$refs.search.focus()
})
this.contentLoaded = true
this.filteredEmojiGroups = this.getFilteredEmojiGroups()
if (!oldContentLoaded) {
this.$nextTick(() => {
if (this.defaultGroup) {
this.highlight(this.defaultGroup)
}
})
}
},
getFilteredEmojiGroups() {
return this.allEmojiGroups
.map((group) => ({
...group,
emojis: this.filterByKeyword(group.emojis, trim(this.keyword)),
}))
.filter((group) => group.emojis.length > 0)
},
recalculateItemPerRow() {
this.$nextTick(() => {
if (!this.$refs['emoji-groups']) {
return
}
this.width = this.$refs['emoji-groups'].$el.clientWidth
})
},
},
watch: {
keyword() {
this.onScroll()
this.debouncedHandleKeywordChange()
},
allCustomGroups() {
this.filteredEmojiGroups = this.getFilteredEmojiGroups()
},
},
computed: {
minItemSize() {
return this.emojiSize
},
// used to watch it
fontSize() {
this.$nextTick(() => {
this.updateEmojiSize()
})
return useMergedConfigStore().mergedConfig.fontSize
},
emojiHeight() {
return this.emojiSize
},
itemPerRow() {
return this.width ? Math.floor(this.width / this.emojiSize) : 6
},
activeGroupView() {
return this.showingStickers ? '' : this.activeGroup
},
stickersAvailable() {
if (useEmojiStore().stickers) {
return useEmojiStore().stickers.length > 0
}
return 0
},
allCustomGroups() {
if (this.hideCustomEmoji || this.hideCustomEmojiInPicker) {
return {}
}
const emojis = useEmojiStore().groupedCustomEmojis
if (emojis.unpacked) {
emojis.unpacked.text = this.$t('emoji.unpacked')
}
return emojis
},
defaultGroup() {
return Object.keys(this.allCustomGroups)[0]
},
unicodeEmojiGroups() {
return useEmojiStore().standardEmojiGroupList.map((group) => ({
id: `standard-${group.id}`,
text: this.$t(`emoji.unicode_groups.${group.id}`),
icon: UNICODE_EMOJI_GROUP_ICON[group.id],
emojis: group.emojis,
}))
},
allEmojiGroups() {
return Object.entries(this.allCustomGroups)
.map(([, v]) => v)
.concat(this.unicodeEmojiGroups)
},
stickerPickerEnabled() {
return (useEmojiStore().stickers || []).length !== 0
},
debouncedHandleKeywordChange() {
return debounce(() => {
this.filteredEmojiGroups = this.getFilteredEmojiGroups()
}, 500)
},
emojiItems() {
return this.filteredEmojiGroups
.map((group) =>
chunk(group.emojis, this.itemPerRow).map((items, index) => ({
...group,
id: index === 0 ? group.id : `row-${index}-${group.id}`,
emojis: items,
isFirstRow: index === 0,
})),
)
- .reduce((a, c) => a.concat(c), [])
+ .flat()
},
languages() {
return ensureFinalFallback(
useMergedConfigStore().mergedConfig.interfaceLanguage,
)
},
maybeLocalizedEmojiName() {
return (emoji) => {
if (!emoji.annotations) {
return emoji.displayText
}
if (emoji.displayTextI18n) {
return this.$t(emoji.displayTextI18n.key, emoji.displayTextI18n.args)
}
for (const lang of this.languages) {
if (emoji.annotations[lang]?.name) {
return emoji.annotations[lang].name
}
}
return emoji.displayText
}
},
isInModal() {
return this.popoversZLayer === 'modals'
},
},
}
export default EmojiPicker
diff --git a/src/components/flash/flash.js b/src/components/flash/flash.js
index 3c25abea13..48d342d2fa 100644
--- a/src/components/flash/flash.js
+++ b/src/components/flash/flash.js
@@ -1,54 +1,54 @@
import RuffleService from '../../services/ruffle_service/ruffle_service.js'
import { library } from '@fortawesome/fontawesome-svg-core'
import {
faExclamationTriangle,
faStop,
} from '@fortawesome/free-solid-svg-icons'
library.add(faStop, faExclamationTriangle)
const Flash = {
props: ['src'],
data() {
return {
player: false, // can be true, "hidden", false. hidden = element exists
loaded: false,
ruffleInstance: null,
}
},
methods: {
openPlayer() {
if (this.player) return // prevent double-loading, or re-loading on failure
this.player = 'hidden'
RuffleService.getRuffle().then((ruffle) => {
const player = ruffle.newest().createPlayer()
player.config = {
letterbox: 'on',
}
const container = this.$refs.container
container.appendChild(player)
player.style.width = '100%'
player.style.height = '100%'
player
.load(this.src)
.then(() => {
this.player = true
})
.catch((e) => {
console.error('Error loading ruffle', e)
this.player = 'error'
})
this.ruffleInstance = player
this.$emit('playerOpened')
})
},
closePlayer() {
- this.ruffleInstance && this.ruffleInstance.remove()
+ this.ruffleInstance?.remove()
this.player = false
this.$emit('playerClosed')
},
},
}
export default Flash
diff --git a/src/components/font_control/font_control.js b/src/components/font_control/font_control.js
index 697d83ee21..af4d623589 100644
--- a/src/components/font_control/font_control.js
+++ b/src/components/font_control/font_control.js
@@ -1,57 +1,57 @@
import Checkbox from 'src/components/checkbox/checkbox.vue'
import Popover from 'src/components/popover/popover.vue'
import Select from 'src/components/select/select.vue'
import LocalSettingIndicator from 'src/components/settings_modal/helpers/local_setting_indicator.vue'
import { useInterfaceStore } from 'src/stores/interface.js'
import { library } from '@fortawesome/fontawesome-svg-core'
import {
faExclamationTriangle,
faFont,
faKeyboard,
} from '@fortawesome/free-solid-svg-icons'
library.add(faExclamationTriangle, faKeyboard, faFont)
export default {
components: {
Select,
Checkbox,
Popover,
LocalSettingIndicator,
},
props: ['name', 'label', 'modelValue', 'fallback', 'options', 'no-inherit'],
mounted() {
useInterfaceStore().queryLocalFonts()
},
emits: ['update:modelValue'],
data() {
return {
manualEntry: false,
availableOptions: [
this.noInherit ? '' : 'inherit',
'serif',
'sans-serif',
'monospace',
...(this.options || []),
- ].filter((_) => _),
+ ].filter(Boolean),
}
},
methods: {
toggleManualEntry() {
this.manualEntry = !this.manualEntry
},
},
computed: {
present() {
return this.modelValue != null
},
localFontsList() {
return useInterfaceStore().localFonts
},
localFontsSize() {
return useInterfaceStore().localFonts?.length
},
},
}
diff --git a/src/components/font_control/font_control.vue b/src/components/font_control/font_control.vue
index 99bdb86032..5a54e83685 100644
--- a/src/components/font_control/font_control.vue
+++ b/src/components/font_control/font_control.vue
@@ -1,146 +1,146 @@
<template>
<div class="font-control">
<div class="setting-item">
<Checkbox
- v-if="typeof fallback !== 'undefined'"
+ v-if="fallback !== undefined"
:id="name + '-o'"
class="font-checkbox setting-control setting-label"
:model-value="present"
@change="$emit('update:modelValue', modelValue == null ? fallback : null)"
>
<LocalSettingIndicator />
{{ ' ' }}
<i18n-t
scope="global"
keypath="settings.style.fonts.override"
tag="span"
>
<span>
{{ label }}
</span>
</i18n-t>
</Checkbox>
</div>
{{ ' ' }}
<div
v-if="modelValue"
class="font-input setting-item"
>
<label
v-if="manualEntry"
:id="name + '-label'"
:for="manualEntry ? name : name + '-font-switcher'"
class="label"
>
<i18n-t
keypath="settings.style.themes3.font.entry"
tag="span"
scope="global"
>
<template #fontFamily>
<code>font-family</code>
</template>
</i18n-t>
</label>
<label
v-else
:id="name + '-label'"
:for="manualEntry ? name : name + '-font-switcher'"
class="label"
>
{{ $t('settings.style.themes3.font.select') }}
</label>
{{ ' ' }}
<span
v-if="manualEntry"
class="btn-group"
>
<button
class="btn button-default"
:title="$t('settings.style.themes3.font.lookup_local_fonts')"
@click="toggleManualEntry"
>
<FAIcon
fixed-width
icon="font"
/>
</button>
<input
:id="name"
:model-value="modelValue"
class="input custom-font"
type="text"
@update:modelValue="$emit('update:modelValue', $event.target.value)"
>
</span>
<span
v-else
class="font-selector btn-group"
>
<button
class="btn button-default"
:title="$t('settings.style.themes3.font.enter_manually')"
@click="toggleManualEntry"
>
<FAIcon
fixed-width
icon="keyboard"
/>
</button>
<Select
:id="name + '-local-font-switcher'"
:model-value="modelValue"
class="custom-font"
@update:model-value="v => $emit('update:modelValue', v)"
>
<optgroup
:label="$t('settings.style.themes3.font.group-builtin')"
>
<option
v-for="option in availableOptions"
:key="option"
:value="option"
:style="{ fontFamily: option === 'inherit' ? null : option }"
>
{{ $t('settings.style.themes3.font.builtin.' + option) }}
</option>
</optgroup>
<optgroup
v-if="localFontsSize > 0"
:label="$t('settings.style.themes3.font.group-local')"
>
<option
v-for="option in localFontsList"
:key="option"
:value="option"
:style="{ fontFamily: option }"
>
{{ option }}
</option>
</optgroup>
<optgroup
v-else
:label="$t('settings.style.themes3.font.group-local')"
>
<option disabled>
{{ $t('settings.style.themes3.font.local-unavailable1') }}
</option>
<option disabled>
{{ $t('settings.style.themes3.font.local-unavailable2') }}
</option>
</optgroup>
</Select>
</span>
</div>
</div>
</template>
<script src="./font_control.js"></script>
<style lang="scss">
.invalid-tooltip {
margin: 0.5em 1em;
min-width: 10em;
text-align: center;
}
</style>
diff --git a/src/components/image_cropper/image_cropper.js b/src/components/image_cropper/image_cropper.js
index c7a213a4fe..599c95cafd 100644
--- a/src/components/image_cropper/image_cropper.js
+++ b/src/components/image_cropper/image_cropper.js
@@ -1,102 +1,102 @@
import 'cropperjs' // This adds all of the cropperjs's components into DOM
import { library } from '@fortawesome/fontawesome-svg-core'
import { faCircleNotch } from '@fortawesome/free-solid-svg-icons'
library.add(faCircleNotch)
const ImageCropper = {
props: {
// Mime-types to accept, i.e. which filetypes to accept (.gif, .png, etc.)
mimes: {
type: String,
default: 'image/png, image/gif, image/jpeg, image/bmp, image/x-icon',
},
// Fixed aspect-ratio for selection box
aspectRatio: {
type: Number,
},
},
data() {
return {
dataUrl: undefined,
filename: undefined,
}
},
emits: [
'submit', // cropping complete or uncropped image returned
'close', // cropper is closed
],
methods: {
destroy() {
this.$refs.input.value = ''
this.dataUrl = undefined
this.$emit('close')
},
submit(cropping = true) {
let cropperPromise
if (cropping) {
cropperPromise = this.$refs.cropperSelection.$toCanvas()
} else {
cropperPromise = Promise.resolve()
}
cropperPromise.then((canvas) => {
this.$emit('submit', { canvas, file: this.file })
})
},
pickImage() {
this.$refs.input.click()
},
readFile() {
const fileInput = this.$refs.input
- if (fileInput.files != null && fileInput.files[0] != null) {
+ if (fileInput?.files?.[0]) {
this.file = fileInput.files[0]
const reader = new window.FileReader()
reader.onload = (e) => {
this.dataUrl = e.target.result
this.$emit('open')
}
reader.readAsDataURL(this.file)
this.$emit('changed', this.file, reader)
}
},
inSelection(selection, maxSelection) {
return (
selection.x >= maxSelection.x &&
selection.y >= maxSelection.y &&
selection.x + selection.width <= maxSelection.x + maxSelection.width &&
selection.y + selection.height <= maxSelection.y + maxSelection.height
)
},
onCropperSelectionChange(event) {
if (!this.$refs.cropperCanvas) {
return // Cropper sends even before component is fully initialized
}
const cropperCanvas = this.$refs.cropperCanvas
const cropperCanvasRect = cropperCanvas.getBoundingClientRect()
const selection = event.detail
const maxSelection = {
x: 0,
y: 0,
width: cropperCanvasRect.width,
height: cropperCanvasRect.height,
}
if (!this.inSelection(selection, maxSelection)) {
event.preventDefault()
}
},
},
mounted() {
// listen for input file changes
const fileInput = this.$refs.input
fileInput.addEventListener('change', this.readFile)
},
beforeUnmount: function () {
const fileInput = this.$refs.input
fileInput.removeEventListener('change', this.readFile)
},
}
export default ImageCropper
diff --git a/src/components/lists_edit/lists_edit.js b/src/components/lists_edit/lists_edit.js
index d7a8525a82..7eb035091c 100644
--- a/src/components/lists_edit/lists_edit.js
+++ b/src/components/lists_edit/lists_edit.js
@@ -1,151 +1,151 @@
import { mapState as mapPiniaState } from 'pinia'
import { mapGetters, mapState } from 'vuex'
import BasicUserCard from 'src/components/basic_user_card/basic_user_card.vue'
import ListsUserSearch from 'src/components/lists_user_search/lists_user_search.vue'
import PanelLoading from 'src/components/panel_loading/panel_loading.vue'
import TabSwitcher from 'src/components/tab_switcher/tab_switcher.jsx'
import UserAvatar from 'src/components/user_avatar/user_avatar.vue'
import { useInterfaceStore } from 'src/stores/interface.js'
import { useListsStore } from 'src/stores/lists.js'
import { library } from '@fortawesome/fontawesome-svg-core'
import { faChevronLeft, faSearch } from '@fortawesome/free-solid-svg-icons'
library.add(faSearch, faChevronLeft)
const ListsNew = {
components: {
BasicUserCard,
UserAvatar,
ListsUserSearch,
TabSwitcher,
PanelLoading,
},
data() {
return {
title: '',
titleDraft: '',
membersUserIds: [],
removedUserIds: new Set([]), // users we added for members, to undo
searchUserIds: [],
addedUserIds: new Set([]), // users we added from search, to undo
searchLoading: false,
reallyDelete: false,
}
},
created() {
if (!this.id) return
useListsStore()
.fetchList({ listId: this.id })
.then(() => {
this.title = this.findListTitle(this.id)
this.titleDraft = this.title
})
useListsStore()
.fetchListAccounts({ listId: this.id })
.then(() => {
this.membersUserIds = this.findListAccounts(this.id)
this.membersUserIds.forEach((userId) => {
this.$store.dispatch('fetchUserIfMissing', userId)
})
})
},
computed: {
id() {
return this.$route.params.id
},
membersUsers() {
return [...this.membersUserIds, ...this.addedUserIds]
.map((userId) => this.findUser(userId))
- .filter((user) => user)
+ .filter(Boolean)
},
searchUsers() {
return this.searchUserIds
.map((userId) => this.findUser(userId))
- .filter((user) => user)
+ .filter(Boolean)
},
...mapState({
currentUser: (state) => state.users.currentUser,
}),
...mapPiniaState(useListsStore, ['findListTitle', 'findListAccounts']),
...mapGetters(['findUser']),
},
methods: {
onInput() {
this.search(this.query)
},
toggleRemoveMember(user) {
if (this.removedUserIds.has(user.id)) {
this.id && this.addUser(user)
this.removedUserIds.delete(user.id)
} else {
this.id && this.removeUser(user.id)
this.removedUserIds.add(user.id)
}
},
toggleAddFromSearch(user) {
if (this.addedUserIds.has(user.id)) {
this.id && this.removeUser(user.id)
this.addedUserIds.delete(user.id)
} else {
this.id && this.addUser(user)
this.addedUserIds.add(user.id)
}
},
isRemoved(user) {
return this.removedUserIds.has(user.id)
},
isAdded(user) {
return this.addedUserIds.has(user.id)
},
addUser(user) {
useListsStore().addListAccount({ accountId: user.id, listId: this.id })
},
removeUser(userId) {
useListsStore().removeListAccount({ accountId: userId, listId: this.id })
},
onSearchLoading() {
this.searchLoading = true
},
onSearchLoadingDone() {
this.searchLoading = false
},
onSearchResults(results) {
this.searchLoading = false
this.searchUserIds = results
},
updateListTitle() {
useListsStore().setList({ listId: this.id, title: this.titleDraft })
this.title = this.findListTitle(this.id)
},
createList() {
useListsStore()
.createList({ title: this.titleDraft })
.then((list) => {
useListsStore().setListAccounts({
listId: list.id,
accountIds: [...this.addedUserIds],
})
return list.id
})
.then((listId) => {
this.$router.push({ name: 'lists-timeline', params: { id: listId } })
})
.catch((e) => {
useInterfaceStore().pushGlobalNotice({
messageKey: 'lists.error',
messageArgs: [e.message],
level: 'error',
})
})
},
deleteList() {
useListsStore().deleteList({ listId: this.id })
this.$router.push({ name: 'lists' })
},
},
}
export default ListsNew
diff --git a/src/components/mention_link/mention_link.js b/src/components/mention_link/mention_link.js
index f1861748b4..0309079e84 100644
--- a/src/components/mention_link/mention_link.js
+++ b/src/components/mention_link/mention_link.js
@@ -1,169 +1,167 @@
import { mapState as mapPiniaState } from 'pinia'
import { mapState } from 'vuex'
import UnicodeDomainIndicator from 'src/components/unicode_domain_indicator/unicode_domain_indicator.vue'
import UserAvatar from 'src/components/user_avatar/user_avatar.vue'
import UserPopover from 'src/components/user_popover/user_popover.vue'
import {
highlightClass,
highlightStyle,
} from '../../services/user_highlighter/user_highlighter.js'
import { useInstanceStore } from 'src/stores/instance.js'
import { useMergedConfigStore } from 'src/stores/merged_config.js'
import { useUserHighlightStore } from 'src/stores/user_highlight.js'
import generateProfileLink from 'src/services/user_profile_link_generator/user_profile_link_generator'
import { library } from '@fortawesome/fontawesome-svg-core'
import { faAt } from '@fortawesome/free-solid-svg-icons'
library.add(faAt)
const MentionLink = {
name: 'MentionLink',
components: {
UserAvatar,
UnicodeDomainIndicator,
UserPopover,
},
props: {
url: {
required: true,
type: String,
},
content: {
required: true,
type: String,
},
userId: {
required: false,
type: String,
},
userScreenName: {
required: false,
type: String,
},
},
data() {
return {
hasSelection: false,
}
},
methods: {
onClick() {
if (this.shouldShowTooltip) return
const link = generateProfileLink(
this.userId || this.user.id,
this.userScreenName || this.user.screen_name,
)
this.$router.push(link)
},
handleSelection() {
if (this.$refs.full) {
this.hasSelection = document
.getSelection()
.containsNode(this.$refs.full, true)
}
},
},
mounted() {
document.addEventListener('selectionchange', this.handleSelection)
},
unmounted() {
document.removeEventListener('selectionchange', this.handleSelection)
},
computed: {
user() {
- return (
- this.url && this.$store && this.$store.getters.findUserByUrl(this.url)
- )
+ return this.url && this.$store?.getters.findUserByUrl(this.url)
},
isYou() {
// FIXME why user !== currentUser???
- return this.user && this.user.id === this.currentUser.id
+ return this.user?.id === this.currentUser.id
},
userName() {
return this.user && this.userNameFullUi.split('@')[0]
},
serverName() {
// XXX assumed that domain does not contain @
return (
this.user &&
(this.userNameFullUi.split('@')[1] || useInstanceStore().instanceDomain)
)
},
userNameFull() {
- return this.user && this.user.screen_name
+ return this.user?.screen_name
},
userNameFullUi() {
- return this.user && this.user.screen_name_ui
+ return this.user?.screen_name_ui
},
highlightData() {
return this.highlight[this.user?.screen_name]
},
highlightType() {
return this.highlightData && '-' + this.highlightData.type
},
highlightClass() {
return this.highlightData && highlightClass(this.user)
},
style() {
if (this.highlightData) {
const {
backgroundColor,
backgroundPosition,
backgroundImage,
...rest
} = highlightStyle(this.highlightData)
return rest
}
},
classnames() {
return [
{
'-you': this.isYou && this.shouldBoldenYou,
'-highlighted': !!this.highlightData,
'-has-selection': this.hasSelection,
},
this.highlightType,
]
},
isRemote() {
return this.userName !== this.userNameFull
},
shouldShowFullUserName() {
const conf = this.mergedConfig.mentionLinkDisplay
if (conf === 'short') {
return false
} else if (conf === 'full') {
return true
} else {
// full_for_remote
return this.isRemote
}
},
shouldShowTooltip() {
return this.mergedConfig.mentionLinkShowTooltip
},
shouldShowAvatar() {
return this.mergedConfig.mentionLinkShowAvatar
},
shouldShowYous() {
return this.mergedConfig.mentionLinkShowYous
},
shouldBoldenYou() {
return this.mergedConfig.mentionLinkBoldenYou
},
shouldFadeDomain() {
return this.mergedConfig.mentionLinkFadeDomain
},
...mapPiniaState(useMergedConfigStore, ['mergedConfig']),
...mapPiniaState(useUserHighlightStore, ['highlight']),
...mapState({
currentUser: (state) => state.users.currentUser,
}),
},
}
export default MentionLink
diff --git a/src/components/mrf_transparency_panel/mrf_transparency_panel.js b/src/components/mrf_transparency_panel/mrf_transparency_panel.js
index b2048984db..7f2a161868 100644
--- a/src/components/mrf_transparency_panel/mrf_transparency_panel.js
+++ b/src/components/mrf_transparency_panel/mrf_transparency_panel.js
@@ -1,97 +1,97 @@
import { get } from 'lodash'
import { mapState } from 'pinia'
import { useInstanceStore } from 'src/stores/instance.js'
/**
* This is for backwards compatibility. We originally didn't recieve
* extra info like a reason why an instance was rejected/quarantined/etc.
* Because we didn't want to break backwards compatibility it was decided
* to add an extra "info" key.
*/
const toInstanceReasonObject = (instances, info, key) => {
return instances.map((instance) => {
- if (info[key] && info[key][instance] && info[key][instance].reason) {
+ if (info[key]?.[instance]?.reason) {
return { instance, reason: info[key][instance].reason }
}
return { instance, reason: '' }
})
}
const MRFTransparencyPanel = {
computed: {
...mapState(useInstanceStore, {
federationPolicy: (state) => state.federationPolicy,
mrfPolicies: (state) => get(state, 'federationPolicy.mrf_policies', []),
quarantineInstances: (state) =>
toInstanceReasonObject(
get(state, 'federationPolicy.quarantined_instances', []),
get(state, 'federationPolicy.quarantined_instances_info', []),
'quarantined_instances',
),
acceptInstances: (state) =>
toInstanceReasonObject(
get(state, 'federationPolicy.mrf_simple.accept', []),
get(state, 'federationPolicy.mrf_simple_info', []),
'accept',
),
rejectInstances: (state) =>
toInstanceReasonObject(
get(state, 'federationPolicy.mrf_simple.reject', []),
get(state, 'federationPolicy.mrf_simple_info', []),
'reject',
),
ftlRemovalInstances: (state) =>
toInstanceReasonObject(
get(
state,
'federationPolicy.mrf_simple.federated_timeline_removal',
[],
),
get(state, 'federationPolicy.mrf_simple_info', []),
'federated_timeline_removal',
),
mediaNsfwInstances: (state) =>
toInstanceReasonObject(
get(state, 'federationPolicy.mrf_simple.media_nsfw', []),
get(state, 'federationPolicy.mrf_simple_info', []),
'media_nsfw',
),
mediaRemovalInstances: (state) =>
toInstanceReasonObject(
get(state, 'federationPolicy.mrf_simple.media_removal', []),
get(state, 'federationPolicy.mrf_simple_info', []),
'media_removal',
),
keywordsFtlRemoval: (state) =>
get(
state,
'federationPolicy.mrf_keyword.federated_timeline_removal',
[],
),
keywordsReject: (state) =>
get(state, 'federationPolicy.mrf_keyword.reject', []),
keywordsReplace: (state) =>
get(state, 'federationPolicy.mrf_keyword.replace', []),
}),
hasInstanceSpecificPolicies() {
return (
this.quarantineInstances.length ||
this.acceptInstances.length ||
this.rejectInstances.length ||
this.ftlRemovalInstances.length ||
this.mediaNsfwInstances.length ||
this.mediaRemovalInstances.length
)
},
hasKeywordPolicies() {
return (
this.keywordsFtlRemoval.length ||
this.keywordsReject.length ||
this.keywordsReplace.length
)
},
},
}
export default MRFTransparencyPanel
diff --git a/src/components/navigation/filter.js b/src/components/navigation/filter.js
index 0255db6aab..0cbefd3fa6 100644
--- a/src/components/navigation/filter.js
+++ b/src/components/navigation/filter.js
@@ -1,50 +1,49 @@
export const filterNavigation = (
list = [],
{
hasChats,
hasAnnouncements,
isFederating,
isPrivate,
currentUser,
supportsBookmarkFolders,
supportsBubbleTimeline,
},
) => {
return list.filter(({ criteria, anon, anonRoute }) => {
const set = new Set(criteria || [])
if (!isFederating && set.has('federating')) return false
if (!currentUser && isPrivate && set.has('!private')) return false
if (!currentUser && !(anon || anonRoute)) return false
- if ((!currentUser || !currentUser.locked) && set.has('lockedUser'))
- return false
+ if (!currentUser?.locked && set.has('lockedUser')) return false
if (!hasChats && set.has('chats')) return false
if (!hasAnnouncements && set.has('announcements')) return false
if (!supportsBubbleTimeline && set.has('supportsBubbleTimeline'))
return false
if (!supportsBookmarkFolders && set.has('supportsBookmarkFolders'))
return false
if (supportsBookmarkFolders && set.has('!supportsBookmarkFolders'))
return false
return true
})
}
export const getListEntries = (store) =>
store.allLists.map((list) => ({
name: 'list-' + list.id,
routeObject: { name: 'lists-timeline', params: { id: list.id } },
labelRaw: list.title,
iconLetter: list.title[0],
}))
export const getBookmarkFolderEntries = (store) =>
store.allFolders
? store.allFolders.map((folder) => ({
name: 'bookmark-folder-' + folder.id,
routeObject: { name: 'bookmark-folder', params: { id: folder.id } },
labelRaw: folder.name,
iconEmoji: folder.emoji,
iconEmojiUrl: folder.emoji_url,
iconLetter: folder.name[0],
}))
: []
diff --git a/src/components/opacity_input/opacity_input.vue b/src/components/opacity_input/opacity_input.vue
index e6b736fa33..e6bdd6b8dc 100644
--- a/src/components/opacity_input/opacity_input.vue
+++ b/src/components/opacity_input/opacity_input.vue
@@ -1,49 +1,49 @@
<template>
<div
class="opacity-control style-control"
:class="{ disabled: !present || disabled }"
>
<label
:for="name"
class="label"
:class="{ faint: !present || disabled }"
>
{{ label || $t('settings.style.themes3.editor.opacity') }}
</label>
<Checkbox
- v-if="typeof fallback !== 'undefined'"
+ v-if="fallback !== undefined"
:model-value="present"
:disabled="disabled"
class="opt"
@update:model-value="$emit('update:modelValue', !present ? fallback : undefined)"
/>
<input
:id="name"
class="input input-number"
type="number"
:value="modelValue || fallback"
:disabled="!present || disabled"
:class="{ disabled: !present || disabled }"
max="1"
min="0"
step=".05"
@input="$emit('update:modelValue', $event.target.value)"
>
</div>
</template>
<script>
import Checkbox from 'src/components/checkbox/checkbox.vue'
export default {
components: {
Checkbox,
},
props: ['name', 'label', 'modelValue', 'fallback', 'disabled'],
emits: ['update:modelValue'],
computed: {
present() {
return this.modelValue !== undefined
},
},
}
</script>
diff --git a/src/components/poll/poll.js b/src/components/poll/poll.js
index b93a6699db..0ce304a0a3 100644
--- a/src/components/poll/poll.js
+++ b/src/components/poll/poll.js
@@ -1,129 +1,129 @@
import Checkbox from 'components/checkbox/checkbox.vue'
import Timeago from 'components/timeago/timeago.vue'
import genRandomSeed from '../../services/random_seed/random_seed.service.js'
import { useMergedConfigStore } from 'src/stores/merged_config.js'
import { usePollsStore } from 'src/stores/polls.js'
export default {
name: 'Poll',
props: ['basePoll', 'emoji'],
components: {
Timeago,
Checkbox,
},
data() {
return {
loading: false,
choices: [],
randomSeed: genRandomSeed(),
}
},
created() {
if (!usePollsStore().pollsObject[this.pollId]) {
usePollsStore().mergeOrAddPoll(this.basePoll)
}
usePollsStore().trackPoll(this.pollId)
},
unmounted() {
usePollsStore().untrackPoll(this.pollId)
},
computed: {
pollId() {
return this.basePoll.id
},
poll() {
const storePoll = usePollsStore().pollsObject[this.pollId]
return storePoll || {}
},
options() {
- return (this.poll && this.poll.options) || []
+ return this.poll?.options || []
},
expiresAt() {
- return (this.poll && this.poll.expires_at) || null
+ return this.poll?.expires_at || null
},
expired() {
- return (this.poll && this.poll.expired) || false
+ return this.poll?.expired || false
},
expirationLabel() {
if (useMergedConfigStore().mergedConfig.useAbsoluteTimeFormat) {
return this.expired ? 'polls.expired_at' : 'polls.expires_at'
} else {
return this.expired ? 'polls.expired' : 'polls.expires_in'
}
},
allowNonSquareEmoji() {
return useMergedConfigStore().mergedConfig.nonSquareEmoji
},
pauseMfm() {
return useMergedConfigStore().mergedConfig.pauseMfm
},
scaleMfm() {
return useMergedConfigStore().mergedConfig.scaleMfm
},
loggedIn() {
return this.$store.state.users.currentUser
},
showResults() {
return this.poll.voted || this.expired || !this.loggedIn
},
totalVotesCount() {
return this.poll.votes_count
},
containerClass() {
return {
loading: this.loading,
}
},
choiceIndices() {
// Convert array of booleans into an array of indices of the
// items that were 'true', so [true, false, false, true] becomes
// [0, 3].
return this.choices
.map((entry, index) => entry && index)
.filter((value) => typeof value === 'number')
},
isDisabled() {
const noChoice = this.choiceIndices.length === 0
return this.loading || noChoice
},
},
methods: {
percentageForOption(count) {
return this.totalVotesCount === 0
? 0
: Math.round((count / this.totalVotesCount) * 100)
},
resultTitle(option) {
return `${option.votes_count}/${this.totalVotesCount} ${this.$t('polls.votes')}`
},
activateOption(index, value) {
let result
if (this.poll.multiple) {
result = this.choices || this.options.map(() => false)
} else {
result = this.options.map(() => false)
}
result[index] = value
this.choices = result
},
optionId(index) {
return `poll${this.poll.id}-${index}`
},
vote() {
if (this.choiceIndices.length === 0) return
this.loading = true
usePollsStore()
.votePoll({
id: this.statusId,
pollId: this.poll.id,
choices: this.choiceIndices,
})
.then(() => {
this.loading = false
})
},
},
}
diff --git a/src/components/poll/poll_form.js b/src/components/poll/poll_form.js
index 5d0334a635..a9c9151271 100644
--- a/src/components/poll/poll_form.js
+++ b/src/components/poll/poll_form.js
@@ -1,151 +1,164 @@
import Select from 'src/components/select/select.vue'
import { useInstanceStore } from 'src/stores/instance.js'
import * as DateUtils from 'src/services/date_utils/date_utils.js'
import { pollFallback } from 'src/services/poll/poll.service.js'
import { library } from '@fortawesome/fontawesome-svg-core'
import { faPlus, faTimes } from '@fortawesome/free-solid-svg-icons'
library.add(faTimes, faPlus)
export default {
components: {
Select,
},
name: 'PollForm',
props: {
visible: Boolean,
modelValue: {
type: Object,
required: false,
default: null,
},
},
emits: ['update:modelValue'],
computed: {
+ // Model value
+ options() {
+ return pollFallback(this.modelValue, 'options')
+ },
pollType: {
get() {
return pollFallback(this.modelValue, 'pollType')
},
set(newVal) {
this.$emit('update:modelValue', {
...this.modelValue,
pollType: newVal,
})
},
},
- options: {
- get() {
- return pollFallback(this.modelValue, 'options')
- },
- set(newVal) {
- this.$emit('update:modelValue', { ...this.modelValue, options: newVal })
- },
- },
expiryAmount: {
get() {
return pollFallback(this.modelValue, 'expiryAmount')
},
set(newVal) {
this.$emit('update:modelValue', {
...this.modelValue,
expiryAmount: newVal,
})
},
},
expiryUnit: {
get() {
return pollFallback(this.modelValue, 'expiryUnit')
},
set(newVal) {
this.$emit('update:modelValue', {
...this.modelValue,
expiryUnit: newVal,
})
},
},
+
+ // Configuration
pollLimits() {
return useInstanceStore().limits.pollLimits
},
maxOptions() {
return this.pollLimits.max_options
},
maxLength() {
return this.pollLimits.max_option_chars
},
expiryUnits() {
const allUnits = ['minutes', 'hours', 'days']
const expiry = this.convertExpiryFromUnit
return allUnits.filter(
(unit) => this.pollLimits.max_expiration >= expiry(unit, 1),
)
},
minExpirationInCurrentUnit() {
return Math.ceil(
this.convertExpiryToUnit(
this.expiryUnit,
this.pollLimits.min_expiration,
),
)
},
maxExpirationInCurrentUnit() {
return Math.floor(
this.convertExpiryToUnit(
this.expiryUnit,
this.pollLimits.max_expiration,
),
)
},
},
methods: {
nextOption(index) {
const element = this.$el.querySelector(`#poll-${index + 1}`)
if (element) {
element.focus()
} else {
// Try adding an option and try focusing on it
const addedOption = this.addOption()
if (addedOption) {
this.$nextTick(function () {
this.nextOption(index)
})
}
}
},
addOption() {
if (this.options.length < this.maxOptions) {
- this.options = [...this.options, '']
+ this.$emit('update:modelValue', {
+ ...this.modelValue,
+ options: [...this.options, ''],
+ })
+
return true
}
return false
},
deleteOption(index) {
if (this.options.length > 2) {
- this.options.splice(index, 1)
- this.options = this.options
+ const options = [...this.options]
+ options.splice(index, 1)
+
+ this.$emit('update:modelValue', {
+ ...this.modelValue,
+ options,
+ })
}
},
updateOption(index, value) {
- this.options = this.options
+ const options = [...this.options]
+ options[index] = value
+
+ this.$emit('update:modelValue', {
+ ...this.modelValue,
+ options,
+ })
},
convertExpiryToUnit(unit, amount) {
// Note: we want seconds and not milliseconds
return DateUtils.secondsToUnit(unit, amount)
},
convertExpiryFromUnit(unit, amount) {
return DateUtils.unitToSeconds(unit, amount)
},
expiryAmountChange() {
this.expiryAmount = Math.max(
this.minExpirationInCurrentUnit,
this.expiryAmount,
)
this.expiryAmount = Math.min(
this.maxExpirationInCurrentUnit,
this.expiryAmount,
)
},
},
}
diff --git a/src/components/poll/poll_form.vue b/src/components/poll/poll_form.vue
index cb8a6f91fc..b5db1314a0 100644
--- a/src/components/poll/poll_form.vue
+++ b/src/components/poll/poll_form.vue
@@ -1,156 +1,159 @@
<template>
<div
v-if="visible"
class="poll-form"
>
<div
v-for="(option, index) in options"
:key="index"
class="poll-option"
>
<div class="input-container">
<input
:id="`poll-${index}`"
- v-model="options[index]"
+ :model-value="options[index]"
size="1"
class="input poll-option-input"
type="text"
:placeholder="$t('polls.option')"
:maxlength="maxLength"
+ :aria-label="$t('polls.option')"
@keydown.enter.stop.prevent="nextOption(index)"
- @change="updateOption"
+ @change="(e) => updateOption(index, e.target.value)"
>
</div>
<button
v-if="options.length > 2"
class="delete-option button-unstyled -hover-highlight"
@click="deleteOption(index)"
>
<FAIcon icon="times" />
</button>
</div>
<button
v-if="options.length < maxOptions"
class="add-option faint button-unstyled -hover-highlight"
@click="addOption"
>
<FAIcon
icon="plus"
size="sm"
/>
{{ $t("polls.add_option") }}
</button>
<div class="poll-type-expiry">
<div
class="poll-type"
:title="$t('polls.type')"
>
<Select
v-model="pollType"
class="poll-type-select"
unstyled="true"
>
<option value="single">
{{ $t('polls.single_choice') }}
</option>
<option value="multiple">
{{ $t('polls.multiple_choices') }}
</option>
</Select>
</div>
<div
class="poll-expiry"
:title="$t('polls.expiry')"
>
<input
v-model="expiryAmount"
type="number"
class="input expiry-amount hide-number-spinner"
:min="minExpirationInCurrentUnit"
:max="maxExpirationInCurrentUnit"
+ :aria-label="$t('polls.expiry_amount')"
@change="expiryAmountChange"
>
{{ ' ' }}
<Select
v-model="expiryUnit"
unstyled="true"
class="expiry-unit"
@change="expiryAmountChange"
+ :aria-label="$t('polls.expiry_unit')"
>
<option
v-for="unit in expiryUnits"
:key="unit"
:value="unit"
>
{{ $t(`time.unit.${unit}_short`, [''], expiryAmount) }}
</option>
</Select>
</div>
</div>
</div>
</template>
<script src="./poll_form.js"></script>
<style lang="scss">
.poll-form {
display: flex;
flex-direction: column;
padding: 0 0.5em 0.5em;
.add-option {
align-self: flex-start;
padding-top: 0.25em;
padding-left: 0.1em;
}
.poll-option {
display: flex;
align-items: baseline;
justify-content: space-between;
margin-bottom: 0.25em;
}
.input-container {
width: 100%;
input {
// Hack: dodge the floating X icon
padding-right: 2.5em;
width: 100%;
}
}
.delete-option {
// Hack: Move the icon over the input box
width: 1.5em;
margin-left: -1.5em;
z-index: 1;
}
.poll-type-expiry {
margin-top: 0.5em;
display: flex;
width: 100%;
}
.poll-type {
margin-right: 0.75em;
flex: 1 1 60%;
.poll-type-select {
padding-right: 0.75em;
}
}
.poll-expiry {
display: flex;
.expiry-amount {
width: 3em;
text-align: right;
}
}
}
</style>
diff --git a/src/components/popover/popover.js b/src/components/popover/popover.js
index 9a06c4e699..40344834b8 100644
--- a/src/components/popover/popover.js
+++ b/src/components/popover/popover.js
@@ -1,424 +1,421 @@
/* global process */
const Popover = {
name: 'Popover',
props: {
// Action to trigger popover: either 'hover' or 'click'
trigger: String,
// 'top', 'bottom', 'left', 'right'
placement: String,
// Takes object with properties 'x' and 'y', values of these can be
// 'container' for using offsetParent as boundaries for either axis
// or 'viewport'
boundTo: Object,
// Takes a selector to use as a replacement for the parent container
// for getting boundaries for x an y axis
boundToSelector: String,
// Takes a top/bottom/left/right object, how much space to leave
// between boundary and popover element
margin: Object,
// Takes a x/y object and tells how many pixels to offset from
// anchor point on either axis
offset: Object,
// Replaces the classes you may want for the popover container.
// Use 'popover-default' in addition to get the default popover
// styles with your custom class.
popoverClass: String,
// If true, subtract padding when calculating position for the popover,
// use it when popover offset looks to be different on top vs bottom.
removePadding: Boolean,
// self-explanatory (i hope)
disabled: Boolean,
// Instead of putting popover next to anchor, overlay popover's center on top of anchor's center
overlayCenters: Boolean,
// What selector (witin popover!) to use for determining center of popover
overlayCentersSelector: String,
// Lets hover popover stay when clicking inside of it
stayOnClick: Boolean,
// Use styled button (to avoid nested buttons)
normalButton: Boolean,
// Whether to hide the trigger totally
hideTrigger: {
type: Boolean,
default: false,
},
triggerAttrs: {
type: Object,
default: {},
},
},
inject: {
// override popover z layer
popoversZLayer: {
default: '',
},
},
data() {
return {
// lockReEntry is a flag that is set when mouse cursor is leaving the popover's content
// so that if mouse goes back into popover it won't be re-shown again to prevent annoyance
// with popovers refusing to be hidden when user wants to interact with something in below popover
anchorEl: null,
// There's an issue where having teleport enabled by default causes things just...
// not render at all, i.e. main post status form and its emoji inputs
teleport: false,
lockReEntry: false,
hidden: true,
styles: {},
oldSize: { width: 0, height: 0 },
scrollable: null,
// used to avoid blinking if hovered onto popover
graceTimeout: null,
parentPopover: null,
disableClickOutside: false,
childrenShown: new Set(),
}
},
computed: {
allTriggerAttrs() {
if (process.env.NODE_ENV === 'development') {
if ('aria-hidden' in this.triggerAttrs) {
throw new Error(
'Do not use aria-hidden in triggerAttrs. Instead set hideTrigger to true',
)
}
}
const attrs = {
...this.triggerAttrs,
}
if (this.hideTrigger) {
attrs['aria-hidden'] = true
attrs.tabindex = 1
}
return attrs
},
},
methods: {
setAnchorEl(el) {
this.anchorEl = el
this.updateStyles()
},
containerBoundingClientRect() {
const container = this.boundToSelector
? this.$el.closest(this.boundToSelector)
: this.$el.offsetParent
return container.getBoundingClientRect()
},
updateStyles() {
if (this.hidden) {
this.styles = {}
return
}
// Popover will be anchored around this element, trigger ref is the container, so
// its children are what are inside the slot. Expect only one v-slot:trigger.
const anchorEl =
- this.anchorEl ||
- (this.$refs.trigger && this.$refs.trigger.children[0]) ||
- this.$el
+ this.anchorEl || this.$refs.trigger?.children[0] || this.$el
// SVGs don't have offsetWidth/Height, use fallback
const anchorHeight = anchorEl.offsetHeight || anchorEl.clientHeight
const anchorWidth = anchorEl.offsetWidth || anchorEl.clientWidth
const anchorScreenBox = anchorEl.getBoundingClientRect()
const anchorStyle = getComputedStyle(anchorEl)
const topPadding = parseFloat(anchorStyle.paddingTop)
const bottomPadding = parseFloat(anchorStyle.paddingBottom)
const rightPadding = parseFloat(anchorStyle.paddingRight)
const leftPadding = parseFloat(anchorStyle.paddingLeft)
// Screen position of the origin point for popover = center of the anchor
const origin = {
x: anchorScreenBox.left + anchorWidth * 0.5,
y: anchorScreenBox.top + anchorHeight * 0.5,
}
const content = this.$refs.content
const overlayCenter = this.overlayCenters
? this.$refs.content.querySelector(this.overlayCentersSelector)
: null
// Minor optimization, don't call a slow reflow call if we don't have to
const parentScreenBox =
- this.boundTo &&
- (this.boundTo.x === 'container' || this.boundTo.y === 'container') &&
+ (this.boundTo?.x === 'container' || this.boundTo?.y === 'container') &&
this.containerBoundingClientRect()
const margin = this.margin || {}
// What are the screen bounds for the popover? Viewport vs container
// when using viewport, using default margin values to dodge the navbar
const xBounds =
- this.boundTo && this.boundTo.x === 'container'
+ this.boundTo?.x === 'container'
? {
min: parentScreenBox.left + (margin.left || 0),
max: parentScreenBox.right - (margin.right || 0),
}
: {
min: 0 + (margin.left || 10),
max: window.innerWidth - (margin.right || 10),
}
const yBounds =
- this.boundTo && this.boundTo.y === 'container'
+ this.boundTo?.y === 'container'
? {
min: parentScreenBox.top + (margin.top || 0),
max: parentScreenBox.bottom - (margin.bottom || 0),
}
: {
min: 0 + (margin.top || 50),
max: window.innerHeight - (margin.bottom || 5),
}
let horizOffset = 0
let vertOffset = 0
if (overlayCenter) {
const box = content.getBoundingClientRect()
const overlayCenterScreenBox = overlayCenter.getBoundingClientRect()
const leftInnerOffset = overlayCenterScreenBox.left - box.left
const topInnerOffset = overlayCenterScreenBox.top - box.top
horizOffset = -leftInnerOffset - overlayCenter.offsetWidth * 0.5
vertOffset = -topInnerOffset - overlayCenter.offsetHeight * 0.5
} else {
horizOffset = content.offsetWidth * -0.5
vertOffset = content.offsetHeight * -0.5
}
const leftBorder = origin.x + horizOffset
const rightBorder = leftBorder + content.offsetWidth
const topBorder = origin.y + vertOffset
const bottomBorder = topBorder + content.offsetHeight
// If overflowing from left, move it so that it doesn't
if (leftBorder < xBounds.min) {
horizOffset += xBounds.min - leftBorder
}
// If overflowing from right, move it so that it doesn't
if (rightBorder > xBounds.max) {
horizOffset -= rightBorder - xBounds.max
}
// If overflowing from top, move it so that it doesn't
if (topBorder < yBounds.min) {
vertOffset += yBounds.min - topBorder
}
// If overflowing from bottom, move it so that it doesn't
if (bottomBorder > yBounds.max) {
vertOffset -= bottomBorder - yBounds.max
}
let translateX = 0
let translateY = 0
if (overlayCenter) {
translateX = origin.x + horizOffset
translateY = origin.y + vertOffset
} else if (this.placement !== 'right' && this.placement !== 'left') {
// Default to whatever user wished with placement prop
let usingTop = this.placement !== 'bottom'
// Handle special cases, first force to displaying on top if there's no space on bottom,
// regardless of what placement value was. Then check if there's no space on top, and
// force to bottom, again regardless of what placement value was.
const topBoundary =
origin.y - anchorHeight * 0.5 + (this.removePadding ? topPadding : 0)
const bottomBoundary =
origin.y +
anchorHeight * 0.5 -
(this.removePadding ? bottomPadding : 0)
if (bottomBoundary + content.offsetHeight > yBounds.max) usingTop = true
if (topBoundary - content.offsetHeight < yBounds.min) usingTop = false
- const yOffset = (this.offset && this.offset.y) || 0
+ const yOffset = this.offset?.y || 0
translateY = usingTop
? topBoundary - yOffset - content.offsetHeight
: bottomBoundary + yOffset
- const xOffset = (this.offset && this.offset.x) || 0
+ const xOffset = this.offset?.x || 0
translateX = origin.x + horizOffset + xOffset
} else {
// Default to whatever user wished with placement prop
let usingLeft = this.placement !== 'right'
// Handle special cases, first force to displaying on left if there's no space on right,
// regardless of what placement value was. Then check if there's no space on right, and
// force to left, again regardless of what placement value was.
const leftBoundary =
origin.x - anchorWidth * 0.5 + (this.removePadding ? leftPadding : 0)
const rightBoundary =
origin.x + anchorWidth * 0.5 - (this.removePadding ? rightPadding : 0)
if (rightBoundary + content.offsetWidth > xBounds.max) usingLeft = true
if (leftBoundary - content.offsetWidth < xBounds.min) usingLeft = false
- const xOffset = (this.offset && this.offset.x) || 0
+ const xOffset = this.offset?.x || 0
translateX = usingLeft
? leftBoundary - xOffset - content.offsetWidth
: rightBoundary + xOffset
- const yOffset = (this.offset && this.offset.y) || 0
+ const yOffset = this.offset?.y || 0
translateY = origin.y + vertOffset + yOffset
}
this.styles = {
left: `${Math.round(translateX)}px`,
top: `${Math.round(translateY)}px`,
}
if (this.popoversZLayer) {
this.styles['--ZI_popover_override'] =
`var(--ZI_${this.popoversZLayer}_popovers)`
}
if (parentScreenBox) {
this.styles.maxWidth = `${Math.round(parentScreenBox.width)}px`
}
},
showPopover() {
if (this.disabled) return
this.disableClickOutside = true
setTimeout(() => {
this.disableClickOutside = false
}, 0)
const wasHidden = this.hidden
this.hidden = false
- this.parentPopover && this.parentPopover.onChildPopoverState(this, true)
+ this.parentPopover?.onChildPopoverState(this, true)
if (this.trigger === 'click' || this.stayOnClick) {
document.addEventListener('click', this.onClickOutside)
}
this.scrollable.addEventListener('scroll', this.onScroll)
this.scrollable.addEventListener('resize', this.onResize)
// My assumption is that upon showing popover initially has different size
// as its contents are getting populating, so logic uses those incorrect
// sizes as basis
setTimeout(() => {
if (wasHidden) this.$emit('show')
this.updateStyles()
}, 1)
},
hidePopover() {
if (this.disabled) return
if (!this.hidden) this.$emit('close')
this.hidden = true
- this.parentPopover && this.parentPopover.onChildPopoverState(this, false)
+ this.parentPopover?.onChildPopoverState(this, false)
if (this.trigger === 'click') {
document.removeEventListener('click', this.onClickOutside)
}
this.scrollable?.removeEventListener('scroll', this.onScroll)
this.scrollable?.removeEventListener('resize', this.onResize)
},
resizePopover() {
setTimeout(() => {
this.updateStyles()
}, 1)
},
onMouseenter() {
if (this.trigger === 'hover') {
this.lockReEntry = false
clearTimeout(this.graceTimeout)
this.graceTimeout = null
this.showPopover()
}
},
onMouseleave() {
if (this.trigger === 'hover' && this.childrenShown.size === 0) {
this.graceTimeout = setTimeout(() => this.hidePopover(), 1)
}
},
onMouseenterContent() {
if (this.trigger === 'hover' && !this.lockReEntry) {
this.lockReEntry = true
clearTimeout(this.graceTimeout)
this.graceTimeout = null
this.showPopover()
}
},
onMouseleaveContent() {
if (this.trigger === 'hover' && this.childrenShown.size === 0) {
this.graceTimeout = setTimeout(() => this.hidePopover(), 1)
}
},
onClick() {
if (this.trigger === 'click') {
if (this.hidden) {
this.showPopover()
} else {
this.hidePopover()
}
}
},
onClickOutside(e) {
if (this.disableClickOutside) return
if (this.hidden) return
- if (this.$refs.content && this.$refs.content.contains(e.target)) return
+ if (this.$refs.content?.contains(e.target)) return
if (this.$el.contains(e.target)) return
if (this.childrenShown.size > 0) return
this.hidePopover()
if (this.parentPopover) this.parentPopover.onClickOutside(e)
},
onScroll() {
this.updateStyles()
},
onResize() {
const content = this.$refs.content
if (!content) return
if (
this.oldSize.width !== content.offsetWidth ||
this.oldSize.height !== content.offsetHeight
) {
this.updateStyles()
this.oldSize = {
width: content.offsetWidth,
height: content.offsetHeight,
}
}
},
onChildPopoverState(childRef, state) {
if (state) {
this.childrenShown.add(childRef)
} else {
this.childrenShown.delete(childRef)
}
},
},
updated() {
// Monitor changes to content size, update styles only when content sizes have changed,
// that should be the only time we need to move the popover box if we don't care about scroll
// or resize
this.onResize()
},
mounted() {
this.teleport = true
let scrollable =
this.$refs.trigger.closest('.column.-scrollable') ||
this.$refs.trigger.closest('.mobile-notifications')
if (!scrollable) scrollable = window
this.scrollable = scrollable
let parent = this.$parent
while (parent && parent.$.type.name !== 'Popover') {
parent = parent.$parent
}
this.parentPopover = parent
},
beforeUnmount() {
this.hidePopover()
},
}
export default Popover
diff --git a/src/components/post_status_form/post_status_form.js b/src/components/post_status_form/post_status_form.js
index 917fa7c163..1acd211c49 100644
--- a/src/components/post_status_form/post_status_form.js
+++ b/src/components/post_status_form/post_status_form.js
@@ -1,1102 +1,1101 @@
import {
debounce,
isEqual,
unescape as ldUnescape,
reject,
uniqBy,
} from 'lodash'
import { mapActions, mapState } from 'pinia'
import { defineAsyncComponent } from 'vue'
import Attachment from 'src/components/attachment/attachment.vue'
import Checkbox from 'src/components/checkbox/checkbox.vue'
import DraftCloser from 'src/components/draft_closer/draft_closer.vue'
import EmojiInput from 'src/components/emoji_input/emoji_input.vue'
import suggestor from 'src/components/emoji_input/suggestor.js'
import Gallery from 'src/components/gallery/gallery.vue'
import MediaUpload from 'src/components/media_upload/media_upload.vue'
import Popover from 'src/components/popover/popover.vue'
import ScopeSelector from 'src/components/scope_selector/scope_selector.vue'
import Select from 'src/components/select/select.vue'
import StatusContent from 'src/components/status_content/status_content.vue'
import { propsToNative } from '../../services/attributes_helper/attributes_helper.service.js'
import { findOffset } from '../../services/offset_finder/offset_finder.service.js'
import genRandomSeed from '../../services/random_seed/random_seed.service.js'
import statusPoster from '../../services/status_poster/status_poster.service.js'
import { useEmojiStore } from 'src/stores/emoji.js'
import { useInstanceStore } from 'src/stores/instance.js'
import { useInstanceCapabilitiesStore } from 'src/stores/instance_capabilities.js'
import { useInterfaceStore } from 'src/stores/interface.js'
import { useMediaViewerStore } from 'src/stores/media_viewer.js'
import { useMergedConfigStore } from 'src/stores/merged_config.js'
import { useSyncConfigStore } from 'src/stores/sync_config.js'
import { pollFormToMasto } from 'src/services/poll/poll.service.js'
import { library } from '@fortawesome/fontawesome-svg-core'
import {
faBan,
faChevronDown,
faChevronLeft,
faChevronRight,
faCircleNotch,
faPollH,
faQuoteRight,
faSmileBeam,
faTimes,
faUpload,
} from '@fortawesome/free-solid-svg-icons'
library.add(
faSmileBeam,
faPollH,
faUpload,
faQuoteRight,
faBan,
faTimes,
faCircleNotch,
faChevronDown,
faChevronLeft,
faChevronRight,
)
// Converts a string with px to a number like '2px' -> 2
const pxStringToNumber = (str) => {
return Number(str.substring(0, str.length - 2))
}
const PostStatusForm = {
props: {
// Status editing stuff
statusId: String,
statusText: String,
statusSubject: String,
statusIsSensitive: {
type: Boolean,
required: false,
default: null, // Avoiding automatic conversion null -> false
},
statusPoll: Object,
statusQuote: Object,
statusFiles: Array,
statusMediaDescriptions: Object,
statusVisibility: String,
statusContentType: String,
// Replies/mentions
repliedStatus: Object, // Object of a status replying to
profileMention: Object, // Mentioned user (used in profile page -> mention)
// Draft stuff
hideDraft: Boolean, // Disable drafts functionality
closeable: Boolean, // Whether form can be closed (i.e. in replies)
draftId: String, // ID of the draft to be used
// Chats stuff
chatView: Boolean, // Used for the "submit on enter" user setting
maxHeight: Number,
placeholder: String,
postHandler: Function, // Used to override poster to use chats one instead of status one
preserveFocus: Boolean, // Keep focus on form after posting
autoFocus: Boolean, // Steal focus when form is opened
fileLimit: Number, // Chats only support 1 attachment :(
emojiPickerPlacement: String,
optimisticPosting: Boolean, // Don't wait for confirmation that post is done
// Feature toggles for special cases (mostly chats)
mentionsLine: Boolean, // Use separate field for specifying mentions
mentionsLineReadOnly: Boolean, // Make the said field read-only (for chat conversation view)
disableSubject: Boolean,
disableScopeSelector: Boolean,
disableVisibilitySelector: Boolean,
disableNotice: Boolean,
disableLockWarning: Boolean,
disablePolls: Boolean,
disableQuotes: Boolean,
disableSensitivityCheckbox: Boolean,
disableSubmit: Boolean,
disablePreview: Boolean,
disableDraft: Boolean,
},
emits: [
'posted',
'draft-done',
'resize',
'mediaplay',
'mediapause',
'close-accepted',
'update',
],
data() {
return {
initialized: false,
randomSeed: genRandomSeed(),
// Posting stuff
idempotencyKey: '',
/* Data is initialized first, but we have no access to .computed yet
* which we need for some defaults (i.e. user configration)
* so we pre-fill with stuff meant for status editing and later
* back-fill with defaults in .created()
*/
newStatus: {
status: this.statusText ?? null,
mentions: this.statusMentionLine ?? null,
spoilerText: this.statusSubject ?? null,
quote: this.statusQuote ?? null,
files: this.statusFiles ?? null,
poll: this.statusPoll ?? null,
mediaDescriptions: this.statusMediaDescriptions ?? null,
nsfw: this.statusIsSensitive ?? null,
visibility: this.statusVisibility ?? null,
contentType: this.statusContentType ?? null,
},
// Attachments
dropFiles: [],
uploadingFiles: false,
showDropIcon: 'hide',
dropStopTimeout: null,
// Preview
preview: null,
previewLoading: false,
// Draft
saveInhibited: true,
saveable: false,
// Misc States
emojiInputShown: false,
error: null,
posting: false,
}
},
components: {
MediaUpload,
EmojiInput,
PollForm: defineAsyncComponent(
() => import('src/components/poll/poll_form.vue'),
),
QuoteForm: defineAsyncComponent(
() => import('src/components/quote/quote_form.vue'),
),
ScopeSelector,
Checkbox,
Select,
Attachment,
StatusContent,
Gallery,
DraftCloser,
Popover,
},
created() {
this.updateIdempotencyKey()
// If we are starting a new post, do not associate it with old drafts
const draft =
!this.disableDraft && (this.draftId || this.statusType !== 'new')
? this.getDraft(this.statusType, this.refId)
: null
if (draft) {
// Copying and overriding defaults from the draft for each field
Object.keys(this.newStatus).forEach((key) => {
this.newStatus[key] = draft[key] ?? this.newStatus[key]
})
} else {
Object.entries(this.defaultNewStatus).forEach(([key, value]) => {
this.newStatus[key] = this.newStatus[key] ?? value
})
}
this.initialized = true
},
mounted() {
this.resize(this.$refs.textarea)
if (this.repliedStatus) {
const textLength = this.$refs.textarea.value.length
this.$refs.textarea.setSelectionRange(textLength, textLength)
}
if (this.repliedStatus || this.autoFocus) {
this.$refs.textarea.focus()
}
},
computed: {
// Visibility / expansion state of subcomponents
pollFormVisible() {
return this.hasPoll
},
quoteFormVisible() {
return this.hasQuote && !this.newStatus.quote.thread
},
showPreview() {
return !this.disablePreview && (!!this.preview || this.previewLoading)
},
// Composing stuff
statusType() {
if (this.repliedStatus) {
return 'reply'
} else if (this.profileMention) {
return 'mention'
} else if (this.statusId) {
return 'edit'
} else {
return 'new'
}
},
refId() {
if (this.repliedStatus) {
return this.repliedStatus.id
} else if (this.profileMention) {
return this.profileMention.id
} else if (this.statusId) {
return this.statusId
} else {
return null
}
},
mentionsString() {
if (this.statusType !== 'reply' && this.statusType !== 'mention')
return ''
let allAttentions = [...(this.repliedStatus?.attentions || [])]
const repliedUser = this.repliedStatus?.user || this.profileMention
if (repliedUser) allAttentions.unshift(repliedUser)
allAttentions = uniqBy(allAttentions, 'id')
allAttentions = reject(allAttentions, { id: this.currentUser.id })
const mentions = allAttentions.map(
(attention) => `@${attention.screen_name}`,
)
return mentions.length > 0 ? mentions.join(' ') + ' ' : ''
},
newStatusContent() {
return this.mentionsLine
? this.mentionsString + this.newStatus.status
: this.newStatus.status
},
defaultNewStatus() {
const defaultNewStatus = {
files: [],
poll: null,
quote: null,
mediaDescriptions: {},
}
const scope = (() => {
if (this.repliedStatus) {
if (this.repliedStatus.visibility === 'direct') return 'direct'
if (this.userDefaultScopeCopy) return this.repliedStatus.visibility
}
return this.userDefaultScope
})()
const preset = this.$route.query.message
const statusText = preset ?? ''
if (this.mentionsLine) {
defaultNewStatus.status = statusText
} else {
defaultNewStatus.status = this.mentionsString + statusText
}
defaultNewStatus.mentions = this.mentionsString.trim()
defaultNewStatus.spoilerText = this.repliedSubjectString ?? ''
defaultNewStatus.nsfw = this.userDefaultSensitive
defaultNewStatus.visibility = scope
defaultNewStatus.contentType = this.userDefaultPostContentType
return defaultNewStatus
},
// -Edit
isEdit() {
- return typeof this.statusId !== 'undefined' && this.statusId.trim() !== ''
+ return this.statusId !== undefined && this.statusId.trim() !== ''
},
// -Reply
isReply() {
return this.statusType === 'reply'
},
inReplyToStatusId() {
return !this.hasQuote ||
!this.newStatus.quote.thread ||
!this.newStatus.quote.id
? this.repliedStatus?.id
: undefined
},
repliedSubjectString() {
if (!this.repliedStatus?.summary) return null
const decodedSummary = ldUnescape(this.repliedStatus.summary)
const behavior = this.mergedConfig.subjectLineBehavior
const startsWithRe = decodedSummary.match(/^re[: ]/i)
if ((behavior !== 'noop' && startsWithRe) || behavior === 'masto') {
return decodedSummary
} else if (behavior === 'email') {
return 're: '.concat(decodedSummary)
} else if (behavior === 'noop') {
return ''
}
},
// -Poll
hasPoll() {
return this.newStatus.poll != null
},
// -Quotes
hasQuote() {
return this.newStatus.quote !== null
},
quotable() {
if (!this.quotingAvailable || !this.isReply) {
return false
}
if (
this.repliedStatus.visibility === 'public' ||
this.repliedStatus.visibility === 'unlisted' ||
this.repliedStatus.visibility === 'local'
) {
return true
} else if (this.repliedStatus.visibility === 'private') {
return this.repliedStatus.user.id === this.currentUser.id
}
return false
},
quoteId() {
return this.newStatus.quote?.id ?? null
},
// This is for "reply/quote" toggle
quoteThreadToggled: {
get() {
return this.newStatus.quote?.thread
},
set(value) {
if (value) {
this.newStatus.quote = {}
- this.newStatus.quote.thread = value
- this.newStatus.quote.id = value ? this.repliedStatus.id : ''
+ this.newStatus.quote.thread = true
+ this.newStatus.quote.id = this.repliedStatus.id
} else {
this.newStatus.quote = null
}
},
},
postingOptions() {
const poll = this.hasPoll ? pollFormToMasto(this.newStatus.poll) : null
return {
status: this.newStatusContent,
spoilerText: this.newStatus.spoilerText,
visibility: this.newStatus.visibility,
sensitive: this.newStatus.nsfw,
media: this.newStatus.files,
inReplyToStatusId: this.inReplyToStatusId,
quoteId: this.quoteId,
contentType: this.newStatus.contentType,
poll,
idempotencyKey: this.idempotencyKey,
store: this.$store,
}
},
// Emoji stuff
emojiUserSuggestor() {
return suggestor({
emoji: [
...useEmojiStore().standardEmojiList,
...useEmojiStore().customEmoji,
],
store: this.$store,
})
},
emojiSuggestor() {
return suggestor({
emoji: [
...useEmojiStore().standardEmojiList,
...useEmojiStore().customEmoji,
],
})
},
emoji() {
return useEmojiStore().standardEmojiList
},
customEmoji() {
return useEmojiStore().customEmoji
},
// Length & Limits
statusLength() {
return this.newStatusContent.length
},
spoilerTextLength() {
return this.newStatus.spoilerText.length
},
statusLengthLimit() {
return useInstanceStore().limits.textLimit
},
hasStatusLengthLimit() {
return this.statusLengthLimit > 0
},
charactersLeft() {
return (
this.statusLengthLimit - (this.statusLength + this.spoilerTextLength)
)
},
isOverLengthLimit() {
return this.hasStatusLengthLimit && this.charactersLeft < 0
},
isEmptyStatus() {
return (
this.newStatus.status.trim() === '' && this.newStatus.files.length === 0
)
},
uploadFileLimitReached() {
return this.newStatus.files.length >= this.fileLimit
},
// Drafts
isDirty() {
return Object.entries(this.defaultNewStatus).some(
([key, defaultValue]) => {
const actualValue = this.newStatus[key]
if (actualValue === null) return false
return !isEqual(actualValue, defaultValue)
},
)
},
shouldAutoSaveDraft() {
return useMergedConfigStore().mergedConfig.autoSaveDraft
},
debouncedMaybeAutoSaveDraft() {
return debounce(this.maybeAutoSaveDraft, 3000)
},
autoSaveState() {
if (this.saveable) {
return this.$t('post_status.auto_save_saving')
} else if (this.newStatus.id) {
return this.$t('post_status.auto_save_saved')
} else {
return this.$t('post_status.auto_save_nothing_new')
}
},
safeToSaveDraft() {
return (
(this.newStatus.status ||
this.newStatus.spoilerText ||
this.newStatus.files.length ||
this.hasPoll ||
this.hasQuote) &&
this.saveable
)
},
hasEmptyDraft() {
return (
this.newStatus.id &&
!(
this.newStatus.status ||
this.newStatus.spoilerText ||
this.newStatus.files.length ||
this.hasPoll ||
this.hasQuote
)
)
},
// Error handling
pollContentError() {
- return (
- this.pollFormVisible && this.newStatus.poll && this.newStatus.poll.error
- )
+ return this.pollFormVisible && this.newStatus.poll?.error
},
// Featureset detection
postFormats() {
return useInstanceCapabilitiesStore().postFormats || []
},
safeDMEnabled() {
return useInstanceCapabilitiesStore().safeDM
},
pollsAvailable() {
return (
useInstanceCapabilitiesStore().pollsAvailable &&
useInstanceStore().limits.pollLimits.max_options >= 2 &&
this.disablePolls !== true
)
},
hideExtraActions() {
return this.disableDraft || this.hideDraft
},
quotingAvailable() {
if (!useInstanceCapabilitiesStore().quotingAvailable) {
return false
}
return this.disableQuotes !== true
},
// User configuration
userDefaultScope() {
return this.currentUser.default_scope
},
userDefaultPostContentType() {
return this.mergedConfig.postContentType
},
userDefaultScopeCopy() {
return this.mergedConfig.scopeCopy
},
userDefaultSensitive() {
return this.mergedConfig.sensitiveByDefault
},
showAllScopes() {
return !this.mergedConfig.minimalScopesMode
},
minimalScopesMode() {
return this.mergedConfig.minimalScopesMode
},
alwaysShowSubject() {
return this.mergedConfig.alwaysShowSubjectInput
},
hideScopeNotice() {
return (
this.disableNotice ||
useMergedConfigStore().mergedConfig.hideScopeNotice
)
},
submitOnEnter() {
return this.chatView && this.mergedConfig.chatSubmitOnEnter
},
// Global stuff
currentUser() {
return this.$store.state.users.currentUser
},
...mapState(useMergedConfigStore, ['mergedConfig']),
...mapState(useInterfaceStore, {
mobileLayout: (store) => store.mobileLayout,
}),
},
watch: {
newStatus: {
deep: true,
handler() {
this.statusChanged()
},
},
saveable(val) {
// https://developer.mozilla.org/en-US/docs/Web/API/Window/beforeunload_event#usage_notes
// MDN says we'd better add the beforeunload event listener only when needed, and remove it when it's no longer needed
if (val) {
this.addBeforeUnloadListener()
} else {
this.removeBeforeUnloadListener()
}
},
},
methods: {
// Composing
update() {
Object.entries(this.defaultNewStatus).forEach(([key, value]) => {
if (key === 'status') return
this.newStatus[key] = value
})
},
onMentionsLineUpdate(e) {
if (this.mentionsLineReadOnly) return
this.newStatus.mentions = e
},
changeVis(visibility) {
this.newStatus.visibility = visibility
},
clearStatus() {
this.saveInhibited = true
this.newStatus.status = ''
this.newStatus.mentions = ''
this.newStatus.spoilerText = ''
this.newStatus.files = []
this.newStatus.poll = null
this.newStatus.quote = null
this.newStatus.nsfw = this.defaultNewStatus.nsfw
this.newStatus.mediaDescriptions = {}
- this.$refs.mediaUpload && this.$refs.mediaUpload.clearFile()
+ this.$refs.mediaUpload?.clearFile()
if (this.preserveFocus) {
this.$nextTick(() => {
this.$refs.textarea.focus()
})
}
const el = this.$el.querySelector('textarea')
el.style.height = 'auto'
el.style.height = undefined
this.error = null
if (this.preview) this.previewStatus()
this.saveable = false
},
async postStatus(event) {
if (this.posting && !this.optimisticPosting) {
return
}
if (this.disableSubmit) {
return
}
if (this.emojiInputShown) {
return
}
if (this.submitOnEnter) {
event.stopPropagation()
event.preventDefault()
}
if (
this.optimisticPosting &&
(this.isEmptyStatus || this.isOverLengthLimit)
) {
return
}
if (this.isEmptyStatus) {
this.error = this.$t('post_status.empty_status_error')
return
}
if (this.pollContentError) {
this.error = this.pollContentError
return
}
this.posting = true
try {
await this.setAllMediaDescriptions()
} catch {
this.error = this.$t('post_status.media_description_error')
this.posting = false
return
}
const postHandler = this.postHandler
? this.postHandler
: statusPoster.postStatus
postHandler(this.postingOptions)
.then((data) => {
this.abandonDraft()
this.clearStatus()
this.updateIdempotencyKey()
this.$emit('posted', data)
})
.catch((error) => {
this.error = error
})
.finally(() => {
this.posting = false
})
},
// Preview
previewStatus() {
if (this.isEmptyStatus && this.newStatus.spoilerText.trim() === '') {
this.preview = { error: this.$t('post_status.preview_empty') }
this.previewLoading = false
return
}
this.previewLoading = true
statusPoster
.postStatus({
...this.postingOptions,
media: [],
poll: null,
preview: true,
})
.then((data) => {
// Don't apply preview if not loading, because it means
// user has closed the preview manually.
if (!this.previewLoading) return
this.preview = data
})
.catch((error) => {
this.preview = { error }
})
.finally(() => {
this.previewLoading = false
})
},
debouncePreviewStatus: debounce(function () {
this.previewStatus()
}, 500),
autoPreview() {
if (!this.preview) return
this.previewLoading = true
this.debouncePreviewStatus()
},
closePreview() {
this.preview = null
this.previewLoading = false
},
togglePreview() {
if (this.showPreview) {
this.closePreview()
} else {
this.previewStatus()
}
},
// Attachments
setMediaDescription(id) {
const description = this.newStatus.mediaDescriptions[id]
if (!description || description.trim() === '') return
return statusPoster.setMediaDescription({
store: this.$store,
id,
description,
})
},
setAllMediaDescriptions() {
const ids = this.newStatus.files.map((file) => file.id)
return Promise.all(ids.map((id) => this.setMediaDescription(id)))
},
addMediaFile(fileInfo) {
this.newStatus.files.push(fileInfo)
this.$emit('resize', { delayed: true })
},
removeMediaFile(fileInfo) {
const index = this.newStatus.files.indexOf(fileInfo)
this.newStatus.files.splice(index, 1)
this.$emit('resize')
},
editAttachment(fileInfo, newText) {
this.newStatus.mediaDescriptions[fileInfo.id] = newText
},
shiftUpMediaFile(fileInfo) {
const { files } = this.newStatus
const index = this.newStatus.files.indexOf(fileInfo)
if (index === 0) return
files.splice(index, 1)
files.splice(index - 1, 0, fileInfo)
},
shiftDnMediaFile(fileInfo) {
const { files } = this.newStatus
const index = this.newStatus.files.indexOf(fileInfo)
if (index === files.length - 1) return
files.splice(index, 1)
files.splice(index + 1, 0, fileInfo)
},
uploadFailed(errString, templateArgs) {
templateArgs = templateArgs || {}
this.error =
this.$t('upload.error.base') +
' ' +
this.$t('upload.error.' + errString, templateArgs)
},
startedUploadingFiles() {
this.uploadingFiles = true
},
finishedUploadingFiles() {
this.$emit('resize')
this.uploadingFiles = false
},
paste(e) {
this.autoPreview()
this.resize(e)
if (e.clipboardData.files.length > 0) {
// prevent pasting of file as text
e.preventDefault()
// Strangely, files property gets emptied after event propagation
// Trying to wrap it in array doesn't work. Plus I doubt it's possible
// to hold more than one file in clipboard.
this.dropFiles = [e.clipboardData.files[0]]
}
},
fileDrop(e) {
- if (e.dataTransfer && e.dataTransfer.types.includes('Files')) {
+ if (e.dataTransfer?.types.includes('Files')) {
e.preventDefault() // allow dropping text like before
this.dropFiles = e.dataTransfer.files
clearTimeout(this.dropStopTimeout)
this.showDropIcon = 'hide'
}
},
fileDragStop() {
// The false-setting is done with delay because just using leave-events
// directly caused unwanted flickering, this is not perfect either but
// much less noticable.
clearTimeout(this.dropStopTimeout)
this.showDropIcon = 'fade'
this.dropStopTimeout = setTimeout(() => (this.showDropIcon = 'hide'), 500)
},
fileDrag(e) {
e.dataTransfer.dropEffect = this.uploadFileLimitReached ? 'none' : 'copy'
- if (e.dataTransfer && e.dataTransfer.types.includes('Files')) {
+ if (e.dataTransfer?.types.includes('Files')) {
clearTimeout(this.dropStopTimeout)
this.showDropIcon = 'show'
}
},
// Auto-sizable input field
// TODO separate into its own component pls
onEmojiInputInput() {
this.$nextTick(() => {
this.resize(this.$refs.textarea)
})
},
resize(e) {
const target = e.target || e
if (!(target instanceof window.Element)) {
return
}
// Reset to default height for empty form, nothing else to do here.
if (target.value === '') {
target.style.height = null
this.$emit('resize')
return
}
const formRef = this.$refs.form
const bottomRef = this.$refs.bottom
/* Scroller is either `window` (replies in TL), sidebar (main post form,
* replies in notifs) or mobile post form. Note that getting and setting
* scroll is different for `Window` and `Element`s
*/
const bottomBottomPaddingStr =
window.getComputedStyle(bottomRef)['padding-bottom']
const bottomBottomPadding = pxStringToNumber(bottomBottomPaddingStr)
const scrollerRef =
this.$el.closest('.column.-scrollable') ||
this.$el.closest('.post-form-modal-view') ||
window
// Getting info about padding we have to account for, removing 'px' part
const topPaddingStr = window.getComputedStyle(target)['padding-top']
const bottomPaddingStr = window.getComputedStyle(target)['padding-bottom']
const topPadding = pxStringToNumber(topPaddingStr)
const bottomPadding = pxStringToNumber(bottomPaddingStr)
const vertPadding = topPadding + bottomPadding
const oldHeight = pxStringToNumber(target.style.height)
/* Explanation:
*
* https://developer.mozilla.org/en-US/docs/Web/API/Element/scrollHeight
* scrollHeight returns element's scrollable content height, i.e. visible
* element + overscrolled parts of it. We use it to determine when text
* inside the textarea exceeded its height, so we can set height to prevent
* overscroll, i.e. make textarea grow with the text. HOWEVER, since we
* explicitly set new height, scrollHeight won't go below that, so we can't
* SHRINK the textarea when there's extra space. To workaround that we set
* height to 'auto' which makes textarea tiny again, so that scrollHeight
* will match text height again. HOWEVER, shrinking textarea can screw with
* the scroll since there might be not enough padding around form-bottom to even
* warrant a scroll, so it will jump to 0 and refuse to move anywhere,
* so we check current scroll position before shrinking and then restore it
* with needed delta.
*/
// this part has to be BEFORE the content size update
const currentScroll =
scrollerRef === window ? scrollerRef.scrollY : scrollerRef.scrollTop
const scrollerHeight =
scrollerRef === window
? scrollerRef.innerHeight
: scrollerRef.offsetHeight
const scrollerBottomBorder = currentScroll + scrollerHeight
// BEGIN content size update
target.style.height = 'auto'
const heightWithoutPadding = Math.floor(target.scrollHeight - vertPadding)
let newHeight = this.maxHeight
? Math.min(heightWithoutPadding, this.maxHeight)
: heightWithoutPadding
// This is a bit of a hack to combat target.scrollHeight being different on every other input
// on some browsers for whatever reason. Don't change the height if difference is 1px or less.
if (Math.abs(newHeight - oldHeight) <= 1) {
newHeight = oldHeight
}
target.style.height = `${newHeight}px`
this.$emit('resize', newHeight)
// END content size update
// We check where the bottom border of form-bottom element is, this uses findOffset
// to find offset relative to scrollable container (scroller)
const bottomBottomBorder =
bottomRef.offsetHeight +
findOffset(bottomRef, scrollerRef).top +
bottomBottomPadding
const isBottomObstructed = scrollerBottomBorder < bottomBottomBorder
const isFormBiggerThanScroller = scrollerHeight < formRef.offsetHeight
const bottomChangeDelta = bottomBottomBorder - scrollerBottomBorder
// The intention is basically this;
// Keep form-bottom always visible so that submit button is in view EXCEPT
// if form element bigger than scroller and caret isn't at the end, so that
// if you scroll up and edit middle of text you won't get scrolled back to bottom
const shouldScrollToBottom =
isBottomObstructed &&
!(
isFormBiggerThanScroller &&
this.$refs.textarea.selectionStart !==
this.$refs.textarea.value.length
)
const totalDelta = shouldScrollToBottom ? bottomChangeDelta : 0
const targetScroll = Math.round(currentScroll + totalDelta)
if (scrollerRef === window) {
scrollerRef.scroll(0, targetScroll)
} else {
scrollerRef.scrollTop = targetScroll
}
},
// Poll
togglePollForm() {
this.newStatus.poll = this.hasPoll ? null : {}
},
setPoll(poll) {
this.newStatus.poll = poll
},
// Quote
toggleQuoteForm() {
// This is for the "attach quote" button
+ if (this.newStatus.quote?.thread) return
if (!this.hasQuote) {
this.newStatus.quote = {}
this.newStatus.quote.thread = false
this.newStatus.quote.id = null
this.newStatus.quote.url = ''
} else {
this.newStatus.quote = null
}
},
// Drafts
statusChanged() {
this.autoPreview()
this.updateIdempotencyKey()
this.debouncedMaybeAutoSaveDraft()
this.saveable = true
this.saveInhibited = false
},
saveDraft() {
if (!this.disableDraft && !this.saveInhibited) {
if (this.safeToSaveDraft) {
return this.$store
.dispatch('addOrSaveDraft', {
draft: {
type: this.statusType,
refId: this.refId,
...this.newStatus,
},
})
.then((id) => {
if (this.newStatus.id !== id) {
this.newStatus.id = id
}
this.saveable = false
if (!this.shouldAutoSaveDraft) {
this.clearStatus()
this.updateIdempotencyKey()
this.$emit('draft-done')
}
})
} else if (this.hasEmptyDraft) {
// There is a draft, but there is nothing in it, clear it
return this.abandonDraft().then(() => {
this.saveable = false
if (!this.shouldAutoSaveDraft) {
this.clearStatus()
this.updateIdempotencyKey()
this.$emit('draft-done')
}
})
}
}
return Promise.resolve()
},
maybeAutoSaveDraft() {
if (this.shouldAutoSaveDraft) {
this.saveDraft(false)
}
},
abandonDraft() {
return this.$store.dispatch('abandonDraft', { id: this.draftId })
},
getDraft() {
const maybeDraft = this.$store.state.drafts.drafts[this.draftId]
if (this.draftId && maybeDraft) {
return maybeDraft
} else {
const existingDrafts = this.$store.getters.draftsByTypeAndRefId(
this.statusType,
this.refId,
)
if (existingDrafts.length) {
return existingDrafts[0]
}
}
// No draft available, fall back
},
requestClose() {
if (!this.saveable) {
this.$emit('close-accepted')
} else {
this.$refs.draftCloser.requestClose()
}
},
saveAndCloseDraft() {
this.saveDraft().then(() => {
this.$emit('close-accepted')
})
},
discardAndCloseDraft() {
this.abandonDraft().then(() => {
this.$emit('close-accepted')
})
},
addBeforeUnloadListener() {
this._beforeUnloadListener ||= () => {
this.saveDraft()
}
window.addEventListener('beforeunload', this._beforeUnloadListener)
},
removeBeforeUnloadListener() {
if (this._beforeUnloadListener) {
window.removeEventListener('beforeunload', this._beforeUnloadListener)
}
},
// Misc
propsToNative(props) {
return propsToNative(props)
},
handleEmojiInputShow(value) {
this.emojiInputShown = value
},
updateIdempotencyKey() {
this.idempotencyKey = Date.now().toString()
},
openProfileTab() {
useInterfaceStore().openSettingsModalTab('profile')
},
dismissScopeNotice() {
useSyncConfigStore().setSimplePrefAndSave({
path: 'hideScopeNotice',
value: true,
})
},
clearError() {
this.error = null
},
...mapActions(useMediaViewerStore, ['increment']),
},
beforeUnmount() {
this.maybeAutoSaveDraft()
this.removeBeforeUnloadListener()
},
}
export default PostStatusForm
diff --git a/src/components/post_status_form/post_status_form.vue b/src/components/post_status_form/post_status_form.vue
index e387862066..5c2e073c26 100644
--- a/src/components/post_status_form/post_status_form.vue
+++ b/src/components/post_status_form/post_status_form.vue
@@ -1,454 +1,454 @@
<template>
<div
+ v-if="initialized"
ref="form"
class="post-status-form"
- v-if="initialized"
>
<form
autocomplete="off"
@submit.prevent
@dragover.prevent="fileDrag"
>
<div class="form-group">
<div
v-if="!currentUser.locked && newStatus.visibility == 'private' && !disableLockWarning"
class="visibility-notice notice-dismissible"
>
<i18n-t
keypath="post_status.account_not_locked_warning"
tag="p"
class=""
scope="global"
>
<button
class="button-unstyled -link"
@click="openProfileTab"
>
{{ $t('post_status.account_not_locked_warning_link') }}
</button>
</i18n-t>
</div>
<p
v-if="!hideScopeNotice && newStatus.visibility === 'public'"
class="visibility-notice notice-dismissible"
>
<span>{{ $t('post_status.scope_notice.public') }}</span>
<a
class="fa-scale-110 fa-old-padding dismiss"
:title="$t('post_status.scope_notice_dismiss')"
role="button"
tabindex="0"
@click.prevent="dismissScopeNotice()"
>
<FAIcon icon="times" />
</a>
</p>
<p
v-else-if="!hideScopeNotice && newStatus.visibility === 'unlisted'"
class="visibility-notice notice-dismissible"
>
<span>{{ $t('post_status.scope_notice.unlisted') }}</span>
<a
class="fa-scale-110 fa-old-padding dismiss"
:title="$t('post_status.scope_notice_dismiss')"
role="button"
tabindex="0"
@click.prevent="dismissScopeNotice()"
>
<FAIcon icon="times" />
</a>
</p>
<p
v-else-if="!hideScopeNotice && newStatus.visibility === 'private' && currentUser.locked"
class="visibility-notice notice-dismissible"
>
<span>{{ $t('post_status.scope_notice.private') }}</span>
<a
class="fa-scale-110 fa-old-padding dismiss"
:title="$t('post_status.scope_notice_dismiss')"
role="button"
tabindex="0"
@click.prevent="dismissScopeNotice()"
>
<FAIcon icon="times" />
</a>
</p>
<p
v-else-if="!hideScopeNotice && newStatus.visibility === 'direct'"
class="visibility-notice notice-dismissible"
>
<span v-if="safeDMEnabled">{{ $t('post_status.direct_warning_to_first_only') }}</span>
<span v-else>{{ $t('post_status.direct_warning_to_all') }}</span>
</p>
<div
v-if="isEdit"
class="visibility-notice edit-warning"
>
<p>{{ $t('post_status.edit_remote_warning') }}</p>
<p>{{ $t('post_status.edit_unsupported_warning') }}</p>
</div>
<div
v-if="!disablePreview"
class="preview-heading"
>
<a
class="preview-toggle faint"
@click.stop.prevent="togglePreview"
>
{{ $t('post_status.preview') }}
<FAIcon :icon="showPreview ? 'chevron-left' : 'chevron-right'" />
</a>
<div
v-show="previewLoading"
class="preview-spinner"
>
<FAIcon
class="fa-old-padding"
spin
icon="circle-notch"
/>
</div>
<div
v-if="quotable"
role="radiogroup"
class="reply-or-quote-selector"
>
<Checkbox
v-model="quoteThreadToggled"
:radio="true"
:disabled="quoteFormVisible"
>
{{ $t('post_status.quote_option') }}
</Checkbox>
<Checkbox
role="radio"
:radio="true"
:model-value="!quoteThreadToggled"
:disabled="quoteFormVisible"
@update:model-value="e => quoteThreadToggled = !e"
>
{{ $t('post_status.reply_option') }}
</Checkbox>
</div>
</div>
<div
v-if="showPreview"
class="preview-container"
>
<div
v-if="!preview"
class="preview-status"
>
{{ $t('general.loading') }}
</div>
<div
v-else-if="preview.error"
class="preview-status preview-error"
>
{{ preview.error }}
</div>
<StatusContent
v-else
:status="preview"
class="preview-status"
/>
</div>
<div class="input inputs-wrapper">
<EmojiInput
v-if="!disableSubject && (newStatus.spoilerText || alwaysShowSubject)"
v-model="newStatus.spoilerText"
enable-emoji-picker
:suggest="emojiSuggestor"
class="input form-control subject-input unstyled"
>
<template #default="inputProps">
<input
v-model="newStatus.spoilerText"
type="text"
:placeholder="$t('post_status.content_warning')"
:disabled="posting && !optimisticPosting"
v-bind="propsToNative(inputProps)"
size="1"
class="input form-post-subject unstyled"
>
</template>
</EmojiInput>
<input
v-if="mentionsLine"
:value="mentionsLineReadOnly ? mentionsString : newStatus.mentionsLine"
- @change="onMentionsLineUpdate"
type="text"
:placeholder="$t('post_status.mentions_line')"
:disabled="mentionsLineReadOnly || (posting && !optimisticPosting)"
size="1"
class="input mentions-input form-post-mentions unstyled"
+ @change="onMentionsLineUpdate"
>
<EmojiInput
ref="emoji-input"
v-model="newStatus.status"
:suggest="emojiUserSuggestor"
:placement="emojiPickerPlacement"
class="input form-control main-input unstyled"
enable-sticker-picker
enable-emoji-picker
:newline-on-ctrl-enter="submitOnEnter"
@input="onEmojiInputInput"
@sticker-uploaded="addMediaFile"
@sticker-upload-failed="uploadFailed"
@shown="handleEmojiInputShow"
>
<template #default="inputProps">
<textarea
ref="textarea"
v-model="newStatus.status"
:placeholder="placeholder || $t('post_status.default')"
rows="1"
cols="1"
:disabled="posting && !optimisticPosting"
class="input form-post-body"
:class="{ 'scrollable-form': !!maxHeight }"
v-bind="propsToNative(inputProps)"
@keydown.exact.enter="submitOnEnter && postStatus($event)"
@keydown.meta.enter="postStatus($event, newStatus)"
@keydown.ctrl.enter="!submitOnEnter && postStatus($event)"
@input="resize"
@compositionupdate="resize"
@paste="paste"
/>
<p
v-if="hasStatusLengthLimit"
class="character-counter faint"
:class="{ error: isOverLengthLimit }"
>
{{ charactersLeft }}
</p>
</template>
</EmojiInput>
</div>
<div
v-if="!disableScopeSelector"
class="visibility-tray"
>
<scope-selector
v-if="!disableVisibilitySelector"
ref="scopeSelector"
:show-all="showAllScopes"
:user-default="userDefaultScope"
- :original-scope="newStatus.visibility"
+ :original-scope="repliedStatus?.visibility"
:initial-scope="newStatus.visibility"
@change="changeVis"
/>
<div
v-if="postFormats.length > 1"
class="text-format"
>
<Select
v-model="newStatus.contentType"
class="input form-control unstyled"
:attrs="{ 'aria-label': $t('post_status.content_type_selection') }"
unstyled="true"
>
<option
v-for="postFormat in postFormats"
:key="postFormat"
:value="postFormat"
>
{{ $t(`post_status.content_type["${postFormat}"]`) }}
</option>
</Select>
</div>
<div
v-if="postFormats.length === 1 && postFormats[0] !== 'text/plain'"
class="text-format"
>
<span class="only-format">
{{ $t(`post_status.content_type["${postFormats[0]}"]`) }}
</span>
</div>
</div>
</div>
<PollForm
v-if="pollsAvailable"
ref="pollForm"
- :visible="pollFormVisible"
v-model="newStatus.poll"
+ :visible="pollFormVisible"
/>
<QuoteForm
v-if="quotingAvailable"
+ :id="newStatus.quote?.id"
ref="quoteForm"
:visible="quoteFormVisible"
- :id="newStatus.quote?.id"
:url="newStatus.quote?.url"
@update:url="url => newStatus.quote.url = url"
@update:id="id => newStatus.quote.id = id"
/>
<span
v-if="!disableDraft && shouldAutoSaveDraft"
class="auto-save-status"
>
{{ autoSaveState }}
</span>
<div
ref="bottom"
class="form-bottom"
>
<div class="form-bottom-left">
<media-upload
ref="mediaUpload"
class="bottom-left-button media-upload-icon"
:drop-files="dropFiles"
:disabled="uploadFileLimitReached"
@uploading="startedUploadingFiles"
@uploaded="addMediaFile"
@upload-failed="uploadFailed"
@all-uploaded="finishedUploadingFiles"
/>
<button
v-if="pollsAvailable"
class="bottom-left-button poll-icon button-unstyled"
:class="{ toggled: pollFormVisible }"
:title="$t('polls.add_poll')"
@click="togglePollForm"
>
<FAIcon icon="poll-h" />
</button>
<button
v-if="quotingAvailable"
class="bottom-left-button quote-icon button-unstyled"
:disabled="quoteThreadToggled"
:class="{ toggled: quoteFormVisible }"
:title="$t('tool_tip.add_quote')"
@click="toggleQuoteForm"
>
<FAIcon icon="quote-right" />
</button>
</div>
<div class="btn-group post-button-group">
<button
class="btn button-default post-button"
:disabled="isOverLengthLimit || posting || uploadingFiles || disableSubmit"
@click.stop.prevent="postStatus($event)"
>
<template v-if="posting">
{{ $t('post_status.posting') }}
</template>
<template v-else>
{{ $t('post_status.post') }}
</template>
</button>
<Popover
v-if="!hideExtraActions"
class="more-post-actions"
:normal-button="true"
trigger="click"
placement="bottom"
:offset="{ y: 5 }"
:trigger-attrs="{ 'aria-label': $t('post_status.more_post_actions') }"
>
<template #trigger>
<FAIcon
class="fa-scale-110 fa-old-padding"
icon="chevron-down"
/>
</template>
<template #content="{close}">
<div
class="dropdown-menu"
role="menu"
>
<div
class="menu-item dropdown-item"
:class="{ disabled: !safeToSaveDraft }"
>
<button
v-if="!hideDraft || !disableDraft"
class="main-button"
role="menu"
:disabled="!safeToSaveDraft"
@click.prevent="saveDraft"
@click="close"
>
<template v-if="closeable">
{{ $t('post_status.save_to_drafts_and_close_button') }}
</template>
<template v-else>
{{ $t('post_status.save_to_drafts_button') }}
</template>
</button>
</div>
</div>
</template>
</Popover>
</div>
</div>
<small class="keyboard-enter-hint faint">
<i v-if="submitOnEnter">
{{ $t('post_status.enter_submits') }}
</i>
<i v-else>
{{ $t('post_status.enter_newline') }}
</i>
</small>
<div
v-show="showDropIcon !== 'hide'"
:style="{ animation: showDropIcon === 'show' ? 'fade-in 0.25s' : 'fade-out 0.5s' }"
class="drop-indicator"
@dragleave="fileDragStop"
@drop.stop="fileDrop"
>
<FAIcon :icon="uploadFileLimitReached ? 'ban' : 'upload'" />
</div>
<div
v-if="error"
class="alert error -dismissible"
>
<span>
{{ error }}
</span>
<button
class="button-unstyled"
@click="clearError"
>
<FAIcon
class="fa-scale-110 fa-old-padding"
icon="times"
/>
</button>
</div>
<Gallery
v-if="newStatus.files && newStatus.files.length > 0"
class="attachments"
:grid="true"
:nsfw="false"
:attachments="newStatus.files"
:descriptions="newStatus.mediaDescriptions"
:set-media="() => setMedia()"
:editable="true"
:edit-attachment="editAttachment"
:remove-attachment="removeMediaFile"
:shift-up-attachment="newStatus.files.length > 1 && shiftUpMediaFile"
:shift-dn-attachment="newStatus.files.length > 1 && shiftDnMediaFile"
@play="$emit('mediaplay', 'newStatus')"
@pause="$emit('mediapause', 'newStatus')"
/>
<div
v-if="newStatus.files.length > 0 && !disableSensitivityCheckbox"
class="upload_settings"
>
<Checkbox v-model="newStatus.nsfw">
{{ $t('post_status.attachments_sensitive') }}
</Checkbox>
</div>
</form>
<DraftCloser
ref="draftCloser"
@save="saveAndCloseDraft"
@discard="discardAndCloseDraft"
/>
</div>
</template>
<script src="./post_status_form.js"></script>
<style src="./post_status_form.scss" lang="scss"></style>
diff --git a/src/components/post_status_modal/post_status_modal.js b/src/components/post_status_modal/post_status_modal.js
index 973c2b1a58..e7001db9a2 100644
--- a/src/components/post_status_modal/post_status_modal.js
+++ b/src/components/post_status_modal/post_status_modal.js
@@ -1,60 +1,58 @@
import { get } from 'lodash'
import Modal from 'src/components/modal/modal.vue'
import PostStatusForm from 'src/components/post_status_form/post_status_form.vue'
import { usePostStatusStore } from 'src/stores/post_status.js'
const PostStatusModal = {
components: {
PostStatusForm,
Modal,
},
data() {
return {
resettingForm: false,
}
},
computed: {
isLoggedIn() {
return !!this.$store.state.users.currentUser
},
modalActivated() {
return usePostStatusStore().modalActivated
},
isFormVisible() {
return this.isLoggedIn && !this.resettingForm && this.modalActivated
},
params() {
return usePostStatusStore().params || {}
},
},
watch: {
params(newVal, oldVal) {
if (get(newVal, 'repliedUser.id') !== get(oldVal, 'repliedUser.id')) {
this.resettingForm = true
this.$nextTick(() => {
this.resettingForm = false
})
}
},
isFormVisible(val) {
if (val) {
- this.$nextTick(
- () => this.$el && this.$el.querySelector('textarea').focus(),
- )
+ this.$nextTick(() => this.$el?.querySelector('textarea').focus())
}
},
},
methods: {
closeModal() {
usePostStatusStore().closePostStatusModal()
},
resetAndClose() {
usePostStatusStore().resetPostStatusModal()
usePostStatusStore().closePostStatusModal()
},
},
}
export default PostStatusModal
diff --git a/src/components/quote/quote_form.js b/src/components/quote/quote_form.js
index cd6f9a7092..b350fb0e3c 100644
--- a/src/components/quote/quote_form.js
+++ b/src/components/quote/quote_form.js
@@ -1,120 +1,120 @@
import { debounce } from 'lodash'
import Checkbox from 'src/components/checkbox/checkbox.vue'
import Quote from './quote.vue'
import { useInstanceStore } from 'src/stores/instance.js'
export default {
components: {
Quote,
Checkbox,
},
name: 'QuoteForm',
props: {
visible: {
type: Boolean,
},
url: {
type: String,
required: false,
default: '',
},
id: {
type: String,
required: false,
default: '',
},
},
data() {
return {
text: this.url,
loading: false,
error: false,
debounceSetQuote: debounce((value) => {
this.fetchStatus(value)
}, 1000),
}
},
created() {
if (this.url && !this.id) {
this.fetchStatus(this.url)
} else if (this.id) {
this.text =
window.location.protocol +
'//' +
this.instanceHost +
'/notice/' +
this.id
}
},
computed: {
instanceHost() {
return new URL(useInstanceStore().server).host
},
noticeRegex() {
return new RegExp(
`^([^/:]*:?//|)(${window.location.host}|${this.instanceHost})/notice/(.*)$`,
)
},
quoteVisible() {
return (!!this.id || this.loading) && !this.error
},
},
watch: {
text(value) {
this.debounceSetQuote(value)
this.$emit('update:url', value)
},
visible(value) {
if (value && this.url) {
this.fetchStatus(this.url)
}
},
},
methods: {
clear() {
this.text = this.url
this.loading = false
this.error = false
},
setLoading(value) {
this.loading = value
},
handleError(error) {
this.id = null
this.error = !!error
},
fetchStatus(value) {
this.error = false
const notice = this.noticeRegex.exec(value)
if (notice?.length === 4) {
this.$emit('update:id', notice[3])
} else if (value) {
this.loading = true
this.$store
.dispatch('search', {
q: value,
resolve: true,
offset: 0,
limit: 1,
type: 'statuses',
})
.then((data) => {
- if (data?.statuses && data.statuses.length === 1) {
+ if (data?.statuses?.length === 1) {
this.$emit('update:id', data.statuses[0].id)
} else {
this.handleError(true)
}
})
.catch(this.handleError)
.finally(() => {
this.loading = false
})
} else {
this.$emit('update:id', null)
}
},
},
}
diff --git a/src/components/quote/quote_form.vue b/src/components/quote/quote_form.vue
index 2e7200d7f2..a019bdda8c 100644
--- a/src/components/quote/quote_form.vue
+++ b/src/components/quote/quote_form.vue
@@ -1,55 +1,56 @@
<template>
<div
v-if="visible"
class="quote-form"
>
<div class="input-container">
<input
v-model="text"
type="text"
size="1"
class="input"
:placeholder="$t('post_status.quote_url')"
+ :aria-label="$t('post_status.quote_url')"
>
</div>
<Quote
:status-id="id"
:status-url="url"
:status-visible="quoteVisible"
:initially-expanded="true"
:loading="loading"
@loading="setLoading"
@error="handleError"
/>
</div>
</template>
<script src="./quote_form.js"></script>
<style lang="scss">
.quote-form {
display: flex;
flex-direction: column;
padding: 0 0.5em 0.5em;
.input-container {
width: 100%;
display: flex;
flex-direction: row;
input {
width: 100%;
}
.checkbox {
white-space: pre;
display: flex;
flex-flow: row-reverse;
line-height: 2;
column-gap: 0.5em;
align-items: center;
user-select: none;
}
}
}
</style>
diff --git a/src/components/range_input/range_input.vue b/src/components/range_input/range_input.vue
index 40c427784e..9fb7c69fe0 100644
--- a/src/components/range_input/range_input.vue
+++ b/src/components/range_input/range_input.vue
@@ -1,75 +1,75 @@
<template>
<div
class="range-control style-control"
:class="{ disabled: !present || disabled }"
>
<label
:id="name + '-label'"
:for="name"
class="label"
>
{{ label }}
</label>
<input
- v-if="typeof fallback !== 'undefined'"
+ v-if="fallback !== undefined"
:id="name + '-o'"
:aria-labelledby="name + '-label'"
class="input -checkbox opt visible-for-screenreader-only"
type="checkbox"
:checked="present"
@change="$emit('update:modelValue', !present ? fallback : undefined)"
>
<label
- v-if="typeof fallback !== 'undefined'"
+ v-if="fallback !== undefined"
class="opt-l"
:for="name + '-o'"
:aria-hidden="true"
/>
<input
:id="name"
class="input input-number"
type="range"
:value="modelValue || fallback"
:disabled="!present || disabled"
:max="max || hardMax || 100"
:min="min || hardMin || 0"
:step="step || 1"
@input="$emit('update:modelValue', $event.target.value)"
>
<input
:id="name + '-numeric'"
class="input input-number"
type="number"
:aria-labelledby="name + '-label'"
:value="modelValue || fallback"
:disabled="!present || disabled"
:max="hardMax"
:min="hardMin"
:step="step || 1"
@input="$emit('update:modelValue', $event.target.value)"
>
</div>
</template>
<script>
export default {
props: [
'name',
'modelValue',
'fallback',
'disabled',
'label',
'max',
'min',
'step',
'hardMin',
'hardMax',
],
emits: ['update:modelValue'],
computed: {
present() {
return this.modelValue !== undefined
},
},
}
</script>
diff --git a/src/components/registration/registration.js b/src/components/registration/registration.js
index 60f0fd16bb..ea07c662b7 100644
--- a/src/components/registration/registration.js
+++ b/src/components/registration/registration.js
@@ -1,162 +1,162 @@
import useVuelidate from '@vuelidate/core'
import { required, requiredIf, sameAs } from '@vuelidate/validators'
import { mapState as mapPiniaState } from 'pinia'
import { mapActions, mapState } from 'vuex'
import InterfaceLanguageSwitcher from 'src/components/interface_language_switcher/interface_language_switcher.vue'
import TermsOfServicePanel from 'src/components/terms_of_service_panel/terms_of_service_panel.vue'
import localeService from '../../services/locale/locale.service.js'
import { useInstanceStore } from 'src/stores/instance.js'
import { DAY } from 'src/services/date_utils/date_utils.js'
const registration = {
setup() {
return { v$: useVuelidate() }
},
data: () => ({
user: {
email: '',
fullname: '',
username: '',
password: '',
confirm: '',
birthday: '',
reason: '',
language: [''],
},
captcha: {},
}),
components: {
InterfaceLanguageSwitcher,
TermsOfServicePanel,
},
validations() {
return {
user: {
email: { required: requiredIf(() => this.accountActivationRequired) },
username: { required },
fullname: { required },
password: { required },
confirm: {
required,
sameAs: sameAs(this.user.password),
},
birthday: {
required: requiredIf(() => this.birthdayRequired),
maxValue: (value) => {
return (
!this.birthdayRequired ||
new Date(value).getTime() <= this.birthdayMin.getTime()
)
},
},
reason: { required: requiredIf(() => this.accountApprovalRequired) },
language: {},
},
}
},
created() {
if ((!this.registrationOpen && !this.token) || this.signedIn) {
this.$router.push({ name: 'root' })
}
this.setCaptcha()
},
computed: {
token() {
return this.$route.params.token
},
bioPlaceholder() {
return this.replaceNewlines(this.$t('registration.bio_placeholder'))
},
reasonPlaceholder() {
return this.replaceNewlines(this.$t('registration.reason_placeholder'))
},
birthdayMin() {
const minAge = this.birthdayMinAge
const today = new Date()
today.setUTCMilliseconds(0)
today.setUTCSeconds(0)
today.setUTCMinutes(0)
today.setUTCHours(0)
const minDate = new Date()
minDate.setTime(today.getTime() - minAge * DAY)
return minDate
},
birthdayMinAttr() {
return this.birthdayMin.toJSON().replace(/T.+$/, '')
},
birthdayMinFormatted() {
const browserLocale = localeService.internalToBrowserLocale(
this.$i18n.locale,
)
return (
this.user.birthday &&
new Date(Date.parse(this.birthdayMin)).toLocaleDateString(
browserLocale,
{ timeZone: 'UTC', day: 'numeric', month: 'long', year: 'numeric' },
)
)
},
...mapPiniaState(useInstanceStore, {
registrationOpen: (store) => store.registrationOpen,
embeddedToS: (store) => store.embeddedToS,
termsOfService: (store) => store.tos,
accountActivationRequired: (store) => store.accountActivationRequired,
accountApprovalRequired: (store) => store.accountApprovalRequired,
birthdayRequired: (store) => store.birthdayRequired,
birthdayMinAge: (store) => store.birthdayMinAge,
}),
...mapState({
signedIn: (state) => !!state.users.currentUser,
isPending: (state) => state.users.signUpPending,
serverValidationErrors: (state) => state.users.signUpErrors,
signUpNotice: (state) => state.users.signUpNotice,
hasSignUpNotice: (state) => !!state.users.signUpNotice.message,
}),
},
methods: {
...mapActions(['signUp', 'getCaptcha']),
async submit() {
this.user.nickname = this.user.username
this.user.token = this.token
this.user.captcha_solution = this.captcha.solution
this.user.captcha_token = this.captcha.token
this.user.captcha_answer_data = this.captcha.answer_data
if (this.user.language) {
this.user.language = localeService.internalToBackendLocaleMulti(
- this.user.language.filter((k) => k),
+ this.user.language.filter(Boolean),
)
}
this.v$.$touch()
if (!this.v$.$invalid) {
try {
const status = await this.signUp(this.user)
if (status === 'ok') {
this.$router.push({ name: 'friends' })
}
// If status is not 'ok' (i.e. it needs further actions to be done
// before you can login), display sign up notice, do not switch anywhere
} catch (error) {
console.warn('Registration failed: ', error)
this.setCaptcha()
}
}
},
setCaptcha() {
this.getCaptcha().then((cpt) => {
this.captcha = cpt
})
},
replaceNewlines(str) {
- return str.replace(/\s*\n\s*/g, ' \n')
+ return str.replaceAll(/\s*\n\s*/g, ' \n')
},
},
}
export default registration
diff --git a/src/components/rich_content/rich_content.jsx b/src/components/rich_content/rich_content.jsx
index 646df5bab8..f80f153b1a 100644
--- a/src/components/rich_content/rich_content.jsx
+++ b/src/components/rich_content/rich_content.jsx
@@ -1,566 +1,562 @@
import { flattenDeep, unescape as ldUnescape } from 'lodash'
import HashtagLink from 'src/components/hashtag_link/hashtag_link.vue'
import { MENTIONS_LIMIT } from 'src/components/mentions_line/mentions_line.js'
import MentionsLine from 'src/components/mentions_line/mentions_line.vue'
import StillImage from 'src/components/still-image/still-image.vue'
import StillImageEmojiPopover from 'src/components/still-image/still-image-emoji-popover.vue'
import { convertHtmlToLines } from 'src/services/html_converter/html_line_converter.service.js'
import { convertHtmlToTree } from 'src/services/html_converter/html_tree_converter.service.js'
import {
getAttrs,
getTagName,
processTextForEmoji,
} from 'src/services/html_converter/utility.service.js'
import './rich_content.scss'
const MAYBE_LINE_BREAKING_ELEMENTS = [
'blockquote',
'br',
'hr',
'ul',
'ol',
'li',
'p',
'table',
'tbody',
'td',
'th',
'thead',
'tr',
'h1',
'h2',
'h3',
'h4',
'h5',
]
/**
* RichContent, The Über-powered component for rendering Post HTML.
*
* This takes post HTML and does multiple things to it:
* - Groups all mentions into <MentionsLine>, this affects all mentions regardles
* of where they are (beginning/middle/end), even single mentions are converted
* to a <MentionsLine> containing single <MentionLink>.
* - Replaces emoji shortcodes with <StillImage>'d images.
*
* There are two problems with this component's architecture:
* 1. Parsing HTML and rendering are inseparable. Attempts to separate the two
* proven to be a massive overcomplication due to amount of things done here.
* 2. We need to output both render and some extra data, which seems to be imp-
* possible in vue. Current solution is to emit 'parseReady' event when parsing
* is done within render() function.
*
* Apart from that one small hiccup with emit in render this _should_ be vue3-ready
*/
export default {
name: 'RichContent',
components: {
MentionsLine,
HashtagLink,
},
props: {
// Original html content
html: {
required: true,
type: String,
},
attentions: {
required: false,
default: () => [],
},
// Emoji object, as in status.emojis, note the "s" at the end...
emoji: {
required: true,
type: Array,
},
// Whether to handle links or not (posts: yes, everything else: no)
handleLinks: {
required: false,
type: Boolean,
default: false,
},
// Meme arrows
greentext: {
required: false,
type: Boolean,
default: false,
},
// Faint style (for notifs)
faint: {
required: false,
type: Boolean,
default: false,
},
// Collapse newlines
collapse: {
required: false,
type: Boolean,
default: false,
},
/* Content comes from current instance
*
* This is used for emoji stealing popover.
* By default we assume it is, so that steal
* emoji button isn't shown where it probably
* should not be.
*/
isLocal: {
required: false,
type: Boolean,
default: true,
},
// Allow wide emoji (max 3:1 ratio)
allowNonSquareEmoji: {
required: false,
type: Boolean,
default: false,
},
pauseMfm: {
required: false,
type: Boolean,
default: false,
},
scaleMfm: {
required: false,
type: Boolean,
default: false,
},
},
// NEVER EVER TOUCH DATA INSIDE RENDER
render() {
// Pre-process HTML
const { newHtml: html } = preProcessPerLine(this.html, this.greentext)
let currentMentions = null // Current chain of mentions, we group all mentions together
// This is used to recover spacing removed when parsing mentions
let lastSpacing = ''
const lastTags = [] // Tags that appear at the end of post body
const writtenMentions = [] // All mentions that appear in post body
const invisibleMentions = [] // All mentions that go beyond the limiter (see MentionsLine)
// to collapse too many mentions in a row
const writtenTags = [] // All tags that appear in post body
// unique index for vue "tag" property
let mentionIndex = 0
let tagsIndex = 0
const renderImage = (tag) => {
return <StillImage {...getAttrs(tag)} class="img" />
}
const renderHashtag = (attrs, children, encounteredTextReverse) => {
const { index, ...linkData } = getLinkData(attrs, children, tagsIndex++)
writtenTags.push(linkData)
if (!encounteredTextReverse) {
lastTags.push(linkData)
}
const { url, tag, content } = linkData
return <HashtagLink url={url} tag={tag} content={content} />
}
const renderMention = (attrs, children) => {
const linkData = getLinkData(attrs, children, mentionIndex++)
linkData.notifying = this.attentions.some(
(a) => a.statusnet_profile_url === linkData.url,
)
writtenMentions.push(linkData)
if (currentMentions === null) {
currentMentions = []
}
currentMentions.push(linkData)
if (currentMentions.length > MENTIONS_LIMIT) {
invisibleMentions.push(linkData)
}
if (currentMentions.length === 1) {
return <MentionsLine mentions={currentMentions} />
} else {
return ''
}
}
// Processor to use with html_tree_converter
- const processItem = (item, index, array, what) => {
+ const processItem = (item, index, array) => {
// Handle text nodes - just add emoji
if (typeof item === 'string') {
const emptyText = item.trim() === ''
if (item.includes('\n')) {
currentMentions = null
}
if (emptyText) {
// don't include spaces when processing mentions - we'll include them
// in MentionsLine
lastSpacing = item
// Don't remove last space in a container (fixes poast mentions)
return index !== array.length - 1 && currentMentions !== null
? item.trim()
: item
}
currentMentions = null
if (item.includes(':')) {
item = [
'',
processTextForEmoji(item, this.emoji, ({ shortcode, url }) => {
return (
<StillImageEmojiPopover
class="emoji img"
src={url}
title={`:${shortcode}:`}
alt={`:${shortcode}:`}
shortcode={shortcode}
isLocal={this.isLocal}
/>
)
}),
]
}
return item
}
// Handle tag nodes
if (Array.isArray(item)) {
const [opener, children, closer] = item
let Tag = getTagName(opener)
if (Tag.toLowerCase() === 'script') Tag = 'js-exploit'
if (Tag.toLowerCase() === 'style') Tag = 'css-exploit'
const fullAttrs = getAttrs(opener, () => true)
const attrs = getAttrs(opener)
const previouslyMentions = currentMentions !== null
/* During grouping of mentions we trim all the empty text elements
* This padding is added to recover last space removed in case
* we have a tag right next to mentions
*/
const mentionsLinePadding =
// Padding is only needed if we just finished parsing mentions
previouslyMentions &&
// Don't add padding if content is string and has padding already
!(
children &&
typeof children[0] === 'string' &&
children[0].match(/^\s/)
)
? lastSpacing
: ''
if (MAYBE_LINE_BREAKING_ELEMENTS.includes(Tag)) {
// all the elements that can cause a line change
currentMentions = null
} else if (Tag === 'img') {
// replace images with StillImage
return ['', [mentionsLinePadding, renderImage(opener)], '']
} else if (Tag === 'a' && this.handleLinks) {
// replace mentions with MentionLink
- if (fullAttrs.class && fullAttrs.class.includes('mention')) {
+ if (fullAttrs.class?.includes('mention')) {
// Handling mentions here
return renderMention(attrs, children)
} else {
currentMentions = null
}
} else if (Tag === 'span') {
- if (
- this.handleLinks &&
- fullAttrs.class &&
- fullAttrs.class.includes('h-card')
- ) {
+ if (this.handleLinks && fullAttrs.class?.includes('h-card')) {
return ['', children.map(processItem), '']
}
}
if (children !== undefined) {
return [
'',
[mentionsLinePadding, [opener, children.map(processItem), closer]],
'',
]
} else {
return ['', [mentionsLinePadding, item], '']
}
}
}
// Processor for back direction (for finding "last" stuff, just easier this way)
let encounteredTextReverse = false
- const processItemReverse = (item, index, array, what) => {
+ const processItemReverse = (item, index, array) => {
// Handle text nodes - just add emoji
if (typeof item === 'string') {
const emptyText = item.trim() === ''
if (emptyText) return item
if (!encounteredTextReverse) encounteredTextReverse = true
return ldUnescape(item)
} else if (Array.isArray(item)) {
// Handle tag nodes
const [opener, children] = item
const Tag = opener === '' ? '' : getTagName(opener)
switch (Tag) {
case 'a': {
// replace mentions with MentionLink
if (!this.handleLinks) break
const fullAttrs = getAttrs(opener, () => true)
const attrs = getAttrs(opener, () => true)
// should only be this
if (
- (fullAttrs.class && fullAttrs.class.includes('hashtag')) || // Pleroma style
+ fullAttrs.class?.includes('hashtag') || // Pleroma style
fullAttrs.rel === 'tag' // Mastodon style
) {
return renderHashtag(attrs, children, encounteredTextReverse)
} else {
attrs.target = '_blank'
const newChildren = [...children]
.reverse()
.map(processItemReverse)
.reverse()
return <a {...attrs}>{newChildren}</a>
}
}
case '':
return [...children].reverse().map(processItemReverse).reverse()
}
// Render tag as is
if (children !== undefined) {
const newChildren = Array.isArray(children)
? [...children].reverse().map(processItemReverse).reverse()
: children
const attrs = getAttrs(opener)
const newAttrs = { ...attrs }
const fullAttrs = getAttrs(opener, () => true)
const classname = fullAttrs['class']
const isMFM = classname?.startsWith('mfm-')
if (isMFM) {
const mfmOperator = /^mfm-(\w+)$/.exec(classname)?.[1]
newAttrs['class'] = [
'mfm',
this.pauseMfm ? '-pause' : '',
this.scaleMfm ? '-scale' : '',
]
.filter(Boolean)
.join(' ')
newAttrs['data-mfm-operator'] = mfmOperator
switch (mfmOperator) {
case 'position': {
const x = Number.parseFloat(fullAttrs['data-mfm-x']) || 0
const y = Number.parseFloat(fullAttrs['data-mfm-y']) || 0
newAttrs.style = [
'transform:',
`translate(calc(${x} * (var(--emoji-size) / 2)), `,
`calc(${y} * (var(--emoji-size) / 2)))`,
].join(' ')
break
}
case 'scale': {
const x = Number.parseFloat(fullAttrs['data-mfm-x']) || 1
const y = Number.parseFloat(fullAttrs['data-mfm-y']) || 1
newAttrs.style = ['transform:', `scale(${x}, ${y})`].join(' ')
break
}
case 'rotate': {
const deg = Number.parseFloat(fullAttrs['data-mfm-deg']) || 0
newAttrs.style = [
`transform: rotate(${deg}deg)`,
'transform-origin: center',
].join(';')
break
}
case 'bg': {
const color = fullAttrs['data-mfm-color'] || 0
newAttrs.style = [`background-color: #${color}`].join(' ')
break
}
case 'fg': {
const color = fullAttrs['data-mfm-color'] || 0
newAttrs.style = [`color: #${color}`].join(';')
break
}
case 'spin': {
const speed = fullAttrs['data-mfm-speed'] || '1s'
const delay = fullAttrs['data-mfm-delay'] || 0
const left = fullAttrs['data-mfm-left'] != null
const alternate = fullAttrs['data-mfm-alternate'] != null
const y = fullAttrs['data-mfm-y'] != null
const x = fullAttrs['data-mfm-x'] != null
const anim = [
x ? 'mfm-spinX' : null,
y ? 'mfm-spinY' : null,
'mfm-spin',
- ].filter((a) => a)[0]
+ ].filter(Boolean)[0]
const direction = [
alternate ? 'alternate' : null,
left ? 'reverse' : null,
'normal',
- ].filter((a) => a)[0]
+ ].filter(Boolean)[0]
newAttrs.style = [
`animation-name: ${anim}`,
`animation-duration: ${speed}`,
'animation-iteration-count: infinite',
`animation-delay: ${delay}`,
`animation-direction: ${direction}`,
'animation-fill-mode: none',
'animation-timing-function: linear',
].join(';')
break
}
case 'flip': {
newAttrs.style = 'transform: scaleX(-1)'
break
}
case 'border': {
const width = fullAttrs['data-mfm-width'] || '0'
const style = fullAttrs['data-mfm-style'] || 'solid'
const color = fullAttrs['data-mfm-color'] || 'transparent'
const radius = fullAttrs['data-mfm-radius'] || '0'
const noclip = fullAttrs['data-mfm-noclip'] || false
newAttrs.style = [
`border: ${width} ${style} ${color}`,
`border-radius: ${radius}`,
`overflow: ${noclip ? 'visible' : 'clip'}`,
].join(';')
break
}
case 'tada':
case 'jelly':
case 'twitch':
case 'shake':
case 'jump':
case 'bounce':
case 'rainbow': {
const speed = fullAttrs['data-mfm-speed'] || '1s'
const delay = fullAttrs['data-mfm-delay'] || 0
const rules = [
`animation-name: mfm-${mfmOperator}`,
`animation-duration: ${speed}`,
'animation-iteration-count: infinite',
`animation-delay: ${delay}`,
'animation-direction: normal',
'animation-fill-mode: none',
'animation-timing-function: linear',
].join(';')
newAttrs.style = rules
break
}
case 'sparkle':
case 'x2':
case 'x3':
case 'x4':
// handled by css
break
default:
console.warn('Unsupported MFM operator:', mfmOperator, opener)
break
}
}
return <Tag {...newAttrs}>{newChildren}</Tag>
} else {
return <Tag />
}
}
return item
}
const pass1 = convertHtmlToTree(html).map(processItem)
const pass2 = [...pass1].reverse().map(processItemReverse).reverse()
// DO NOT USE SLOTS they cause a re-render feedback loop here.
// slots updated -> rerender -> emit -> update up the tree -> rerender -> ...
// at least until vue3?
const result = (
<span
class={[
'RichContent',
this.faint ? '-faint' : '',
this.allowNonSquareEmoji ? '-allow-non-square-emoji' : '',
]}
>
{this.collapse
? pass2.map((x) => {
- if (typeof x === 'string') return x.replace(/\n/g, ' ')
+ if (typeof x === 'string') return x.replaceAll('\n', ' ')
if (!Array.isArray(x)) return x
return x.map((y) => (y.type === 'br' ? ' ' : y))
})
: pass2}
</span>
)
const event = {
lastTags,
writtenMentions,
writtenTags,
invisibleMentions,
}
// DO NOT MOVE TO UPDATE. BAD IDEA.
this.$emit('parseReady', event)
return result
},
}
const getLinkData = (attrs, children, index) => {
const stripTags = (item) => {
if (typeof item === 'string') {
return item
} else {
return item[1].map(stripTags).join('')
}
}
const textContent = children.map(stripTags).join('')
return {
index,
url: attrs.href,
tag: attrs['data-tag'],
content: flattenDeep(children).join(''),
textContent,
}
}
/** Pre-processing HTML
*
* Currently this does one thing:
* - add green/cyantexting
*
* @param {String} html - raw HTML to process
* @param {Boolean} greentext - whether to enable greentexting or not
*/
export const preProcessPerLine = (html, greentext) => {
const greentextHandle = new Set(['p', 'div'])
const lines = convertHtmlToLines(html)
const newHtml = lines
.reverse()
.map((item, index, array) => {
if (!item.text) return item
const string = item.text
// Greentext stuff
if (
// Only if greentext is engaged
greentext &&
// Only handle p's and divs. Don't want to affect blockquotes, code etc
item.level.every((l) => greentextHandle.has(l)) &&
// Only if line begins with '>' or '<'
(string.includes('&gt;') || string.includes('&lt;'))
) {
const cleanedString = string
- .replace(/<[^>]+?>/gi, '') // remove all tags
- .replace(/@\w+/gi, '') // remove mentions (even failed ones)
+ .replaceAll(/<[^>]+?>/gi, '') // remove all tags
+ .replaceAll(/@\w+/gi, '') // remove mentions (even failed ones)
.trim()
if (cleanedString.startsWith('&gt;')) {
return `<span class='greentext'>${string}</span>`
} else if (cleanedString.startsWith('&lt;')) {
return `<span class='cyantext'>${string}</span>`
}
}
return string
})
.reverse()
.join('')
return { newHtml }
}
diff --git a/src/components/roundness_input/roundness_input.vue b/src/components/roundness_input/roundness_input.vue
index 36fa8fd474..ad735f2d97 100644
--- a/src/components/roundness_input/roundness_input.vue
+++ b/src/components/roundness_input/roundness_input.vue
@@ -1,49 +1,49 @@
<template>
<div
class="roundness-control style-control"
:class="{ disabled: !present || disabled }"
>
<label
:for="name"
class="label"
:class="{ faint: !present || disabled }"
>
{{ label }}
</label>
<Checkbox
- v-if="typeof fallback !== 'undefined'"
+ v-if="fallback !== undefined"
:model-value="present"
:disabled="disabled"
class="opt"
@update:model-value="$emit('update:modelValue', !present ? fallback : undefined)"
/>
<input
:id="name"
class="input input-number"
type="number"
:value="modelValue || fallback"
:disabled="!present || disabled"
:class="{ disabled: !present || disabled }"
max="999"
min="0"
step="1"
@input="$emit('update:modelValue', $event.target.value)"
>
</div>
</template>
<script>
import Checkbox from 'src/components/checkbox/checkbox.vue'
export default {
components: {
Checkbox,
},
props: ['name', 'label', 'modelValue', 'fallback', 'disabled'],
emits: ['update:modelValue'],
computed: {
present() {
return this.modelValue !== undefined
},
},
}
</script>
diff --git a/src/components/search/search.js b/src/components/search/search.js
index 0a1f779bb9..1a5f8b51d7 100644
--- a/src/components/search/search.js
+++ b/src/components/search/search.js
@@ -1,130 +1,130 @@
import { map, uniqBy } from 'lodash'
import Conversation from 'src/components/conversation/conversation.vue'
import FollowCard from 'src/components/follow_card/follow_card.vue'
import TabSwitcher from 'src/components/tab_switcher/tab_switcher.jsx'
import { library } from '@fortawesome/fontawesome-svg-core'
import { faCircleNotch, faSearch } from '@fortawesome/free-solid-svg-icons'
library.add(faCircleNotch, faSearch)
const Search = {
components: {
FollowCard,
Conversation,
TabSwitcher,
},
props: ['query'],
data() {
return {
loaded: false,
loading: false,
searchTerm: this.query || '',
userIds: [],
statuses: [],
hashtags: [],
currenResultTab: 'statuses',
statusesOffset: 0,
lastStatusFetchCount: 0,
lastQuery: '',
}
},
computed: {
users() {
return this.userIds.map((userId) => this.$store.getters.findUser(userId))
},
visibleStatuses() {
const allStatusesObject = this.$store.state.statuses.allStatusesObject
return this.statuses.filter(
(status) =>
allStatusesObject[status.id] && !allStatusesObject[status.id].deleted,
)
},
},
mounted() {
this.search(this.query)
},
watch: {
query(newValue) {
this.searchTerm = newValue
this.search(newValue)
},
},
methods: {
newQuery(query) {
this.$router.push({ name: 'search', query: { query } })
this.$refs.searchInput.focus()
},
search(query, searchType = null) {
if (!query) {
this.loading = false
return
}
this.loading = true
this.$refs.searchInput.blur()
if (this.lastQuery !== query) {
this.userIds = []
this.hashtags = []
this.statuses = []
this.statusesOffset = 0
this.lastStatusFetchCount = 0
}
this.$store
.dispatch('search', {
q: query,
resolve: true,
offset: this.statusesOffset,
type: searchType,
})
.then((data) => {
this.loading = false
const oldLength = this.statuses.length
// 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')
this.hashtags = this.hashtags.concat(data.hashtags)
this.currenResultTab = this.getActiveTab()
this.loaded = true
// Offset from whatever we already have
this.statusesOffset = this.statuses.length
// Because the amount of new statuses can actually be zero, compare to old lenght instead
this.lastStatusFetchCount = this.statuses.length - oldLength
this.lastQuery = query
})
},
resultCount(tabName) {
const length = this[tabName].length
return length === 0 ? '' : ` (${length})`
},
onResultTabSwitch(key) {
this.currenResultTab = key
},
getActiveTab() {
if (this.visibleStatuses.length > 0) {
return 'statuses'
} else if (this.users.length > 0) {
return 'people'
} else if (this.hashtags.length > 0) {
return 'hashtags'
}
return 'statuses'
},
lastHistoryRecord(hashtag) {
- return hashtag.history && hashtag.history[0]
+ return hashtag.history?.[0]
},
},
}
export default Search
diff --git a/src/components/settings_modal/admin_tabs/emoji_tab.js b/src/components/settings_modal/admin_tabs/emoji_tab.js
index 56361587de..33ead7c160 100644
--- a/src/components/settings_modal/admin_tabs/emoji_tab.js
+++ b/src/components/settings_modal/admin_tabs/emoji_tab.js
@@ -1,318 +1,318 @@
import Checkbox from 'components/checkbox/checkbox.vue'
import Popover from 'components/popover/popover.vue'
import Select from 'components/select/select.vue'
import StillImage from 'components/still-image/still-image.vue'
import { clone } from 'lodash'
import { defineAsyncComponent } from 'vue'
import TabSwitcher from 'src/components/tab_switcher/tab_switcher.jsx'
import EmojiEditingPopover from '../helpers/emoji_editing_popover.vue'
import ModifiedIndicator from '../helpers/modified_indicator.vue'
import SharedComputedObject from '../helpers/shared_computed_object.js'
import StringSetting from '../helpers/string_setting.vue'
import { useAdminSettingsStore } from 'src/stores/admin_settings.js'
import { useEmojiStore } from 'src/stores/emoji.js'
import { useInstanceStore } from 'src/stores/instance.js'
import { useInterfaceStore } from 'src/stores/interface.js'
import { library } from '@fortawesome/fontawesome-svg-core'
import {
faArrowsRotate,
faDownload,
faFolderOpen,
faServer,
} from '@fortawesome/free-solid-svg-icons'
library.add(faArrowsRotate, faFolderOpen, faDownload, faServer)
const EmojiTab = {
components: {
TabSwitcher,
StringSetting,
Checkbox,
StillImage,
Select,
Popover,
ConfirmModal: defineAsyncComponent(
() => import('src/components/confirm_modal/confirm_modal.vue'),
),
ModifiedIndicator,
EmojiEditingPopover,
},
data() {
return {
knownLocalPacks: {},
knownRemotePacks: {},
editedMetadata: {},
packName: '',
newPackName: '',
deleteModalVisible: false,
remotePackInstance: '',
remotePackDownloadAs: '',
remotePackURL: '',
remotePackFile: null,
}
},
provide() {
return { emojiAddr: this.emojiAddr }
},
computed: {
...SharedComputedObject(),
pack() {
return this.packName !== '' ? this.knownPacks[this.packName] : undefined
},
packMeta() {
if (this.packName === '') return {}
if (this.editedMetadata[this.packName] === undefined) {
this.editedMetadata[this.packName] = clone(this.pack.pack)
}
return this.editedMetadata[this.packName]
},
knownPacks() {
// Copy the object itself but not the children, so they are still passed by reference and modified
const result = clone(this.knownLocalPacks)
for (const instName in this.knownRemotePacks) {
for (const instPack in this.knownRemotePacks[instName]) {
result[`${instPack}@${instName}`] =
this.knownRemotePacks[instName][instPack]
}
}
return result
},
downloadWillReplaceLocal() {
return (
(this.remotePackDownloadAs.trim() === '' &&
this.pack.remote &&
this.pack.remote.baseName in this.knownLocalPacks) ||
this.remotePackDownloadAs in this.knownLocalPacks
)
},
},
methods: {
reloadEmoji() {
useAdminSettingsStore().reloadEmoji()
},
importFromFS() {
useAdminSettingsStore().importEmojiFromFS()
},
emojiAddr(name) {
if (this.pack.remote !== undefined) {
// Remote pack
return `${this.pack.remote.instance}/emoji/${encodeURIComponent(this.pack.remote.baseName)}/${name}`
} else {
return `${useInstanceStore().server}/emoji/${encodeURIComponent(this.packName)}/${name}`
}
},
createEmojiPack() {
useAdminSettingsStore()
.createEmojiPack({ name: this.newPackName })
.then((resp) => resp.json())
.then((resp) => {
if (resp === 'ok') {
return this.refreshPackList()
} else {
this.displayError(resp.error)
- return Promise.reject(resp)
+ throw new Error(resp)
}
})
.then(() => {
this.packName = this.newPackName
this.newPackName = ''
})
},
deleteEmojiPack() {
useAdminSettingsStore()
.deleteEmojiPack({ name: this.packName })
.then((resp) => resp.json())
.then((resp) => {
if (resp === 'ok') {
return this.refreshPackList()
} else {
this.displayError(resp.error)
- return Promise.reject(resp)
+ throw new Error(resp)
}
})
.then(() => {
delete this.editedMetadata[this.packName]
this.deleteModalVisible = false
this.packName = ''
})
},
metaEdited(prop) {
if (!this.pack) return
const def = this.pack.pack[prop] || ''
const edited = this.packMeta[prop] || ''
return edited !== def
},
savePackMetadata() {
useAdminSettingsStore()
.saveEmojiPackMetadata({ name: this.packName, newData: this.packMeta })
.then((resp) => resp.json())
.then((resp) => {
if (resp.error !== undefined) {
this.displayError(resp.error)
return
}
// Update actual pack data
this.pack.pack = resp
// Delete edited pack data, should auto-update itself
delete this.editedMetadata[this.packName]
})
},
updatePackFiles(newFiles, packName) {
this.knownPacks[packName].files = newFiles
this.sortPackFiles(packName)
},
refreshPackList() {
useEmojiStore()
.getAdminPacks(
this.remotePackInstance,
useAdminSettingsStore().listEmojiPacks,
)
.then((allPacks) => {
this.knownLocalPacks = allPacks
for (const name of Object.keys(this.knownLocalPacks)) {
this.sortPackFiles(name)
}
})
},
listRemotePacks() {
useEmojiStore()
.getAdminPacks(
this.remotePackInstance,
useAdminSettingsStore().listRemoteEmojiPacks,
)
.then((allPacks) => {
let inst = this.remotePackInstance
if (!inst.startsWith('http')) {
inst = 'https://' + inst
}
const instUrl = new URL(inst)
inst = instUrl.host
for (const packName in allPacks) {
allPacks[packName].remote = {
baseName: packName,
instance: instUrl.origin,
}
}
this.knownRemotePacks[inst] = allPacks
for (const pack in this.knownRemotePacks[inst]) {
this.sortPackFiles(`${pack}@${inst}`)
}
})
.catch((data) => {
this.displayError(data)
})
},
downloadRemotePack() {
if (this.remotePackDownloadAs.trim() === '') {
this.remotePackDownloadAs = this.pack.remote.baseName
}
useAdminSettingsStore()
.downloadRemoteEmojiPack({
instance: this.pack.remote.instance,
packName: this.pack.remote.baseName,
as: this.remotePackDownloadAs,
})
.then((data) => data.json())
.then((resp) => {
if (resp === 'ok') {
return this.refreshPackList()
} else {
this.displayError(resp.error)
- return Promise.reject(resp)
+ throw new Error(resp)
}
})
.then(() => {
this.packName = this.remotePackDownloadAs
this.remotePackDownloadAs = ''
})
},
downloadRemoteURLPack() {
useAdminSettingsStore()
.downloadRemoteEmojiPackZIP({
url: this.remotePackURL,
packName: this.newPackName,
})
.then((data) => data.json())
.then((resp) => {
if (resp === 'ok') {
return this.refreshPackList()
} else {
this.displayError(resp.error)
- return Promise.reject(resp)
+ throw new Error(resp)
}
})
.then(() => {
this.packName = this.newPackName
this.newPackName = ''
this.remotePackURL = ''
})
},
downloadRemoteFilePack() {
useAdminSettingsStore()
.downloadRemoteEmojiPackZIP({
file: this.remotePackFile[0],
packName: this.newPackName,
})
.then((data) => data.json())
.then((resp) => {
if (resp === 'ok') {
return this.refreshPackList()
} else {
this.displayError(resp.error)
- return Promise.reject(resp)
+ throw new Error(resp)
}
})
.then(() => {
this.packName = this.newPackName
this.newPackName = ''
this.remotePackURL = ''
})
},
displayError(msg) {
useInterfaceStore().pushGlobalNotice({
messageKey: 'admin_dash.emoji.error',
messageArgs: [msg],
level: 'error',
})
},
sortPackFiles(nameOfPack) {
// Sort by key
const sorted = Object.keys(this.knownPacks[nameOfPack].files)
- .sort()
+ .sort((a, b) => a.localeCompare(b))
.reduce((acc, key) => {
if (key.length === 0) return acc
acc[key] = this.knownPacks[nameOfPack].files[key]
return acc
}, {})
this.knownPacks[nameOfPack].files = sorted
},
},
mounted() {
this.refreshPackList()
},
}
export default EmojiTab
diff --git a/src/components/settings_modal/helpers/emoji_editing_popover.vue b/src/components/settings_modal/helpers/emoji_editing_popover.vue
index d3db657d30..0cd04f0538 100644
--- a/src/components/settings_modal/helpers/emoji_editing_popover.vue
+++ b/src/components/settings_modal/helpers/emoji_editing_popover.vue
@@ -1,351 +1,351 @@
<template>
<Popover
ref="emojiPopover"
trigger="click"
:placement="placement"
bound-to-selector=".emoji-list"
popover-class="emoji-tab-edit-popover popover-default"
:bound-to="{ x: 'container' }"
:offset="{ y: 5 }"
:class="{'emoji-unsaved': isEdited}"
>
<template #trigger>
<slot name="trigger" />
</template>
<template #content>
<h3>
{{ title }}
</h3>
<StillImage
v-if="emojiPreview"
class="emoji"
:src="emojiPreview"
/>
<div
v-else
class="emoji"
/>
<div
v-if="newUpload"
class="emoji-tab-popover-new-upload"
>
<h4>{{ $t('admin_dash.emoji.emoji_source') }}</h4>
<div class="emoji-tab-popover-input">
<input
type="file"
accept="image/*"
class="emoji-tab-popover-file input"
@change="uploadFile = $event.target.files"
>
</div>
<div class="emoji-tab-popover-input ">
<input
v-model="uploadURL"
:placeholder="$t('admin_dash.emoji.upload_url')"
class="emoji-data-input input"
>
</div>
</div>
<div>
<div class="emoji-tab-popover-input">
<label>
{{ $t('admin_dash.emoji.shortcode') }}
<input
v-model="editedShortcode"
class="emoji-data-input input"
:placeholder="$t('admin_dash.emoji.new_shortcode')"
>
</label>
</div>
<div class="emoji-tab-popover-input">
<label>
{{ $t('admin_dash.emoji.filename') }}
<input
v-model="editedFile"
class="emoji-data-input input"
:placeholder="$t('admin_dash.emoji.new_filename')"
>
</label>
</div>
<div
v-if="remote !== undefined"
class="emoji-tab-popover-input"
>
<label>
{{ $t('admin_dash.emoji.copy_to') }}
<SelectComponent
v-model="copyToPack"
class="form-control"
>
<option
value=""
disabled
hidden
>
{{ $t('admin_dash.emoji.emoji_pack') }}
</option>
<option
v-for="(pack, listPackName) in knownLocalPacks"
:key="listPackName"
:label="listPackName"
>
{{ listPackName }}
</option>
</SelectComponent>
</label>
</div>
<!--
For local emojis, disable the button if nothing was edited.
For remote emojis, also disable it if a local pack is not selected.
Remote emojis are processed by the same function that uploads new ones, as that is effectively what it does
-->
<button
class="button button-default btn"
type="button"
:disabled="saveButtonDisabled"
@click="(newUpload || remote !== undefined) ? uploadEmoji() : saveEditedEmoji()"
>
{{ $t('admin_dash.emoji.save') }}
</button>
<template v-if="!newUpload && remote === undefined">
<button
class="button button-default btn emoji-tab-popover-button"
type="button"
@click="deleteModalVisible = true"
>
{{ $t('admin_dash.emoji.delete') }}
</button>
<button
class="button button-default btn emoji-tab-popover-button"
type="button"
@click="revertEmoji"
>
{{ $t('admin_dash.emoji.revert') }}
</button>
<ConfirmModal
v-if="deleteModalVisible"
:title="$t('admin_dash.emoji.delete_title')"
:cancel-text="$t('status.delete_confirm_cancel_button')"
:confirm-text="$t('status.delete_confirm_accept_button')"
@cancelled="deleteModalVisible = false"
@accepted="deleteEmoji"
>
{{ $t('admin_dash.emoji.delete_confirm', [shortcode]) }}
</ConfirmModal>
</template>
</div>
</template>
</Popover>
</template>
<script>
import Popover from 'components/popover/popover.vue'
import SelectComponent from 'components/select/select.vue'
import { defineAsyncComponent } from 'vue'
import { useOAuthStore } from 'src/stores/oauth.js'
import {
addNewEmojiFile,
deleteEmojiFile,
updateEmojiFile,
} from 'src/api/admin.js'
export default {
components: {
Popover,
ConfirmModal: defineAsyncComponent(
() => import('src/components/confirm_modal/confirm_modal.vue'),
),
SelectComponent,
},
inject: ['emojiAddr'],
props: {
placement: {
type: String,
required: true,
},
newUpload: Boolean,
title: {
type: String,
required: true,
},
packName: {
type: String,
required: true,
},
shortcode: {
type: String,
// Only exists when this is not a new upload
default: '',
},
file: {
type: String,
// Only exists when this is not a new upload
default: '',
},
// Only exists for emojis from remote packs
remote: {
type: Object,
default: undefined,
},
knownLocalPacks: {
type: Object,
default: undefined,
},
},
emits: ['updatePackFiles', 'displayError'],
data() {
return {
uploadFile: [],
uploadURL: '',
editedShortcode: this.shortcode,
editedFile: this.file,
deleteModalVisible: false,
copyToPack: '',
}
},
computed: {
emojiPreview() {
if (this.newUpload && this.uploadFile.length > 0) {
return URL.createObjectURL(this.uploadFile[0])
} else if (this.newUpload && this.uploadURL !== '') {
return this.uploadURL
} else if (!this.newUpload) {
return this.emojiAddr(this.file)
}
return null
},
isEdited() {
return (
!this.newUpload &&
(this.editedShortcode !== this.shortcode ||
this.editedFile !== this.file)
)
},
saveButtonDisabled() {
if (this.remote === undefined)
return this.newUpload
? this.uploadURL === '' && this.uploadFile.length == 0
: !this.isEdited
else return this.copyToPack === ''
},
},
methods: {
saveEditedEmoji() {
if (!this.isEdited) return
updateEmojiFile({
packName: this.packName,
shortcode: this.shortcode,
newShortcode: this.editedShortcode,
newFilename: this.editedFile,
force: false,
credentials: useOAuthStore().token,
})
.then((resp) => {
if (resp.error !== undefined) {
this.$emit('displayError', resp.error)
- return Promise.reject(resp.error)
+ throw new Error(resp.error)
}
return resp.json()
})
.then((resp) => this.$emit('updatePackFiles', resp))
},
uploadEmoji() {
let packName = this.remote === undefined ? this.packName : this.copyToPack
addNewEmojiFile({
packName: packName,
file:
this.remote === undefined
? this.uploadURL !== ''
? this.uploadURL
: this.uploadFile[0]
: this.emojiAddr(this.file),
shortcode: this.editedShortcode,
filename: this.editedFile,
credentials: useOAuthStore().token,
})
.then((resp) => resp.json())
.then((resp) => {
if (resp.error !== undefined) {
this.$emit('displayError', resp.error)
return
}
this.$emit('updatePackFiles', resp, packName)
this.$refs.emojiPopover.hidePopover()
this.editedFile = ''
this.editedShortcode = ''
this.uploadFile = []
})
},
revertEmoji() {
this.editedFile = this.file
this.editedShortcode = this.shortcode
},
deleteEmoji() {
this.deleteModalVisible = false
deleteEmojiFile({
packName: this.packName,
shortcode: this.shortcode,
credentials: useOAuthStore().token,
})
.then((resp) => resp.json())
.then((resp) => {
if (resp.error !== undefined) {
this.$emit('displayError', resp.error)
return
}
this.$emit('updatePackFiles', resp, this.packName)
})
},
},
}
</script>
<style lang="scss">
.emoji-tab-edit-popover {
padding-left: 0.5em;
padding-right: 0.5em;
padding-bottom: 0.5em;
.emoji-tab-popover-new-upload {
margin-bottom: 2em;
}
.emoji {
width: 2.3em;
height: 2.3em;
}
.Select {
display: inline-block;
}
h4 {
margin-bottom: 1em;
margin-top: 1em;
}
}
</style>
diff --git a/src/components/settings_modal/helpers/number_setting.js b/src/components/settings_modal/helpers/number_setting.js
index d0b23c919d..8f8861c25f 100644
--- a/src/components/settings_modal/helpers/number_setting.js
+++ b/src/components/settings_modal/helpers/number_setting.js
@@ -1,39 +1,39 @@
import Setting from './setting.js'
export default {
...Setting,
props: {
...Setting.props,
min: {
type: Number,
required: false,
default: 1,
},
max: {
type: Number,
required: false,
default: 1,
},
step: {
type: Number,
required: false,
default: 1,
},
truncate: {
type: Number,
required: false,
default: 1,
},
},
methods: {
...Setting.methods,
getValue(e) {
- if (!this.truncate === 1) {
+ if (this.truncate === 1) {
return Number.parseInt(e.target.value)
} else if (this.truncate > 1) {
return Math.trunc(e.target.value / this.truncate) * this.truncate
}
- return parseFloat(e.target.value)
+ return Number.parseFloat(e.target.value)
},
},
}
diff --git a/src/components/settings_modal/helpers/setting.js b/src/components/settings_modal/helpers/setting.js
index f7cd1ca912..a1a946fca0 100644
--- a/src/components/settings_modal/helpers/setting.js
+++ b/src/components/settings_modal/helpers/setting.js
@@ -1,425 +1,425 @@
import { cloneDeep, get, isEqual, set } from 'lodash'
import DraftButtons from './draft_buttons.vue'
import LocalSettingIndicator from './local_setting_indicator.vue'
import ModifiedIndicator from './modified_indicator.vue'
import { useAdminSettingsStore } from 'src/stores/admin_settings.js'
import { useInterfaceStore } from 'src/stores/interface.js'
import { useLocalConfigStore } from 'src/stores/local_config.js'
import { useMergedConfigStore } from 'src/stores/merged_config.js'
import { useSyncConfigStore } from 'src/stores/sync_config.js'
export default {
components: {
ModifiedIndicator,
DraftButtons,
LocalSettingIndicator,
},
props: {
modelValue: {
type: String,
default: null,
},
description: {
type: String,
default: null,
},
path: {
type: [String, Array],
required: false,
},
showDescription: {
type: Boolean,
required: false,
},
descriptionPathOverride: {
type: [String, Array],
required: false,
},
suggestions: {
type: [String, Array],
required: false,
},
subgroup: {
type: String,
required: false,
},
disabled: {
type: Boolean,
default: false,
},
local: {
type: Boolean,
default: false,
},
parentPath: {
type: [String, Array],
},
parentInvert: {
type: Boolean,
default: false,
},
expert: {
type: [Number, String],
default: 0,
},
source: {
type: String,
default: undefined,
},
hideDraftButtons: {
// this is for the weird backend hybrid (Boolean|String or Boolean|Number) settings
required: false,
type: Boolean,
},
hideLabel: {
type: Boolean,
},
hideDescription: {
type: Boolean,
},
swapDescriptionAndLabel: {
type: Boolean,
},
backendDescriptionPath: {
type: [String, Array],
},
overrideBackendDescription: {
type: Boolean,
},
overrideBackendDescriptionLabel: {
type: [Boolean, String],
},
draftMode: {
type: Boolean,
default: undefined,
},
timedApplyMode: {
type: Boolean,
default: false,
},
},
inject: {
defaultSource: {
default: 'default',
},
defaultDraftMode: {
default: false,
},
},
data() {
return {
localDraft: null,
}
},
emits: ['update:modelValue'],
created() {
if (
this.realDraftMode &&
(this.realSource !== 'admin' || this.path == null)
) {
this.draft = cloneDeep(this.state)
}
},
computed: {
draft: {
get() {
if (this.realSource === 'admin' || this.path == null) {
return get(useAdminSettingsStore().draft, this.canonPath)
} else {
return this.localDraft
}
},
set(value) {
if (this.realSource === 'admin' || this.path == null) {
useAdminSettingsStore().updateAdminDraft({
path: this.canonPath,
value,
})
} else {
this.localDraft = value
}
},
},
state() {
if (this.path == null) {
return this.modelValue
}
const value = get(this.configSource, this.canonPath)
if (value === undefined) {
return this.defaultState
} else {
return value
}
},
visibleState() {
return this.realDraftMode ? this.draft : this.state
},
realSource() {
return this.source || this.defaultSource
},
realDraftMode() {
return this.draftMode === undefined
? this.defaultDraftMode
: this.draftMode
},
backendDescription() {
return get(useAdminSettingsStore().descriptions, this.descriptionPath)
},
backendDescriptionLabel() {
if (this.realSource !== 'admin') return ''
if (
this.overrideBackendDescriptionLabel !== '' &&
typeof this.overrideBackendDescriptionLabel === 'string'
) {
return this.overrideBackendDescriptionLabel
}
if (!this.backendDescription || this.overrideBackendDescriptionLabel) {
return this.$t(
[
'admin_dash',
'temp_overrides',
- ...this.canonPath.map((p) => p.replace(/\./g, '_DOT_')),
+ ...this.canonPath.map((p) => p.replaceAll('.', '_DOT_')),
'label',
].join('.'),
)
} else {
return this.swapDescriptionAndLabel
? this.backendDescription?.description
: this.backendDescription?.label
}
},
backendDescriptionDescription() {
if (this.description) return this.description
if (this.realSource !== 'admin') return ''
if (this.hideDescription) return null
if (!this.backendDescription || this.overrideBackendDescription) {
return this.$t(
[
'admin_dash',
'temp_overrides',
- ...this.canonPath.map((p) => p.replace(/\./g, '_DOT_')),
+ ...this.canonPath.map((p) => p.replaceAll('.', '_DOT_')),
'description',
].join('.'),
)
} else {
return this.swapDescriptionAndLabel
? this.backendDescription?.label
: this.backendDescription?.description
}
},
backendDescriptionSuggestions() {
return this.backendDescription?.suggestions || this.suggestions
},
shouldBeDisabled() {
if (this.path == null) {
return this.disabled
}
let parentValue = null
if (this.parentPath !== undefined && this.realSource === 'admin') {
if (this.realDraftMode) {
parentValue = get(useAdminSettingsStore().draft, this.parentPath)
} else {
parentValue = get(this.configSource, this.parentPath)
}
}
return (
this.disabled ||
(parentValue !== null
? this.parentInvert
? parentValue
: !parentValue
: false)
)
},
configSource() {
switch (this.realSource) {
case 'profile':
return this.$store.state.profileConfig
case 'admin':
return useAdminSettingsStore().config
default:
return useMergedConfigStore().mergedConfig
}
},
configSink() {
if (this.path == null) {
return () => {
/* no-op */
}
}
switch (this.realSource) {
case 'profile':
return (k, v) =>
this.$store.dispatch('setProfileOption', { name: k, value: v })
case 'admin':
return (k, v) =>
useAdminSettingsStore().pushAdminSetting({ path: k, value: v })
default:
return (readPath, value) => {
const writePath = `${readPath}`
if (!this.timedApplyMode) {
if (this.local) {
useLocalConfigStore().set({
path: writePath,
value,
})
} else {
useSyncConfigStore().setSimplePrefAndSave({
path: writePath,
value,
})
}
} else {
if (useInterfaceStore().temporaryChangesTimeoutId !== null) {
console.error("Can't track more than one temporary change")
return
}
const oldValue = get(this.configSource, readPath)
if (this.local) {
useLocalConfigStore().setTemporarily({ path: writePath, value })
} else {
useSyncConfigStore().setPreference({ path: writePath, value })
}
const confirm = () => {
if (this.local) {
useLocalConfigStore().set({ path: writePath, value })
} else {
useSyncConfigStore().pushSyncConfig()
}
useInterfaceStore().clearTemporaryChanges()
}
const revert = () => {
if (this.local) {
useLocalConfigStore().unsetTemporarily({
path: writePath,
value,
})
} else {
useSyncConfigStore().setPreference({
path: writePath,
value: oldValue,
})
}
useInterfaceStore().clearTemporaryChanges()
}
useInterfaceStore().setTemporaryChanges({ confirm, revert })
}
}
}
},
defaultState() {
switch (this.realSource) {
case 'profile':
return {}
default: {
return get(useMergedConfigStore().mergedConfigDefault, this.path)
}
}
},
isProfileSetting() {
return this.realSource === 'profile'
},
isLocalSetting() {
return this.local
},
isChanged() {
if (this.path == null) return false
switch (this.realSource) {
case 'profile':
case 'admin':
return false
default:
return this.state !== this.defaultState
}
},
canonPath() {
if (this.path == null) return null
return Array.isArray(this.path) ? this.path : this.path.split('.')
},
descriptionPath() {
if (this.path == null) return null
if (this.descriptionPathOverride) return this.descriptionPathOverride
const path = Array.isArray(this.path) ? this.path : this.path.split('.')
if (this.subgroup) {
return [
...path.slice(0, path.length - 1),
':subgroup,' + this.subgroup,
...path.slice(path.length - 1),
]
}
return path
},
isDirty() {
if (this.path == null) return false
if (this.realSource === 'admin' && this.canonPath.length > 3) {
return false // should not show draft buttons for "grouped" values
} else {
return this.realDraftMode && !isEqual(this.draft, this.state)
}
},
canHardReset() {
return (
this.realSource === 'admin' &&
useAdminSettingsStore().modifiedPaths?.has(this.canonPath.join(' -> '))
)
},
matchesExpertLevel() {
const settingExpertLevel = this.expert || 0
const userToggleExpert =
useMergedConfigStore().mergedConfig.expertLevel || 0
return settingExpertLevel <= userToggleExpert
},
},
methods: {
getValue(e) {
return e.target.value
},
update(e) {
if (this.realDraftMode) {
this.draft = this.getValue(e)
} else {
this.$emit('update:modelValue', this.getValue(e))
this.configSink(this.path, this.getValue(e))
}
},
commitDraft() {
if (this.realDraftMode) {
this.$emit('update:modelValue', this.draft)
this.configSink(this.path, this.draft)
}
},
reset() {
if (this.realDraftMode) {
this.draft = cloneDeep(this.state)
} else {
set(
useMergedConfigStore().mergedConfig,
this.path,
cloneDeep(this.defaultState),
)
}
},
hardReset() {
switch (this.realSource) {
case 'admin':
return this.$store
.dispatch('resetAdminSetting', { path: this.path })
.then(() => {
this.draft = this.state
})
default:
console.warn('Hard reset not implemented yet!')
}
},
},
}
diff --git a/src/components/settings_modal/helpers/unit_setting.js b/src/components/settings_modal/helpers/unit_setting.js
index 99f4ac38ea..3be6b7a104 100644
--- a/src/components/settings_modal/helpers/unit_setting.js
+++ b/src/components/settings_modal/helpers/unit_setting.js
@@ -1,84 +1,87 @@
import Select from 'src/components/select/select.vue'
import Setting from './setting.js'
export const allCssUnits = [
'cm',
'mm',
'in',
'px',
'pt',
'pc',
'em',
'ex',
'ch',
'rem',
'vw',
'vh',
'vmin',
'vmax',
'%',
]
export const defaultHorizontalUnits = ['px', 'rem', 'vw']
export const defaultVerticalUnits = ['px', 'rem', 'vh']
export default {
...Setting,
components: {
...Setting.components,
Select,
},
props: {
...Setting.props,
min: Number,
units: {
type: Array,
default: () => allCssUnits,
},
unitSet: {
type: String,
default: 'none',
},
step: {
type: Number,
default: 1,
},
resetDefault: {
type: Object,
default: null,
},
},
computed: {
...Setting.computed,
stateUnit() {
return typeof this.state === 'string'
? this.state.replace(/[0-9,.]+/, '')
: ''
},
stateValue() {
return typeof this.state === 'string'
? this.state.replace(/[^0-9,.]+/, '')
: ''
},
},
methods: {
...Setting.methods,
getUnitString(value) {
if (this.unitSet === 'none') return value
return this.$t(['settings', 'units', this.unitSet, value].join('.'))
},
updateValue(e) {
- this.configSink(this.path, parseFloat(e.target.value) + this.stateUnit)
+ this.configSink(
+ this.path,
+ Number.parseFloat(e.target.value) + this.stateUnit,
+ )
},
updateUnit(e) {
let value = this.stateValue
const newUnit = e.target.value
if (this.resetDefault) {
const replaceValue = this.resetDefault[newUnit]
if (replaceValue != null) {
value = replaceValue
}
}
this.configSink(this.path, value + newUnit)
},
},
}
diff --git a/src/components/settings_modal/tabs/appearance_tab.js b/src/components/settings_modal/tabs/appearance_tab.js
index 518345d9ea..647c90c47d 100644
--- a/src/components/settings_modal/tabs/appearance_tab.js
+++ b/src/components/settings_modal/tabs/appearance_tab.js
@@ -1,509 +1,509 @@
import { mapActions } from 'pinia'
import fileSizeFormatService from 'src/components/../services/file_size_format/file_size_format.js'
import PaletteEditor from 'src/components/palette_editor/palette_editor.vue'
import BooleanSetting from '../helpers/boolean_setting.vue'
import ChoiceSetting from '../helpers/choice_setting.vue'
import FloatSetting from '../helpers/float_setting.vue'
import IntegerSetting from '../helpers/integer_setting.vue'
import SharedComputedObject from '../helpers/shared_computed_object.js'
import UnitSetting from '../helpers/unit_setting.vue'
import Preview from './old_theme_tab/theme_preview.vue'
import { useInstanceStore } from 'src/stores/instance.js'
import { normalizeThemeData, useInterfaceStore } from 'src/stores/interface.js'
import { useOAuthStore } from 'src/stores/oauth.js'
import { updateProfileImages } from 'src/api/user.js'
import { newImporter } from 'src/services/export_import/export_import.js'
import {
adoptStyleSheets,
createStyleSheet,
} from 'src/services/style_setter/style_setter.js'
import { getCssRules } from 'src/services/theme_data/css_utils.js'
import { deserialize } from 'src/services/theme_data/iss_deserializer.js'
import { init } from 'src/services/theme_data/theme_data_3.service.js'
import { convertTheme2To3 } from 'src/services/theme_data/theme2_to_theme3.js'
const AppearanceTab = {
data() {
return {
availableThemesV3: [],
availableThemesV2: [],
bundledPalettes: [],
compilationCache: {},
fileImporter: newImporter({
accept: '.json, .iss',
validator: this.importValidator,
onImport: this.onImport,
parser: this.importParser,
onImportFailure: this.onImportFailure,
}),
palettesKeys: [
'bg',
'fg',
'link',
'text',
'cRed',
'cGreen',
'cBlue',
'cOrange',
],
userPalette: {},
intersectionObserver: null,
forcedRoundnessOptions: ['disabled', 'sharp', 'nonsharp', 'round'].map(
(mode, i) => ({
key: mode,
value: i - 1,
label: this.$t(
`settings.style.themes3.hacks.forced_roundness_mode_${mode}`,
),
}),
),
underlayOverrideModes: ['none', 'opaque', 'transparent'].map((mode) => ({
key: mode,
value: mode,
label: this.$t(
`settings.style.themes3.hacks.underlay_override_mode_${mode}`,
),
})),
backgroundUploading: false,
background: null,
backgroundError: null,
backgroundPreview: null,
}
},
components: {
BooleanSetting,
ChoiceSetting,
IntegerSetting,
FloatSetting,
UnitSetting,
Preview,
PaletteEditor,
},
mounted() {
useInterfaceStore().getThemeData()
const updateIndex = (resource) => {
const capitalizedResource = resource[0].toUpperCase() + resource.slice(1)
const currentIndex = useInstanceStore()[`${resource}sIndex`]
let promise
if (currentIndex) {
promise = Promise.resolve(currentIndex)
} else {
promise = useInterfaceStore()[`fetch${capitalizedResource}sIndex`]()
}
return promise.then((index) => {
return Object.entries(index).map(([k, func]) => [k, func()])
})
}
updateIndex('style').then((styles) => {
styles.forEach(([key, stylePromise]) =>
stylePromise.then((data) => {
const meta = data.find((x) => x.component === '@meta')
this.availableThemesV3.push({
key,
data,
name: meta.directives.name,
version: 'v3',
})
}),
)
})
updateIndex('theme').then((themes) => {
themes.forEach(([key, themePromise]) =>
themePromise.then((data) => {
if (!data) {
console.warn(`Theme with key ${key} is empty or malformed`)
} else if (Array.isArray(data)) {
console.warn(
`Theme with key ${key} is a v1 theme and should be moved to static/palettes/index.json`,
)
} else if (!data.source && !data.theme) {
console.warn(`Theme with key ${key} is malformed`)
} else {
this.availableThemesV2.push({
key,
data,
name: data.name,
version: 'v2',
})
}
}),
)
})
this.userPalette = useInterfaceStore().paletteDataUsed || {}
updateIndex('palette').then((bundledPalettes) => {
bundledPalettes.forEach(([key, palettePromise]) =>
palettePromise.then((v) => {
let palette
if (Array.isArray(v)) {
const [
name,
bg,
fg,
text,
link,
cRed = '#FF0000',
cGreen = '#00FF00',
cBlue = '#0000FF',
cOrange = '#E3FF00',
] = v
palette = {
key,
name,
bg,
fg,
text,
link,
cRed,
cBlue,
cGreen,
cOrange,
}
} else {
palette = { key, ...v }
}
if (!palette.key.startsWith('style.')) {
this.bundledPalettes.push(palette)
}
}),
)
})
this.previewTheme('stock', 'v3')
if (window.IntersectionObserver) {
this.intersectionObserver = new IntersectionObserver(
(entries, observer) => {
entries.forEach(({ target, isIntersecting }) => {
if (!isIntersecting) return
const theme = this.availableStyles.find(
(x) => x.key === target.dataset.themeKey,
)
this.$nextTick(() => {
if (theme) this.previewTheme(theme.key, theme.version, theme.data)
})
observer.unobserve(target)
})
},
{
root: this.$refs.themeList,
},
)
} else {
this.availableStyles.forEach((theme) =>
this.previewTheme(theme.key, theme.version, theme.data),
)
}
},
updated() {
this.$nextTick(() => {
this.$refs.themeList
.querySelectorAll('.theme-preview')
.forEach((node) => {
this.intersectionObserver.observe(node)
})
})
},
watch: {
paletteDataUsed() {
this.userPalette = this.paletteDataUsed || {}
},
},
computed: {
isDefaultBackground() {
return !this.$store.state.users.currentUser.background_image
},
switchInProgress() {
return useInterfaceStore().themeChangeInProgress
},
paletteDataUsed() {
return useInterfaceStore().paletteDataUsed
},
availableStyles() {
return [...this.availableThemesV3, ...this.availableThemesV2]
},
availablePalettes() {
return [...this.bundledPalettes, ...this.stylePalettes]
},
stylePalettes() {
const ruleset = useInterfaceStore().styleDataUsed || []
- if (!ruleset?.length === 0) return
+ if (ruleset.length === 0) return
const meta = ruleset.find((x) => x.component === '@meta')
const result = ruleset
.filter((x) => x.component.startsWith('@palette'))
.map((x) => {
const { variant, directives } = x
const {
bg,
fg,
text,
link,
accent,
cRed,
cBlue,
cGreen,
cOrange,
wallpaper,
} = directives
const result = {
name: `${meta.directives.name || this.$t('settings.style.themes3.palette.imported')}: ${variant}`,
- key: `style.${variant.toLowerCase().replace(/ /g, '_')}`,
+ key: `style.${variant.toLowerCase().replaceAll(' ', '_')}`,
bg,
fg,
text,
link,
accent,
cRed,
cBlue,
cGreen,
cOrange,
wallpaper,
}
return Object.fromEntries(Object.entries(result).filter(([, v]) => v))
})
return result
},
noIntersectionObserver() {
return !window.IntersectionObserver
},
instanceWallpaper() {
- useInstanceStore().instanceIdentity.background
+ return useInstanceStore().instanceIdentity.background
},
instanceWallpaperUsed() {
return (
useInstanceStore().instanceIdentity.background &&
!this.$store.state.users.currentUser.background_image
)
},
customThemeVersion() {
const { themeVersion } = useInterfaceStore()
return themeVersion
},
isCustomThemeUsed() {
const { customTheme, customThemeSource } = this.mergedConfig
return customTheme != null || customThemeSource != null
},
isCustomStyleUsed() {
const { styleCustomData } = this.mergedConfig
return styleCustomData != null
},
...SharedComputedObject(),
},
methods: {
importFile() {
this.fileImporter.importData()
},
importParser(file, filename) {
if (filename.endsWith('.json')) {
return JSON.parse(file)
} else if (filename.endsWith('.iss')) {
return deserialize(file)
}
},
onImport(parsed, filename) {
if (filename.endsWith('.json')) {
useInterfaceStore().setThemeCustom(parsed.source || parsed.theme)
} else if (filename.endsWith('.iss')) {
useInterfaceStore().setStyleCustom(parsed)
}
},
onImportFailure(result) {
console.error('Failure importing theme:', result)
useInterfaceStore().pushGlobalNotice({
messageKey: 'settings.invalid_theme_imported',
level: 'error',
})
},
importValidator(parsed, filename) {
if (filename.endsWith('.json')) {
const version = parsed._pleroma_theme_version
return version >= 1 || version <= 2
} else if (filename.endsWith('.iss')) {
if (!Array.isArray(parsed)) return false
if (parsed.length < 1) return false
if (parsed.find((x) => x.component === '@meta') == null) return false
return true
}
},
isThemeActive(key) {
return (
key ===
(this.mergedConfig.theme || useInstanceStore().instanceIdentity.theme)
)
},
isStyleActive(key) {
return (
key ===
(this.mergedConfig.style || useInstanceStore().instanceIdentity.style)
)
},
isPaletteActive(key) {
return (
key ===
(this.mergedConfig.palette ||
useInstanceStore().instanceIdentity.palette)
)
},
...mapActions(useInterfaceStore, ['setStyle', 'setTheme']),
setPalette(name, data) {
useInterfaceStore().setPalette(name)
this.userPalette = data
},
setPaletteCustom(data) {
useInterfaceStore().setPaletteCustom(data)
this.userPalette = data
},
resetTheming() {
useInterfaceStore().setStyle('stock')
},
previewTheme(key, version, input) {
let theme3
if (this.compilationCache[key]) {
theme3 = this.compilationCache[key]
} else if (input) {
if (version === 'v2') {
const style = normalizeThemeData(input)
const theme2 = convertTheme2To3(style)
theme3 = init({
inputRuleset: theme2,
ultimateBackgroundColor: '#000000',
liteMode: true,
debug: true,
onlyNormalState: true,
})
} else if (version === 'v3') {
const palette = input.find((x) => x.component === '@palette')
let paletteRule
if (palette) {
const { directives } = palette
directives.link = directives.link || directives.accent
directives.accent = directives.accent || directives.link
paletteRule = {
component: 'Root',
directives: Object.fromEntries(
Object.entries(directives)
.filter(([k]) => k && k !== 'name')
.map(([k, v]) => ['--' + k, 'color | ' + v]),
),
}
} else {
paletteRule = null
}
theme3 = init({
inputRuleset: [...input, paletteRule].filter(Boolean),
ultimateBackgroundColor: '#000000',
liteMode: true,
onlyNormalState: true,
})
}
} else {
theme3 = init({
inputRuleset: [],
ultimateBackgroundColor: '#000000',
liteMode: true,
onlyNormalState: true,
})
}
if (!this.compilationCache[key]) {
this.compilationCache[key] = theme3
}
const sheet = createStyleSheet('appearance-tab-previews', 90)
sheet.addRule(
[
'#theme-preview-',
key,
' {\n',
getCssRules(theme3.eager).join('\n'),
'\n}',
].join(''),
)
sheet.ready = true
adoptStyleSheets()
},
uploadFile(slot, e) {
const file = e.target.files[0]
if (!file) {
return
}
if (file.size > useInstanceStore()[slot + 'limit']) {
const filesize = fileSizeFormatService.fileSizeFormat(file.size)
const allowedsize = fileSizeFormatService.fileSizeFormat(
useInstanceStore()[slot + 'limit'],
)
useInterfaceStore().pushGlobalNotice({
messageKey: 'upload.error.message',
messageArgs: [
this.$t('upload.error.file_too_big', {
filesize: filesize.num,
filesizeunit: filesize.unit,
allowedsize: allowedsize.num,
allowedsizeunit: allowedsize.unit,
}),
],
level: 'error',
})
return
}
const reader = new FileReader()
reader.onload = ({ target }) => {
const img = target.result
this[slot + 'Preview'] = img
this[slot] = file
}
reader.readAsDataURL(file)
},
resetBackground() {
const confirmed = window.confirm(
this.$t('settings.reset_background_confirm'),
)
if (confirmed) {
this.submitBackground('')
}
},
resetUploadedBackground() {
this.backgroundPreview = null
},
clearBackgroundError() {
this.backgroundError = null
},
submitBackground(background) {
if (!this.backgroundPreview && background !== '') {
return
}
this.backgroundUploading = true
updateProfileImages({
background,
credentials: useOAuthStore().token,
})
.then(({ data }) => {
this.$store.commit('addNewUsers', [data])
this.$store.commit('setCurrentUser', data)
this.backgroundPreview = null
this.backgroundError = null
})
.catch((e) => {
this.backgroundError = e
})
.finally(() => {
this.backgroundUploading = false
})
},
},
}
export default AppearanceTab
diff --git a/src/components/settings_modal/tabs/clutter_tab.js b/src/components/settings_modal/tabs/clutter_tab.js
index 85749ab3f1..87269eb998 100644
--- a/src/components/settings_modal/tabs/clutter_tab.js
+++ b/src/components/settings_modal/tabs/clutter_tab.js
@@ -1,159 +1,43 @@
-import { mapActions, mapState } from 'pinia'
-import { v4 as uuidv4 } from 'uuid'
+import { mapState } from 'pinia'
import Checkbox from 'src/components/checkbox/checkbox.vue'
import Select from 'src/components/select/select.vue'
import BooleanSetting from '../helpers/boolean_setting.vue'
import ChoiceSetting from '../helpers/choice_setting.vue'
import HelpIndicator from '../helpers/help_indicator.vue'
import IntegerSetting from '../helpers/integer_setting.vue'
import SharedComputedObject from '../helpers/shared_computed_object.js'
import UnitSetting from '../helpers/unit_setting.vue'
import { useInstanceStore } from 'src/stores/instance.js'
import { useInstanceCapabilitiesStore } from 'src/stores/instance_capabilities.js'
-import { useSyncConfigStore } from 'src/stores/sync_config.js'
const ClutterTab = {
components: {
BooleanSetting,
ChoiceSetting,
UnitSetting,
IntegerSetting,
Checkbox,
Select,
HelpIndicator,
},
computed: {
...SharedComputedObject(),
...mapState(useInstanceCapabilitiesStore, ['shoutAvailable']),
...mapState(useInstanceStore, {
showFeaturesPanel: (store) => store.instanceIdentity.showFeaturesPanel,
instanceSpecificPanelPresent: (store) =>
store.instanceIdentity.showInstanceSpecificPanel &&
store.instanceIdentity.instanceSpecificPanelContent,
}),
- ...mapState(useSyncConfigStore, {
- muteFilters: (store) =>
- Object.entries(store.prefsStorage.simple.muteFilters),
- muteFiltersObject: (store) => store.prefsStorage.simple.muteFilters,
- }),
- },
- methods: {
- ...mapActions(useSyncConfigStore, [
- 'setSimplePrefAndSave',
- 'unsetSimplePrefAndSave',
- 'pushSyncConfig',
- ]),
- getDatetimeLocal(timestamp) {
- const date = new Date(timestamp)
- const fmt = new Intl.NumberFormat('en-US', { minimumIntegerDigits: 2 })
- const datetime = [
- date.getFullYear(),
- '-',
- fmt.format(date.getMonth() + 1),
- '-',
- fmt.format(date.getDate()),
- 'T',
- fmt.format(date.getHours()),
- ':',
- fmt.format(date.getMinutes()),
- ].join('')
- return datetime
- },
- checkRegexValid(id) {
- const filter = this.muteFiltersObject[id]
- if (filter.type !== 'regexp') return true
- if (filter.type !== 'user_regexp') return true
- const { value } = filter
- let valid = true
- try {
- new RegExp(value)
- } catch {
- valid = false
- console.error('Invalid RegExp: ' + value)
- }
- return valid
- },
- createFilter(
- filter = {
- type: 'word',
- value: '',
- name: 'New Filter',
- enabled: true,
- expires: null,
- hide: false,
- },
- ) {
- const newId = uuidv4()
-
- filter.order = this.muteFilters.length + 2
- this.muteFiltersDraftObject[newId] = filter
- this.setSimplePrefAndSave({ path: 'muteFilters.' + newId, value: filter })
- this.pushSyncConfig()
- },
- exportFilter(id) {
- this.exportedFilter = { ...this.muteFiltersDraftObject[id] }
- delete this.exportedFilter.order
- this.filterExporter.exportData()
- },
- importFilter() {
- this.filterImporter.importData()
- },
- copyFilter(id) {
- const filter = { ...this.muteFiltersDraftObject[id] }
- const newId = uuidv4()
-
- this.muteFiltersDraftObject[newId] = filter
- this.setSimplePrefAndSave({ path: 'muteFilters.' + newId, value: filter })
- this.pushSyncConfig()
- },
- deleteFilter(id) {
- delete this.muteFiltersDraftObject[id]
- this.unsetSimplePrefAndSave({ path: 'muteFilters.' + id, value: null })
- this.pushSyncConfig()
- },
- purgeExpiredFilters() {
- this.muteFiltersExpired.forEach(([id]) => {
- delete this.muteFiltersDraftObject[id]
- this.unsetSimplePrefAndSave({ path: 'muteFilters.' + id, value: null })
- })
- this.pushSyncConfig()
- },
- updateFilter(id, field, value) {
- const filter = { ...this.muteFiltersDraftObject[id] }
- if (field === 'expires-never') {
- if (!value) {
- const offset = 1000 * 60 * 60 * 24 * 14 // 2 weeks
- const date = Date.now() + offset
- filter.expires = date
- } else {
- filter.expires = null
- }
- } else if (field === 'expires') {
- const parsed = Date.parse(value)
- filter.expires = parsed.valueOf()
- } else {
- filter[field] = value
- }
- this.muteFiltersDraftObject[id] = filter
- this.muteFiltersDraftDirty[id] = true
- },
- saveFilter(id) {
- this.setSimplePrefAndSave({
- path: 'muteFilters.' + id,
- value: this.muteFiltersDraftObject[id],
- })
- this.pushSyncConfig()
- this.muteFiltersDraftDirty[id] = false
- },
},
// Updating nested properties
watch: {
replyVisibility() {
this.$store.dispatch('queueFlushAll')
},
},
}
export default ClutterTab
diff --git a/src/components/settings_modal/tabs/filtering_tab.js b/src/components/settings_modal/tabs/filtering_tab.js
index e0d0f6879f..3a9cad6d27 100644
--- a/src/components/settings_modal/tabs/filtering_tab.js
+++ b/src/components/settings_modal/tabs/filtering_tab.js
@@ -1,275 +1,278 @@
import { cloneDeep } from 'lodash'
import { mapActions, mapState } from 'pinia'
import { v4 as uuidv4 } from 'uuid'
import Checkbox from 'src/components/checkbox/checkbox.vue'
import Select from 'src/components/select/select.vue'
import BooleanSetting from '../helpers/boolean_setting.vue'
import ChoiceSetting from '../helpers/choice_setting.vue'
import HelpIndicator from '../helpers/help_indicator.vue'
import IntegerSetting from '../helpers/integer_setting.vue'
import SharedComputedObject from '../helpers/shared_computed_object.js'
import UnitSetting from '../helpers/unit_setting.vue'
import { useInstanceCapabilitiesStore } from 'src/stores/instance_capabilities.js'
import { useInterfaceStore } from 'src/stores/interface'
import { useMergedConfigStore } from 'src/stores/merged_config.js'
import { useSyncConfigStore } from 'src/stores/sync_config.js'
import {
newExporter,
newImporter,
} from 'src/services/export_import/export_import.js'
const SUPPORTED_TYPES = new Set(['word', 'regexp', 'user', 'user_regexp'])
const FilteringTab = {
data() {
return {
replyVisibilityOptions: ['all', 'following', 'self'].map((mode) => ({
key: mode,
value: mode,
label: this.$t(`settings.reply_visibility_${mode}`),
})),
muteBlockLv1Options: ['ask', 'forever', 'temporarily'].map((mode) => ({
key: mode,
value: mode,
label: this.$t(`user_card.mute_block_${mode}`),
})),
muteFiltersDraftObject: cloneDeep(
useSyncConfigStore().prefsStorage.simple.muteFilters,
),
muteFiltersDraftDirty: Object.fromEntries(
Object.entries(
useSyncConfigStore().prefsStorage.simple.muteFilters,
).map(([k]) => [k, false]),
),
exportedFilter: null,
filterImporter: newImporter({
validator(parsed) {
if (Array.isArray(parsed)) return false
if (!SUPPORTED_TYPES.has(parsed.type)) return false
return true
},
onImport: (data) => {
const {
enabled = true,
expires = null,
hide = false,
name = '',
value = '',
caseSensitive = false,
} = data
this.createFilter({
enabled,
expires,
hide,
name,
value,
caseSensitive,
})
},
onImportFailure(result) {
console.error('Failure importing filter:', result)
useInterfaceStore().pushGlobalNotice({
messageKey: 'settings.filter.import_failure',
level: 'error',
})
},
}),
filterExporter: newExporter({
filename: 'pleromafe_mute-filter',
getExportedObject: () => this.exportedFilter,
}),
}
},
components: {
BooleanSetting,
ChoiceSetting,
UnitSetting,
IntegerSetting,
Checkbox,
Select,
HelpIndicator,
},
computed: {
...SharedComputedObject(),
...mapState(useSyncConfigStore, {
muteFilters: (store) =>
Object.entries(store.prefsStorage.simple.muteFilters),
muteFiltersObject: (store) => store.prefsStorage.simple.muteFilters,
}),
...mapState(useInstanceCapabilitiesStore, ['blockExpiration']),
onMuteDefaultActionLv1: {
get() {
const value = useMergedConfigStore().mergedConfig.onMuteDefaultAction
if (value === 'ask' || value === 'forever') {
return value
} else {
return 'temporarily'
}
},
set(value) {
let realValue = value
if (value !== 'ask' && value !== 'forever') {
realValue = '14d'
}
this.setPreference({
path: 'simple.onMuteDefaultAction',
value: realValue,
})
},
},
onBlockDefaultActionLv1: {
get() {
const value = useMergedConfigStore().mergedConfig.onBlockDefaultAction
if (value === 'ask' || value === 'forever') {
return value
} else {
return 'temporarily'
}
},
set(value) {
let realValue = value
if (value !== 'ask' && value !== 'forever') {
realValue = '14d'
}
this.setPreference({
path: 'simple.onBlockDefaultAction',
value: realValue,
})
},
},
muteFiltersDraft() {
return Object.entries(this.muteFiltersDraftObject)
},
muteFiltersExpired() {
const now = Date.now()
return Object.entries(this.muteFiltersDraftObject).filter(
([, { expires }]) => expires != null && expires <= now,
)
},
},
methods: {
...mapActions(useSyncConfigStore, [
'setPreference',
'setSimplePrefAndSave',
'unsetSimplePrefAndSave',
'unsetPreference',
'unsetPrefAndSave',
'pushSyncConfig',
]),
getDatetimeLocal(timestamp) {
const date = new Date(timestamp)
const fmt = new Intl.NumberFormat('en-US', { minimumIntegerDigits: 2 })
const datetime = [
date.getFullYear(),
'-',
fmt.format(date.getMonth() + 1),
'-',
fmt.format(date.getDate()),
'T',
fmt.format(date.getHours()),
':',
fmt.format(date.getMinutes()),
].join('')
return datetime
},
checkRegexValid(id) {
const filter = this.muteFiltersObject[id]
if (filter.type !== 'regexp') return true
if (filter.type !== 'user_regexp') return true
const { value } = filter
let valid = true
try {
new RegExp(value)
} catch {
valid = false
console.error('Invalid RegExp: ' + value)
}
return valid
},
- createFilter(
- filter = {
+ createFilter(filter) {
+ const newId = uuidv4()
+ const newFilter = {
type: 'word',
value: '',
name: 'New Filter',
enabled: true,
expires: null,
hide: false,
- },
- ) {
- const newId = uuidv4()
+ ...filter,
+ }
- filter.order = this.muteFilters.length + 2
- this.muteFiltersDraftObject[newId] = filter
- this.setSimplePrefAndSave({ path: 'muteFilters.' + newId, value: filter })
+ newFilter.order = this.muteFilters.length + 2
+ this.muteFiltersDraftObject[newId] = newFilter
+ this.setSimplePrefAndSave({
+ path: 'muteFilters.' + newId,
+ value: newFilter,
+ })
},
exportFilter(id) {
this.exportedFilter = { ...this.muteFiltersDraftObject[id] }
delete this.exportedFilter.order
this.filterExporter.exportData()
},
importFilter() {
this.filterImporter.importData()
},
copyFilter(id) {
const filter = { ...this.muteFiltersDraftObject[id] }
const newId = uuidv4()
this.muteFiltersDraftObject[newId] = filter
this.setSimplePrefAndSave({ path: 'muteFilters.' + newId, value: filter })
},
deleteFilter(id) {
delete this.muteFiltersDraftObject[id]
this.unsetSimplePrefAndSave({ path: 'muteFilters.' + id, value: null })
},
purgeExpiredFilters() {
this.muteFiltersExpired.forEach(([id]) => {
delete this.muteFiltersDraftObject[id]
this.unsetPreference({ path: 'simple.muteFilters.' + id, value: null })
})
this.pushSyncConfig()
},
updateFilter(id, field, value) {
const filter = { ...this.muteFiltersDraftObject[id] }
if (field === 'expires-never') {
if (!value) {
const offset = 1000 * 60 * 60 * 24 * 14 // 2 weeks
const date = Date.now() + offset
filter.expires = date
} else {
filter.expires = null
}
} else if (field === 'expires') {
const parsed = Date.parse(value)
filter.expires = parsed.valueOf()
} else {
filter[field] = value
}
this.muteFiltersDraftObject[id] = filter
this.muteFiltersDraftDirty[id] = true
},
saveFilter(id) {
this.setSimplePrefAndSave({
path: 'muteFilters.' + id,
value: this.muteFiltersDraftObject[id],
})
this.muteFiltersDraftDirty[id] = false
},
},
// Updating nested properties
watch: {
replyVisibility() {
this.$store.dispatch('queueFlushAll')
},
muteFiltersObject() {
this.muteFiltersDraftObject = cloneDeep(
useMergedConfigStore().mergedConfig.muteFilters,
)
},
},
}
export default FilteringTab
diff --git a/src/components/settings_modal/tabs/mutes_and_blocks_tab.vue b/src/components/settings_modal/tabs/mutes_and_blocks_tab.vue
index f892531333..a5395ae367 100644
--- a/src/components/settings_modal/tabs/mutes_and_blocks_tab.vue
+++ b/src/components/settings_modal/tabs/mutes_and_blocks_tab.vue
@@ -1,168 +1,167 @@
<template>
<tab-switcher
class="mutes-and-blocks-tab"
scrollable-tabs
>
<div
class="blocks"
:label="$t('settings.user_blocks')"
>
<div class="usersearch-wrapper">
<Autosuggest
:filter="filterUnblockedUsers"
:query="queryUserIds"
:placeholder="$t('settings.search_user_to_block')"
>
<template #default="row">
<BlockCard
:user-id="row.item"
/>
</template>
</Autosuggest>
</div>
<List
:get-key="i => i"
:external-items="blocks"
:fetch-function="fetchItems('Blocks')"
scrollable
selectable
>
<template #header="{selected}">
<div class="bulk-actions">
<ProgressButton
v-if="selected.length > 0"
class="btn button-default bulk-action-button"
:click="() => blockUsers(selected)"
>
{{ $t('user_card.block') }}
<template #progress>
{{ $t('user_card.block_progress') }}
</template>
</ProgressButton>
<ProgressButton
v-if="selected.length > 0"
class="btn button-default"
:click="() => unblockUsers(selected)"
>
{{ $t('user_card.unblock') }}
<template #progress>
{{ $t('user_card.unblock_progress') }}
</template>
</ProgressButton>
</div>
</template>
<template #item="{item}">
<BlockCard :user-id="item" />
</template>
<template #empty>
{{ $t('settings.no_blocks') }}
</template>
</List>
</div>
<div
class="mutes"
:label="$t('settings.user_mutes2')"
>
<div class="usersearch-wrapper">
<Autosuggest
:filter="filterUnMutedUsers"
:query="queryUserIds"
:placeholder="$t('settings.search_user_to_mute')"
>
<template #default="row">
<MuteCard
:user-id="row.item"
/>
</template>
</Autosuggest>
</div>
<List
:get-key="i => i"
:external-items="mutes"
:fetch-function="fetchItems('Mutes')"
scrollable
selectable
>
<template #header="{selected}">
<div class="bulk-actions">
<ProgressButton
v-if="selected.length > 0"
class="btn button-default"
:click="() => muteUsers(selected)"
>
{{ $t('user_card.mute') }}
<template #progress>
{{ $t('user_card.mute_progress') }}
</template>
</ProgressButton>
<ProgressButton
v-if="selected.length > 0"
class="btn button-default"
:click="() => unmuteUsers(selected)"
>
{{ $t('user_card.unmute') }}
<template #progress>
{{ $t('user_card.unmute_progress') }}
</template>
</ProgressButton>
</div>
</template>
<template #item="{item}">
<MuteCard :user-id="item" />
</template>
<template #empty>
{{ $t('settings.no_mutes') }}
</template>
</List>
</div>
<div :label="$t('settings.domain_mutes2')">
<div class="domain-mute-form">
<Autosuggest
:filter="filterUnMutedDomains"
:query="queryKnownDomains"
:placeholder="$t('settings.type_domains_to_mute')"
>
<template #default="row">
<DomainMuteCard
:domain="row.item"
/>
</template>
</Autosuggest>
</div>
<List
:get-key="i => i"
:external-items="domains"
:fetch-function="fetchItems('DomainMutes')"
scrollable
selectable
>
<template #header="{selected}">
<div class="bulk-actions">
<ProgressButton
v-if="selected.length > 0"
class="btn button-default"
:click="() => unmuteDomains(selected)"
>
{{ $t('domain_mute_card.unmute') }}
<template #progress>
{{ $t('domain_mute_card.unmute_progress') }}
</template>
</ProgressButton>
</div>
</template>
<template #item="{item}">
- {{ item }}
<DomainMuteCard :domain="item" />
</template>
<template #empty>
{{ $t('settings.no_mutes') }}
</template>
</List>
</div>
</tab-switcher>
</template>
<script src="./mutes_and_blocks_tab.js"></script>
<style lang="scss" src="./mutes_and_blocks_tab.scss"></style>
diff --git a/src/components/settings_modal/tabs/old_theme_tab/old_theme_tab.js b/src/components/settings_modal/tabs/old_theme_tab/old_theme_tab.js
index 13bcc3ae6c..90ddc5ee05 100644
--- a/src/components/settings_modal/tabs/old_theme_tab/old_theme_tab.js
+++ b/src/components/settings_modal/tabs/old_theme_tab/old_theme_tab.js
@@ -1,828 +1,828 @@
import Checkbox from 'src/components/checkbox/checkbox.vue'
import ColorInput from 'src/components/color_input/color_input.vue'
import ContrastRatio from 'src/components/contrast_ratio/contrast_ratio.vue'
import FontControl from 'src/components/font_control/font_control.vue'
import OpacityInput from 'src/components/opacity_input/opacity_input.vue'
import RangeInput from 'src/components/range_input/range_input.vue'
import Select from 'src/components/select/select.vue'
import ShadowControl from 'src/components/shadow_control/shadow_control.vue'
import TabSwitcher from 'src/components/tab_switcher/tab_switcher.jsx'
import Preview from './theme_preview.vue'
import { useInstanceStore } from 'src/stores/instance.js'
import { useInterfaceStore } from 'src/stores/interface.js'
import { useMergedConfigStore } from 'src/stores/merged_config.js'
import {
getContrastRatioLayers,
hex2rgb,
relativeLuminance,
rgb2hex,
} from 'src/services/color_convert/color_convert.js'
import {
newExporter,
newImporter,
} from 'src/services/export_import/export_import.js'
import {
adoptStyleSheets,
createStyleSheet,
} from 'src/services/style_setter/style_setter.js'
import {
getCssRules,
getScopedVersion,
} from 'src/services/theme_data/css_utils.js'
import { SLOT_INHERITANCE } from 'src/services/theme_data/pleromafe.js'
import {
CURRENT_VERSION,
colors2to3,
DEFAULT_SHADOWS,
generateColors,
generateFonts,
generateRadii,
generateShadows,
getLayers,
getOpacitySlot,
OPACITIES,
shadows2to3,
} from 'src/services/theme_data/theme_data.service.js'
import { init } from 'src/services/theme_data/theme_data_3.service.js'
import { convertTheme2To3 } from 'src/services/theme_data/theme2_to_theme3.js'
// List of color values used in v1
const v1OnlyNames = [
'bg',
'fg',
'text',
'link',
'cRed',
'cGreen',
'cBlue',
'cOrange',
].map((_) => _ + 'ColorLocal')
const colorConvert = (color) => {
if (color.startsWith('--') || color === 'transparent') {
return color
} else {
return hex2rgb(color)
}
}
export default {
data() {
return {
themeImporter: newImporter({
validator: this.importValidator,
onImport: this.onImport,
onImportFailure: this.onImportFailure,
}),
themeExporter: newExporter({
filename: 'pleroma_theme',
getExportedObject: () => this.exportedTheme,
}),
availableStyles: [],
selected: '',
selectedTheme: useMergedConfigStore().mergedConfig.theme,
themeWarning: undefined,
tempImportFile: undefined,
engineVersion: 0,
previewTheme: {},
shadowsInvalid: true,
colorsInvalid: true,
radiiInvalid: true,
keepColor: false,
keepShadows: false,
keepOpacity: false,
keepRoundness: false,
keepFonts: false,
...Object.keys(SLOT_INHERITANCE)
.map((key) => [key, ''])
.reduce(
(acc, [key, val]) => ({ ...acc, [key + 'ColorLocal']: val }),
{},
),
...Object.keys(OPACITIES)
.map((key) => [key, ''])
.reduce(
(acc, [key, val]) => ({ ...acc, [key + 'OpacityLocal']: val }),
{},
),
shadowSelected: undefined,
shadowsLocal: {},
fontsLocal: {},
btnRadiusLocal: '',
inputRadiusLocal: '',
checkboxRadiusLocal: '',
panelRadiusLocal: '',
avatarRadiusLocal: '',
avatarAltRadiusLocal: '',
attachmentRadiusLocal: '',
tooltipRadiusLocal: '',
chatMessageRadiusLocal: '',
}
},
created() {
const currentIndex = useInstanceStore().themesIndex
let promise
if (currentIndex) {
promise = Promise.resolve(currentIndex)
} else {
promise = useInterfaceStore().fetchThemesIndex()
}
promise.then((themesIndex) => {
Object.values(themesIndex).forEach((themeFunc) => {
themeFunc().then(
(themeData) => themeData && this.availableStyles.push(themeData),
)
})
})
},
mounted() {
if (this.shadowSelected === undefined) {
this.shadowSelected = this.shadowsAvailable[0]
}
},
computed: {
themeWarningHelp() {
if (!this.themeWarning) return
const t = this.$t
const pre = 'settings.style.switcher.help.'
const { origin, themeEngineVersion, type, noActionsPossible } =
this.themeWarning
if (origin === 'file') {
// Loaded v2 theme from file
if (themeEngineVersion === 2 && type === 'wrong_version') {
return t(pre + 'v2_imported')
}
if (themeEngineVersion > CURRENT_VERSION) {
return (
t(pre + 'future_version_imported') +
' ' +
(noActionsPossible
? t(pre + 'snapshot_missing')
: t(pre + 'snapshot_present'))
)
}
if (themeEngineVersion < CURRENT_VERSION) {
return (
t(pre + 'future_version_imported') +
' ' +
(noActionsPossible
? t(pre + 'snapshot_missing')
: t(pre + 'snapshot_present'))
)
}
} else if (origin === 'localStorage') {
if (type === 'snapshot_source_mismatch') {
return t(pre + 'snapshot_source_mismatch')
}
// FE upgraded from v2
if (themeEngineVersion === 2) {
return t(pre + 'upgraded_from_v2')
}
// Admin downgraded FE
if (themeEngineVersion > CURRENT_VERSION) {
return (
t(pre + 'fe_downgraded') +
' ' +
(noActionsPossible
? t(pre + 'migration_snapshot_ok')
: t(pre + 'migration_snapshot_gone'))
)
}
// Admin upgraded FE
if (themeEngineVersion < CURRENT_VERSION) {
return (
t(pre + 'fe_upgraded') +
' ' +
(noActionsPossible
? t(pre + 'migration_snapshot_ok')
: t(pre + 'migration_snapshot_gone'))
)
}
}
},
selectedVersion() {
return Array.isArray(this.selectedTheme) ? 1 : 2
},
currentColors() {
return Object.keys(SLOT_INHERITANCE)
.map((key) => [key, this[key + 'ColorLocal']])
.reduce((acc, [key, val]) => ({ ...acc, [key]: val }), {})
},
currentOpacity() {
return Object.keys(OPACITIES)
.map((key) => [key, this[key + 'OpacityLocal']])
.reduce((acc, [key, val]) => ({ ...acc, [key]: val }), {})
},
currentRadii() {
return {
btn: this.btnRadiusLocal,
input: this.inputRadiusLocal,
checkbox: this.checkboxRadiusLocal,
panel: this.panelRadiusLocal,
avatar: this.avatarRadiusLocal,
avatarAlt: this.avatarAltRadiusLocal,
tooltip: this.tooltipRadiusLocal,
attachment: this.attachmentRadiusLocal,
chatMessage: this.chatMessageRadiusLocal,
}
},
// This needs optimization maybe
previewContrast() {
try {
if (!this.previewTheme.colors.bg) return {}
const colors = this.previewTheme.colors
const opacity = this.previewTheme.opacity
if (!colors.bg) return {}
const hints = (ratio) => ({
text: ratio.toPrecision(3) + ':1',
// AA level, AAA level
aa: ratio >= 4.5,
aaa: ratio >= 7,
// same but for 18pt+ texts
laa: ratio >= 3,
laaa: ratio >= 4.5,
})
const colorsConverted = Object.entries(colors).reduce(
(acc, [key, value]) => ({ ...acc, [key]: colorConvert(value) }),
{},
)
const ratios = Object.entries(SLOT_INHERITANCE).reduce(
(acc, [key, value]) => {
const slotIsBaseText = key === 'text' || key === 'link'
const slotIsText =
slotIsBaseText ||
(typeof value === 'object' && value !== null && value.textColor)
if (!slotIsText) return acc
const { layer, variant } = slotIsBaseText ? { layer: 'bg' } : value
const background = variant || layer
const opacitySlot = getOpacitySlot(background)
const textColors = [
key,
...(background === 'bg'
? ['cRed', 'cGreen', 'cBlue', 'cOrange']
: []),
]
const layers = getLayers(
layer,
variant || layer,
opacitySlot,
colorsConverted,
opacity,
)
// Temporary patch for null-y value errors
if (layers.flat().some((v) => v == null)) return acc
return {
...acc,
...textColors.reduce((acc, textColorKey) => {
const newKey = slotIsBaseText
? 'bg' + textColorKey[0].toUpperCase() + textColorKey.slice(1)
: textColorKey
return {
...acc,
[newKey]: getContrastRatioLayers(
colorsConverted[textColorKey],
layers,
colorsConverted[textColorKey],
),
}
}, {}),
}
},
{},
)
return Object.entries(ratios).reduce((acc, [k, v]) => {
acc[k] = hints(v)
return acc
}, {})
} catch (e) {
console.warn('Failure computing contrasts', e)
return {}
}
},
themeDataUsed() {
return useInterfaceStore().themeDataUsed
},
shadowsAvailable() {
- return Object.keys(DEFAULT_SHADOWS).sort()
+ return Object.keys(DEFAULT_SHADOWS).sort((a, b) => a.localeCompare(b))
},
currentShadowOverriden: {
get() {
return !!this.currentShadow
},
set(val) {
if (val) {
this.shadowsLocal[this.shadowSelected] = (
this.currentShadowFallback || []
).map((s) => ({
name: null,
x: 0,
y: 0,
blur: 0,
spread: 0,
inset: false,
color: '#000000',
alpha: 1,
...s,
}))
} else {
delete this.shadowsLocal[this.shadowSelected]
}
},
},
currentShadowFallback() {
- return (this.previewTheme.shadows || {})[this.shadowSelected]
+ return this.previewTheme.shadows?.[this.shadowSelected]
},
currentShadow: {
get() {
return this.shadowsLocal[this.shadowSelected]
},
set(v) {
this.shadowsLocal[this.shadowSelected] = v
},
},
themeValid() {
return !this.shadowsInvalid && !this.colorsInvalid && !this.radiiInvalid
},
exportedTheme() {
const saveEverything =
!this.keepFonts &&
!this.keepShadows &&
!this.keepOpacity &&
!this.keepRoundness &&
!this.keepColor
const source = {
themeEngineVersion: CURRENT_VERSION,
}
if (this.keepFonts || saveEverything) {
source.fonts = this.fontsLocal
}
if (this.keepShadows || saveEverything) {
source.shadows = this.shadowsLocal
}
if (this.keepOpacity || saveEverything) {
source.opacity = this.currentOpacity
}
if (this.keepColor || saveEverything) {
source.colors = this.currentColors
}
if (this.keepRoundness || saveEverything) {
source.radii = this.currentRadii
}
const theme = {
themeEngineVersion: CURRENT_VERSION,
...this.previewTheme,
}
return {
// To separate from other random JSON files and possible future source formats
_pleroma_theme_version: 2,
theme,
source,
}
},
isActive() {
const tabSwitcher = this.$parent
return tabSwitcher ? tabSwitcher.isActive('theme') : false
},
},
components: {
ColorInput,
OpacityInput,
RangeInput,
ContrastRatio,
ShadowControl,
FontControl,
TabSwitcher,
Preview,
Checkbox,
Select,
},
methods: {
loadTheme(
{ theme, source, _pleroma_theme_version: fileVersion },
origin,
forceUseSource = false,
) {
this.dismissWarning()
const version =
origin === 'localStorage' && !theme.colors ? 'l1' : fileVersion
- const snapshotEngineVersion = (theme || {}).themeEngineVersion
- const themeEngineVersion = (source || {}).themeEngineVersion || 2
+ const snapshotEngineVersion = theme?.themeEngineVersion
+ const themeEngineVersion = source?.themeEngineVersion || 2
const versionsMatch = themeEngineVersion === CURRENT_VERSION
const sourceSnapshotMismatch =
theme !== undefined &&
source !== undefined &&
themeEngineVersion !== snapshotEngineVersion
// Force loading of source if user requested it or if snapshot
// is unavailable
const forcedSourceLoad = (source && forceUseSource) || !theme
if (
!(versionsMatch && !sourceSnapshotMismatch) &&
!forcedSourceLoad &&
version !== 'l1' &&
origin !== 'defaults'
) {
if (sourceSnapshotMismatch && origin === 'localStorage') {
this.themeWarning = {
origin,
themeEngineVersion,
type: 'snapshot_source_mismatch',
}
} else if (!theme) {
this.themeWarning = {
origin,
noActionsPossible: true,
themeEngineVersion,
type: 'no_snapshot_old_version',
}
} else if (!versionsMatch) {
this.themeWarning = {
origin,
noActionsPossible: !source,
themeEngineVersion,
type: 'wrong_version',
}
}
}
this.normalizeLocalState(theme, version, source, forcedSourceLoad)
},
forceLoadLocalStorage() {
this.loadThemeFromLocalStorage(true)
},
dismissWarning() {
this.themeWarning = undefined
this.tempImportFile = undefined
},
forceLoad() {
const { origin } = this.themeWarning
switch (origin) {
case 'localStorage':
this.loadThemeFromLocalStorage(true)
break
case 'file':
this.onImport(this.tempImportFile, true)
break
}
this.dismissWarning()
},
forceSnapshot() {
const { origin } = this.themeWarning
switch (origin) {
case 'localStorage':
this.loadThemeFromLocalStorage(false, true)
break
case 'file':
console.error('Forcing snapshot from file is not supported yet')
break
}
this.dismissWarning()
},
loadThemeFromLocalStorage(confirmLoadSource = false) {
const theme = this.themeDataUsed?.source
if (theme) {
this.loadTheme(
{
theme,
},
'localStorage',
confirmLoadSource,
)
}
},
setCustomTheme() {
useInterfaceStore().setTheme({
themeFileVersion: this.selectedVersion,
themeEngineVersion: CURRENT_VERSION,
shadows: this.shadowsLocal,
fonts: this.fontsLocal,
opacity: this.currentOpacity,
colors: this.currentColors,
radii: this.currentRadii,
})
},
updatePreviewColors() {
const result = generateColors({
opacity: this.currentOpacity,
colors: this.currentColors,
})
this.previewTheme.colors = result.theme.colors
this.previewTheme.opacity = result.theme.opacity
},
updatePreviewShadows() {
this.previewTheme.shadows = generateShadows(
{
shadows: this.shadowsLocal,
opacity: this.previewTheme.opacity,
themeEngineVersion: this.engineVersion,
},
this.previewTheme.colors,
relativeLuminance(this.previewTheme.colors.bg) < 0.5 ? 1 : -1,
).theme.shadows
},
importTheme() {
this.themeImporter.importData()
},
exportTheme() {
this.themeExporter.exportData()
},
onImport(parsed, forceSource = false) {
this.tempImportFile = parsed
this.loadTheme(parsed, 'file', forceSource)
},
onImportFailure() {
useInterfaceStore().pushGlobalNotice({
messageKey: 'settings.invalid_theme_imported',
level: 'error',
})
},
importValidator(parsed) {
const version = parsed._pleroma_theme_version
return version >= 1 || version <= 2
},
clearAll() {
this.loadThemeFromLocalStorage()
},
// Clears all the extra stuff when loading V1 theme
clearV1() {
Object.keys(this.$data)
.filter((_) => _.endsWith('ColorLocal') || _.endsWith('OpacityLocal'))
.filter((_) => !v1OnlyNames.includes(_))
.forEach((key) => {
this.$data[key] = undefined
})
},
clearRoundness() {
Object.keys(this.$data)
.filter((_) => _.endsWith('RadiusLocal'))
.forEach((key) => {
this.$data[key] = undefined
})
},
clearOpacity() {
Object.keys(this.$data)
.filter((_) => _.endsWith('OpacityLocal'))
.forEach((key) => {
this.$data[key] = undefined
})
},
clearShadows() {
this.shadowsLocal = {}
},
clearFonts() {
this.fontsLocal = {}
},
/**
* This applies stored theme data onto form. Supports three versions of data:
* v3 (version >= 3) - newest version of themes which supports snapshots for better compatiblity
* v2 (version = 2) - newer version of themes.
* v1 (version = 1) - older version of themes (import from file)
* v1l (version = l1) - older version of theme (load from local storage)
* v1 and v1l differ because of way themes were stored/exported.
* @param {Object} theme - theme data (snapshot)
* @param {Number} version - version of data. 0 means try to guess based on data. "l1" means v1, locastorage type
* @param {Object} source - theme source - this will be used if compatible
* @param {Boolean} source - by default source won't be used if version doesn't match since it might render differently
* this allows importing source anyway
*/
normalizeLocalState(theme, version = 0, source, forceSource = false) {
let input
- if (typeof source !== 'undefined') {
+ if (source !== undefined) {
if (forceSource || source?.themeEngineVersion === CURRENT_VERSION) {
input = source
version = source.themeEngineVersion
} else {
input = theme
}
} else {
input = theme
}
const radii = input.radii || input
const opacity = input.opacity
const shadows = input.shadows || {}
const fonts = input.fonts || {}
const colors = !input.themeEngineVersion
? colors2to3(input.colors || input)
: input.colors || input
if (version === 0) {
if (input.version) version = input.version
// Old v1 naming: fg is text, btn is foreground
if (colors.text === undefined && colors.fg !== undefined) {
version = 1
}
// New v2 naming: text is text, fg is foreground
if (colors.text !== undefined && colors.fg !== undefined) {
version = 2
}
}
this.engineVersion = version
// Stuff that differs between V1 and V2
if (version === 1) {
this.fgColorLocal = rgb2hex(colors.btn)
this.textColorLocal = rgb2hex(colors.fg)
}
if (!this.keepColor) {
this.clearV1()
const keys = new Set(version !== 1 ? Object.keys(SLOT_INHERITANCE) : [])
if (version === 1 || version === 'l1') {
keys
.add('bg')
.add('link')
.add('cRed')
.add('cBlue')
.add('cGreen')
.add('cOrange')
}
keys.forEach((key) => {
const color = colors[key]
const hex = rgb2hex(colors[key])
this[key + 'ColorLocal'] = hex === '#aN' ? color : hex
})
}
if (opacity && !this.keepOpacity) {
this.clearOpacity()
Object.entries(opacity).forEach(([k, v]) => {
if (v === undefined || v === null || Number.isNaN(v)) return
this[k + 'OpacityLocal'] = v
})
}
if (!this.keepRoundness) {
this.clearRoundness()
Object.entries(radii).forEach(([k, v]) => {
// 'Radius' is kept mostly for v1->v2 localstorage transition
const key = k.endsWith('Radius') ? k.split('Radius')[0] : k
this[key + 'RadiusLocal'] = v
})
}
if (!this.keepShadows) {
this.clearShadows()
if (version === 2) {
this.shadowsLocal = shadows2to3(shadows, this.previewTheme.opacity)
} else {
this.shadowsLocal = shadows
}
this.updatePreviewColors()
this.updatePreviewShadows()
this.shadowSelected = this.shadowsAvailable[0]
}
if (!this.keepFonts) {
this.clearFonts()
this.fontsLocal = fonts
}
},
updateTheme3Preview() {
const theme2 = convertTheme2To3(this.previewTheme)
const theme3 = init({
inputRuleset: theme2,
ultimateBackgroundColor: '#000000',
liteMode: true,
})
const sheet = createStyleSheet('theme-tab-overall-preview', 90)
const rule = getScopedVersion(getCssRules(theme3.eager), '&').join('\n')
sheet.clear()
sheet.addRule('#theme-preview {\n' + rule + '\n}')
sheet.ready = true
adoptStyleSheets()
},
},
watch: {
themeDataUsed() {
this.loadThemeFromLocalStorage()
},
currentRadii() {
try {
this.previewTheme.radii = generateRadii({
radii: this.currentRadii,
}).theme.radii
this.radiiInvalid = false
} catch (e) {
this.radiiInvalid = true
console.warn(e)
}
},
shadowsLocal: {
handler() {
try {
this.updatePreviewShadows()
this.shadowsInvalid = false
} catch (e) {
this.shadowsInvalid = true
console.warn(e)
}
},
deep: true,
},
fontsLocal: {
handler() {
try {
this.previewTheme.fonts = generateFonts({
fonts: this.fontsLocal,
}).theme.fonts
this.fontsInvalid = false
} catch (e) {
this.fontsInvalid = true
console.warn(e)
}
},
deep: true,
},
currentColors() {
try {
this.updatePreviewColors()
this.colorsInvalid = false
} catch (e) {
this.colorsInvalid = true
console.warn(e)
}
},
currentOpacity() {
try {
this.updatePreviewColors()
} catch (e) {
console.warn(e)
}
},
selected() {
this.selectedTheme = Object.entries(this.availableStyles).find(
([, s]) => {
if (Array.isArray(s)) {
return s[0] === this.selected
} else {
return s.name === this.selected
}
},
)[1]
},
selectedTheme() {
this.dismissWarning()
if (this.selectedVersion === 1) {
if (!this.keepRoundness) {
this.clearRoundness()
}
if (!this.keepShadows) {
this.clearShadows()
}
if (!this.keepOpacity) {
this.clearOpacity()
}
if (!this.keepColor) {
this.clearV1()
this.bgColorLocal = this.selectedTheme[1]
this.fgColorLocal = this.selectedTheme[2]
this.textColorLocal = this.selectedTheme[3]
this.linkColorLocal = this.selectedTheme[4]
this.cRedColorLocal = this.selectedTheme[5]
this.cGreenColorLocal = this.selectedTheme[6]
this.cBlueColorLocal = this.selectedTheme[7]
this.cOrangeColorLocal = this.selectedTheme[8]
}
} else if (this.selectedVersion >= 2) {
this.normalizeLocalState(
this.selectedTheme.theme,
2,
this.selectedTheme.source,
)
}
},
},
}
diff --git a/src/components/settings_modal/tabs/old_theme_tab/old_theme_tab.vue b/src/components/settings_modal/tabs/old_theme_tab/old_theme_tab.vue
index 751e113a00..9724162cac 100644
--- a/src/components/settings_modal/tabs/old_theme_tab/old_theme_tab.vue
+++ b/src/components/settings_modal/tabs/old_theme_tab/old_theme_tab.vue
@@ -1,1025 +1,1025 @@
<template>
<div class="old-theme-tab">
<div class="alert warning deprecation-warning">
{{ $t("settings.style.themes2_outdated") }}
</div>
<div class="presets-container">
<div class="save-load">
<div
v-if="themeWarning"
class="theme-warning"
>
<div class="alert warning">
{{ themeWarningHelp }}
</div>
<div class="buttons">
<template v-if="themeWarning.type === 'snapshot_source_mismatch'">
<button
class="btn button-default"
@click="forceLoad"
>
{{ $t('settings.style.switcher.use_source') }}
</button>
<button
class="btn button-default"
@click="forceSnapshot"
>
{{ $t('settings.style.switcher.use_snapshot') }}
</button>
</template>
<template v-else-if="themeWarning.noActionsPossible">
<button
class="btn button-default"
@click="dismissWarning"
>
{{ $t('general.dismiss') }}
</button>
</template>
<template v-else>
<button
class="btn button-default"
@click="forceLoad"
>
{{ $t('settings.style.switcher.load_theme') }}
</button>
<button
class="btn button-default"
@click="dismissWarning"
>
{{ $t('settings.style.switcher.keep_as_is') }}
</button>
</template>
</div>
</div>
<div class="top">
<div class="presets">
{{ $t('settings.presets') }}
<label
for="preset-switcher"
class="select"
>
<Select
id="preset-switcher"
v-model="selected"
class="preset-switcher"
>
<option
v-for="style in availableStyles"
:key="style.name"
:value="style.name || style[0]"
:style="{
backgroundColor: style[1] || (style.theme || style.source).colors.bg,
color: style[3] || (style.theme || style.source).colors.text
}"
>
{{ style[0] || style.name }}
</option>
</Select>
</label>
</div>
<div class="export-import">
<button
class="btn button-default"
@click="importTheme"
>
{{ $t(&quot;settings.import_theme&quot;) }}
</button>
<button
class="btn button-default"
@click="exportTheme"
>
{{ $t(&quot;settings.export_theme&quot;) }}
</button>
</div>
</div>
</div>
<div class="save-load-options">
<span class="keep-option">
<Checkbox v-model="keepColor">
{{ $t('settings.style.switcher.keep_color') }}
</Checkbox>
</span>
<span class="keep-option">
<Checkbox v-model="keepShadows">
{{ $t('settings.style.switcher.keep_shadows') }}
</Checkbox>
</span>
<span class="keep-option">
<Checkbox v-model="keepOpacity">
{{ $t('settings.style.switcher.keep_opacity') }}
</Checkbox>
</span>
<span class="keep-option">
<Checkbox v-model="keepRoundness">
{{ $t('settings.style.switcher.keep_roundness') }}
</Checkbox>
</span>
<span class="keep-option">
<Checkbox v-model="keepFonts">
{{ $t('settings.style.switcher.keep_fonts') }}
</Checkbox>
</span>
<p>{{ $t('settings.style.switcher.save_load_hint') }}</p>
</div>
</div>
<preview id="theme-preview" />
<div>
<button
class="btn button-default"
@click="updateTheme3Preview"
>
{{ $t("settings.style.update_preview") }}
</button>
</div>
<keep-alive>
<tab-switcher key="style-tweak">
<div
:label="$t('settings.style.common_colors._tab_label')"
class="color-container"
>
<div class="tab-header">
<p>{{ $t('settings.theme_help') }}</p>
<div class="tab-header-buttons">
<button
class="btn button-default"
@click="clearOpacity"
>
{{ $t('settings.style.switcher.clear_opacity') }}
</button>
<button
class="btn button-default"
@click="clearV1"
>
{{ $t('settings.style.switcher.clear_all') }}
</button>
</div>
</div>
<p>{{ $t('settings.theme_help_v2_1') }}</p>
<h4>{{ $t('settings.style.common_colors.main') }}</h4>
<div class="color-item">
<ColorInput
v-model="bgColorLocal"
name="bgColor"
:label="$t('settings.background')"
/>
<OpacityInput
v-model="bgOpacityLocal"
name="bgOpacity"
:fallback="previewTheme.opacity?.bg"
/>
<ColorInput
v-model="textColorLocal"
name="textColor"
:label="$t('settings.text')"
/>
<ContrastRatio :contrast="previewContrast.bgText" />
<ColorInput
v-model="accentColorLocal"
name="accentColor"
:fallback="previewTheme.colors?.link"
:label="$t('settings.accent')"
- :show-optional-checkbox="typeof linkColorLocal !== 'undefined'"
+ :show-optional-checkbox="linkColorLocal !== undefined"
/>
<ColorInput
v-model="linkColorLocal"
name="linkColor"
:fallback="previewTheme.colors?.accent"
:label="$t('settings.links')"
- :show-optional-checkbox="typeof accentColorLocal !== 'undefined'"
+ :show-optional-checkbox="accentColorLocal !== undefined"
/>
<ContrastRatio :contrast="previewContrast.bgLink" />
</div>
<div class="color-item">
<ColorInput
v-model="fgColorLocal"
name="fgColor"
:label="$t('settings.foreground')"
/>
<ColorInput
v-model="fgTextColorLocal"
name="fgTextColor"
:label="$t('settings.text')"
:fallback="previewTheme.colors?.fgText"
/>
<ColorInput
v-model="fgLinkColorLocal"
name="fgLinkColor"
:label="$t('settings.links')"
:fallback="previewTheme.colors?.fgLink"
/>
<p>{{ $t('settings.style.common_colors.foreground_hint') }}</p>
</div>
<h4>{{ $t('settings.style.common_colors.rgbo') }}</h4>
<div class="color-item">
<ColorInput
v-model="cRedColorLocal"
name="cRedColor"
:label="$t('settings.cRed')"
/>
<ContrastRatio :contrast="previewContrast.bgCRed" />
<ColorInput
v-model="cBlueColorLocal"
name="cBlueColor"
:label="$t('settings.cBlue')"
/>
<ContrastRatio :contrast="previewContrast.bgCBlue" />
</div>
<div class="color-item">
<ColorInput
v-model="cGreenColorLocal"
name="cGreenColor"
:label="$t('settings.cGreen')"
/>
<ContrastRatio :contrast="previewContrast.bgCGreen" />
<ColorInput
v-model="cOrangeColorLocal"
name="cOrangeColor"
:label="$t('settings.cOrange')"
/>
<ContrastRatio :contrast="previewContrast.bgCOrange" />
</div>
<p>{{ $t('settings.theme_help_v2_2') }}</p>
</div>
<div
:label="$t('settings.style.advanced_colors._tab_label')"
class="color-container"
>
<div class="tab-header">
<p>{{ $t('settings.theme_help') }}</p>
<button
class="btn button-default"
@click="clearOpacity"
>
{{ $t('settings.style.switcher.clear_opacity') }}
</button>
<button
class="btn button-default"
@click="clearV1"
>
{{ $t('settings.style.switcher.clear_all') }}
</button>
</div>
<div class="color-item">
<h4>{{ $t('settings.style.advanced_colors.post') }}</h4>
<ColorInput
v-model="postLinkColorLocal"
name="postLinkColor"
:fallback="previewTheme.colors?.accent"
:label="$t('settings.links')"
/>
<ContrastRatio :contrast="previewContrast.postLink" />
<ColorInput
v-model="postGreentextColorLocal"
name="postGreentextColor"
:fallback="previewTheme.colors?.cGreen"
:label="$t('settings.greentext')"
/>
<ContrastRatio :contrast="previewContrast.postGreentext" />
<h4>{{ $t('settings.style.advanced_colors.alert') }}</h4>
<ColorInput
v-model="alertErrorColorLocal"
name="alertError"
:label="$t('settings.style.advanced_colors.alert_error')"
:fallback="previewTheme.colors?.alertError"
/>
<ColorInput
v-model="alertErrorTextColorLocal"
name="alertErrorText"
:label="$t('settings.text')"
:fallback="previewTheme.colors?.alertErrorText"
/>
<ContrastRatio
:contrast="previewContrast.alertErrorText"
large
/>
<ColorInput
v-model="alertWarningColorLocal"
name="alertWarning"
:label="$t('settings.style.advanced_colors.alert_warning')"
:fallback="previewTheme.colors?.alertWarning"
/>
<ColorInput
v-model="alertWarningTextColorLocal"
name="alertWarningText"
:label="$t('settings.text')"
:fallback="previewTheme.colors?.alertWarningText"
/>
<ContrastRatio
:contrast="previewContrast.alertWarningText"
large
/>
<ColorInput
v-model="alertNeutralColorLocal"
name="alertNeutral"
:label="$t('settings.style.advanced_colors.alert_neutral')"
:fallback="previewTheme.colors?.alertNeutral"
/>
<ColorInput
v-model="alertNeutralTextColorLocal"
name="alertNeutralText"
:label="$t('settings.text')"
:fallback="previewTheme.colors?.alertNeutralText"
/>
<ContrastRatio
:contrast="previewContrast.alertNeutralText"
large
/>
<OpacityInput
v-model="alertOpacityLocal"
name="alertOpacity"
:fallback="previewTheme.opacity?.alert"
/>
</div>
<div class="color-item">
<h4>{{ $t('settings.style.advanced_colors.badge') }}</h4>
<ColorInput
v-model="badgeNotificationColorLocal"
name="badgeNotification"
:label="$t('settings.style.advanced_colors.badge_notification')"
:fallback="previewTheme.colors?.badgeNotification"
/>
<ColorInput
v-model="badgeNotificationTextColorLocal"
name="badgeNotificationText"
:label="$t('settings.text')"
:fallback="previewTheme.colors?.badgeNotificationText"
/>
<ContrastRatio
:contrast="previewContrast.badgeNotificationText"
large
/>
</div>
<div class="color-item">
<h4>{{ $t('settings.style.advanced_colors.panel_header') }}</h4>
<ColorInput
v-model="panelColorLocal"
name="panelColor"
:fallback="previewTheme.colors?.panel"
:label="$t('settings.background')"
/>
<OpacityInput
v-model="panelOpacityLocal"
name="panelOpacity"
:fallback="previewTheme.opacity?.panel"
:disabled="panelColorLocal === 'transparent'"
/>
<ColorInput
v-model="panelTextColorLocal"
name="panelTextColor"
:fallback="previewTheme.colors?.panelText"
:label="$t('settings.text')"
/>
<ContrastRatio
:contrast="previewContrast.panelText"
large
/>
<ColorInput
v-model="panelLinkColorLocal"
name="panelLinkColor"
:fallback="previewTheme.colors?.panelLink"
:label="$t('settings.links')"
/>
<ContrastRatio
:contrast="previewContrast.panelLink"
large
/>
</div>
<div class="color-item">
<h4>{{ $t('settings.style.advanced_colors.top_bar') }}</h4>
<ColorInput
v-model="topBarColorLocal"
name="topBarColor"
:fallback="previewTheme.colors?.topBar"
:label="$t('settings.background')"
/>
<ColorInput
v-model="topBarTextColorLocal"
name="topBarTextColor"
:fallback="previewTheme.colors?.topBarText"
:label="$t('settings.text')"
/>
<ContrastRatio :contrast="previewContrast.topBarText" />
<ColorInput
v-model="topBarLinkColorLocal"
name="topBarLinkColor"
:fallback="previewTheme.colors?.topBarLink"
:label="$t('settings.links')"
/>
<ContrastRatio :contrast="previewContrast.topBarLink" />
</div>
<div class="color-item">
<h4>{{ $t('settings.style.advanced_colors.inputs') }}</h4>
<ColorInput
v-model="inputColorLocal"
name="inputColor"
:fallback="previewTheme.colors?.input"
:label="$t('settings.background')"
/>
<OpacityInput
v-model="inputOpacityLocal"
name="inputOpacity"
:fallback="previewTheme.opacity?.input"
:disabled="inputColorLocal === 'transparent'"
/>
<ColorInput
v-model="inputTextColorLocal"
name="inputTextColor"
:fallback="previewTheme.colors?.inputText"
:label="$t('settings.text')"
/>
<ContrastRatio :contrast="previewContrast.inputText" />
</div>
<div class="color-item">
<h4>{{ $t('settings.style.advanced_colors.buttons') }}</h4>
<ColorInput
v-model="btnColorLocal"
name="btnColor"
:fallback="previewTheme.colors?.btn"
:label="$t('settings.background')"
/>
<OpacityInput
v-model="btnOpacityLocal"
name="btnOpacity"
:fallback="previewTheme.opacity?.btn"
:disabled="btnColorLocal === 'transparent'"
/>
<ColorInput
v-model="btnTextColorLocal"
name="btnTextColor"
:fallback="previewTheme.colors?.btnText"
:label="$t('settings.text')"
/>
<ContrastRatio :contrast="previewContrast.btnText" />
<ColorInput
v-model="btnPanelTextColorLocal"
name="btnPanelTextColor"
:fallback="previewTheme.colors?.btnPanelText"
:label="$t('settings.style.advanced_colors.panel_header')"
/>
<ContrastRatio :contrast="previewContrast.btnPanelText" />
<ColorInput
v-model="btnTopBarTextColorLocal"
name="btnTopBarTextColor"
:fallback="previewTheme.colors?.btnTopBarText"
:label="$t('settings.style.advanced_colors.top_bar')"
/>
<ContrastRatio :contrast="previewContrast.btnTopBarText" />
<h5>{{ $t('settings.style.advanced_colors.pressed') }}</h5>
<ColorInput
v-model="btnPressedColorLocal"
name="btnPressedColor"
:fallback="previewTheme.colors?.btnPressed"
:label="$t('settings.background')"
/>
<ColorInput
v-model="btnPressedTextColorLocal"
name="btnPressedTextColor"
:fallback="previewTheme.colors?.btnPressedText"
:label="$t('settings.text')"
/>
<ContrastRatio :contrast="previewContrast.btnPressedText" />
<ColorInput
v-model="btnPressedPanelTextColorLocal"
name="btnPressedPanelTextColor"
:fallback="previewTheme.colors?.btnPressedPanelText"
:label="$t('settings.style.advanced_colors.panel_header')"
/>
<ContrastRatio :contrast="previewContrast.btnPressedPanelText" />
<ColorInput
v-model="btnPressedTopBarTextColorLocal"
name="btnPressedTopBarTextColor"
:fallback="previewTheme.colors?.btnPressedTopBarText"
:label="$t('settings.style.advanced_colors.top_bar')"
/>
<ContrastRatio :contrast="previewContrast.btnPressedTopBarText" />
<h5>{{ $t('settings.style.advanced_colors.disabled') }}</h5>
<ColorInput
v-model="btnDisabledColorLocal"
name="btnDisabledColor"
:fallback="previewTheme.colors?.btnDisabled"
:label="$t('settings.background')"
/>
<ColorInput
v-model="btnDisabledTextColorLocal"
name="btnDisabledTextColor"
:fallback="previewTheme.colors?.btnDisabledText"
:label="$t('settings.text')"
/>
<ColorInput
v-model="btnDisabledPanelTextColorLocal"
name="btnDisabledPanelTextColor"
:fallback="previewTheme.colors?.btnDisabledPanelText"
:label="$t('settings.style.advanced_colors.panel_header')"
/>
<ColorInput
v-model="btnDisabledTopBarTextColorLocal"
name="btnDisabledTopBarTextColor"
:fallback="previewTheme.colors?.btnDisabledTopBarText"
:label="$t('settings.style.advanced_colors.top_bar')"
/>
<h5>{{ $t('settings.style.advanced_colors.toggled') }}</h5>
<ColorInput
v-model="btnToggledColorLocal"
name="btnToggledColor"
:fallback="previewTheme.colors?.btnToggled"
:label="$t('settings.background')"
/>
<ColorInput
v-model="btnToggledTextColorLocal"
name="btnToggledTextColor"
:fallback="previewTheme.colors?.btnToggledText"
:label="$t('settings.text')"
/>
<ContrastRatio :contrast="previewContrast.btnToggledText" />
<ColorInput
v-model="btnToggledPanelTextColorLocal"
name="btnToggledPanelTextColor"
:fallback="previewTheme.colors?.btnToggledPanelText"
:label="$t('settings.style.advanced_colors.panel_header')"
/>
<ContrastRatio :contrast="previewContrast.btnToggledPanelText" />
<ColorInput
v-model="btnToggledTopBarTextColorLocal"
name="btnToggledTopBarTextColor"
:fallback="previewTheme.colors?.btnToggledTopBarText"
:label="$t('settings.style.advanced_colors.top_bar')"
/>
<ContrastRatio :contrast="previewContrast.btnToggledTopBarText" />
</div>
<div class="color-item">
<h4>{{ $t('settings.style.advanced_colors.tabs') }}</h4>
<ColorInput
v-model="tabColorLocal"
name="tabColor"
:fallback="previewTheme.colors?.tab"
:label="$t('settings.background')"
/>
<ColorInput
v-model="tabTextColorLocal"
name="tabTextColor"
:fallback="previewTheme.colors?.tabText"
:label="$t('settings.text')"
/>
<ContrastRatio :contrast="previewContrast.tabText" />
<ColorInput
v-model="tabActiveTextColorLocal"
name="tabActiveTextColor"
:fallback="previewTheme.colors?.tabActiveText"
:label="$t('settings.text')"
/>
<ContrastRatio :contrast="previewContrast.tabActiveText" />
</div>
<div class="color-item">
<h4>{{ $t('settings.style.advanced_colors.borders') }}</h4>
<ColorInput
v-model="borderColorLocal"
name="borderColor"
:fallback="previewTheme.colors?.border"
:label="$t('settings.style.common.color')"
/>
<OpacityInput
v-model="borderOpacityLocal"
name="borderOpacity"
:fallback="previewTheme.opacity?.border"
:disabled="borderColorLocal === 'transparent'"
/>
</div>
<div class="color-item">
<h4>{{ $t('settings.style.advanced_colors.faint_text') }}</h4>
<ColorInput
v-model="faintColorLocal"
name="faintColor"
:fallback="previewTheme.colors?.faint"
:label="$t('settings.text')"
/>
<ColorInput
v-model="faintLinkColorLocal"
name="faintLinkColor"
:fallback="previewTheme.colors?.faintLink"
:label="$t('settings.links')"
/>
<ColorInput
v-model="panelFaintColorLocal"
name="panelFaintColor"
:fallback="previewTheme.colors?.panelFaint"
:label="$t('settings.style.advanced_colors.panel_header')"
/>
<OpacityInput
v-model="faintOpacityLocal"
name="faintOpacity"
:fallback="previewTheme.opacity?.faint"
/>
</div>
<div class="color-item">
<h4>{{ $t('settings.style.advanced_colors.underlay') }}</h4>
<ColorInput
v-model="underlayColorLocal"
name="underlay"
:label="$t('settings.style.advanced_colors.underlay')"
:fallback="previewTheme.colors?.underlay"
/>
<OpacityInput
v-model="underlayOpacityLocal"
name="underlayOpacity"
:fallback="previewTheme.opacity?.underlay"
:disabled="underlayOpacityLocal === 'transparent'"
/>
</div>
<div class="color-item">
<h4>{{ $t('settings.style.advanced_colors.wallpaper') }}</h4>
<ColorInput
v-model="wallpaperColorLocal"
name="wallpaper"
:label="$t('settings.style.advanced_colors.wallpaper')"
:fallback="previewTheme.colors?.wallpaper"
/>
</div>
<div class="color-item">
<h4>{{ $t('settings.style.advanced_colors.poll') }}</h4>
<ColorInput
v-model="pollColorLocal"
name="poll"
:label="$t('settings.background')"
:fallback="previewTheme.colors?.poll"
/>
<ColorInput
v-model="pollTextColorLocal"
name="pollText"
:label="$t('settings.text')"
:fallback="previewTheme.colors?.pollText"
/>
</div>
<div class="color-item">
<h4>{{ $t('settings.style.advanced_colors.icons') }}</h4>
<ColorInput
v-model="iconColorLocal"
name="icon"
:label="$t('settings.style.advanced_colors.icons')"
:fallback="previewTheme.colors?.icon"
/>
</div>
<div class="color-item">
<h4>{{ $t('settings.style.advanced_colors.highlight') }}</h4>
<ColorInput
v-model="highlightColorLocal"
name="highlight"
:label="$t('settings.background')"
:fallback="previewTheme.colors?.highlight"
/>
<ColorInput
v-model="highlightTextColorLocal"
name="highlightText"
:label="$t('settings.text')"
:fallback="previewTheme.colors?.highlightText"
/>
<ContrastRatio :contrast="previewContrast.highlightText" />
<ColorInput
v-model="highlightLinkColorLocal"
name="highlightLink"
:label="$t('settings.links')"
:fallback="previewTheme.colors?.highlightLink"
/>
<ContrastRatio :contrast="previewContrast.highlightLink" />
</div>
<div class="color-item">
<h4>{{ $t('settings.style.advanced_colors.popover') }}</h4>
<ColorInput
v-model="popoverColorLocal"
name="popover"
:label="$t('settings.background')"
:fallback="previewTheme.colors?.popover"
/>
<OpacityInput
v-model="popoverOpacityLocal"
name="popoverOpacity"
:fallback="previewTheme.opacity?.popover"
:disabled="popoverOpacityLocal === 'transparent'"
/>
<ColorInput
v-model="popoverTextColorLocal"
name="popoverText"
:label="$t('settings.text')"
:fallback="previewTheme.colors?.popoverText"
/>
<ContrastRatio :contrast="previewContrast.popoverText" />
<ColorInput
v-model="popoverLinkColorLocal"
name="popoverLink"
:label="$t('settings.links')"
:fallback="previewTheme.colors?.popoverLink"
/>
<ContrastRatio :contrast="previewContrast.popoverLink" />
</div>
<div class="color-item">
<h4>{{ $t('settings.style.advanced_colors.selectedPost') }}</h4>
<ColorInput
v-model="selectedPostColorLocal"
name="selectedPost"
:label="$t('settings.background')"
:fallback="previewTheme.colors?.selectedPost"
/>
<ColorInput
v-model="selectedPostTextColorLocal"
name="selectedPostText"
:label="$t('settings.text')"
:fallback="previewTheme.colors?.selectedPostText"
/>
<ContrastRatio :contrast="previewContrast.selectedPostText" />
<ColorInput
v-model="selectedPostLinkColorLocal"
name="selectedPostLink"
:label="$t('settings.links')"
:fallback="previewTheme.colors?.selectedPostLink"
/>
<ContrastRatio :contrast="previewContrast.selectedPostLink" />
</div>
<div class="color-item">
<h4>{{ $t('settings.style.advanced_colors.selectedMenu') }}</h4>
<ColorInput
v-model="selectedMenuColorLocal"
name="selectedMenu"
:label="$t('settings.background')"
:fallback="previewTheme.colors?.selectedMenu"
/>
<ColorInput
v-model="selectedMenuTextColorLocal"
name="selectedMenuText"
:label="$t('settings.text')"
:fallback="previewTheme.colors?.selectedMenuText"
/>
<ContrastRatio :contrast="previewContrast.selectedMenuText" />
<ColorInput
v-model="selectedMenuLinkColorLocal"
name="selectedMenuLink"
:label="$t('settings.links')"
:fallback="previewTheme.colors?.selectedMenuLink"
/>
<ContrastRatio :contrast="previewContrast.selectedMenuLink" />
</div>
<div class="color-item">
<h4>{{ $t('chats.chats') }}</h4>
<ColorInput
v-model="chatBgColorLocal"
name="chatBgColor"
:fallback="previewTheme.colors?.bg"
:label="$t('settings.background')"
/>
<h5>{{ $t('settings.style.advanced_colors.chat.incoming') }}</h5>
<ColorInput
v-model="chatMessageIncomingBgColorLocal"
name="chatMessageIncomingBgColor"
:fallback="previewTheme.colors?.bg"
:label="$t('settings.background')"
/>
<ColorInput
v-model="chatMessageIncomingTextColorLocal"
name="chatMessageIncomingTextColor"
:fallback="previewTheme.colors?.text"
:label="$t('settings.text')"
/>
<ColorInput
v-model="chatMessageIncomingLinkColorLocal"
name="chatMessageIncomingLinkColor"
:fallback="previewTheme.colors?.link"
:label="$t('settings.links')"
/>
<ColorInput
v-model="chatMessageIncomingBorderColorLocal"
name="chatMessageIncomingBorderLinkColor"
:fallback="previewTheme.colors?.fg"
:label="$t('settings.style.advanced_colors.chat.border')"
/>
<h5>{{ $t('settings.style.advanced_colors.chat.outgoing') }}</h5>
<ColorInput
v-model="chatMessageOutgoingBgColorLocal"
name="chatMessageOutgoingBgColor"
:fallback="previewTheme.colors?.bg"
:label="$t('settings.background')"
/>
<ColorInput
v-model="chatMessageOutgoingTextColorLocal"
name="chatMessageOutgoingTextColor"
:fallback="previewTheme.colors?.text"
:label="$t('settings.text')"
/>
<ColorInput
v-model="chatMessageOutgoingLinkColorLocal"
name="chatMessageOutgoingLinkColor"
:fallback="previewTheme.colors?.link"
:label="$t('settings.links')"
/>
<ColorInput
v-model="chatMessageOutgoingBorderColorLocal"
name="chatMessageOutgoingBorderLinkColor"
:fallback="previewTheme.colors?.bg"
:label="$t('settings.style.advanced_colors.chat.border')"
/>
</div>
</div>
<div
:label="$t('settings.style.radii._tab_label')"
class="radius-container"
>
<div class="tab-header">
<p>{{ $t('settings.radii_help') }}</p>
<button
class="btn button-default"
@click="clearRoundness"
>
{{ $t('settings.style.switcher.clear_all') }}
</button>
</div>
<RangeInput
v-model="btnRadiusLocal"
name="btnRadius"
:label="$t('settings.btnRadius')"
:fallback="previewTheme.radii?.btn"
max="16"
hard-min="0"
/>
<RangeInput
v-model="inputRadiusLocal"
name="inputRadius"
:label="$t('settings.inputRadius')"
:fallback="previewTheme.radii?.input"
max="9"
hard-min="0"
/>
<RangeInput
v-model="checkboxRadiusLocal"
name="checkboxRadius"
:label="$t('settings.checkboxRadius')"
:fallback="previewTheme.radii?.checkbox"
max="16"
hard-min="0"
/>
<RangeInput
v-model="panelRadiusLocal"
name="panelRadius"
:label="$t('settings.panelRadius')"
:fallback="previewTheme.radii?.panel"
max="50"
hard-min="0"
/>
<RangeInput
v-model="avatarRadiusLocal"
name="avatarRadius"
:label="$t('settings.avatarRadius')"
:fallback="previewTheme.radii?.avatar"
max="28"
hard-min="0"
/>
<RangeInput
v-model="avatarAltRadiusLocal"
name="avatarAltRadius"
:label="$t('settings.avatarAltRadius')"
:fallback="previewTheme.radii?.avatarAlt"
max="28"
hard-min="0"
/>
<RangeInput
v-model="attachmentRadiusLocal"
name="attachmentRadius"
:label="$t('settings.attachmentRadius')"
:fallback="previewTheme.radii?.attachment"
max="50"
hard-min="0"
/>
<RangeInput
v-model="tooltipRadiusLocal"
name="tooltipRadius"
:label="$t('settings.tooltipRadius')"
:fallback="previewTheme.radii?.tooltip"
max="50"
hard-min="0"
/>
<RangeInput
v-model="chatMessageRadiusLocal"
name="chatMessageRadius"
:label="$t('settings.chatMessageRadius')"
:fallback="previewTheme.radii?.chatMessage || 2"
max="50"
hard-min="0"
/>
</div>
<div
:label="$t('settings.style.shadows._tab_label')"
class="shadow-container"
>
<div class="tab-header shadow-selector">
<div class="select-container">
{{ $t('settings.style.shadows.component') }}
{{ ' ' }}
<Select
id="shadow-switcher"
v-model="shadowSelected"
class="shadow-switcher"
>
<option
v-for="shadow in shadowsAvailable"
:key="shadow"
:value="shadow"
>
{{ $t('settings.style.shadows.components.' + shadow) }}
</option>
</Select>
</div>
<div class="override">
<Checkbox
id="override"
v-model="currentShadowOverriden"
name="override"
class="input-override"
>
{{ $t('settings.style.shadows.override') }}
</Checkbox>
</div>
<button
class="btn button-default"
@click="clearShadows"
>
{{ $t('settings.style.switcher.clear_all') }}
</button>
</div>
<ShadowControl
v-model="currentShadow"
:separate-inset="shadowSelected === 'avatar' || shadowSelected === 'avatarStatus'"
:fallback="currentShadowFallback"
:static-vars="previewTheme.colors"
:compact="true"
/>
</div>
<div
:label="$t('settings.style.fonts._tab_label')"
class="fonts-container"
>
<div class="tab-header">
<p>{{ $t('settings.style.fonts.help') }}</p>
<button
class="btn button-default"
@click="clearFonts"
>
{{ $t('settings.style.switcher.clear_all') }}
</button>
</div>
<FontControl
v-model="fontsLocal.interface"
name="ui"
:label="$t('settings.style.fonts.components.interface')"
:fallback="previewTheme.fonts?.interface"
no-inherit="1"
/>
<FontControl
v-model="fontsLocal.input"
name="input"
:label="$t('settings.style.fonts.components.input')"
:fallback="previewTheme.fonts?.input"
/>
<FontControl
v-model="fontsLocal.post"
name="post"
:label="$t('settings.style.fonts.components.post')"
:fallback="previewTheme.fonts?.post"
/>
<FontControl
v-model="fontsLocal.postCode"
name="postCode"
:label="$t('settings.style.fonts.components.postCode')"
:fallback="previewTheme.fonts?.postCode"
/>
</div>
</tab-switcher>
</keep-alive>
<teleport
v-if="isActive"
to="#unscrolled-content"
>
<div class="apply-container">
<button
class="btn button-default submit"
:disabled="!themeValid"
@click="setCustomTheme"
>
{{ $t('general.apply') }}
</button>
<button
class="btn button-default"
@click="clearAll"
>
{{ $t('settings.style.switcher.reset') }}
</button>
</div>
</teleport>
</div>
</template>
<script src="./old_theme_tab.js"></script>
<style src="./old_theme_tab.scss" lang="scss"></style>
diff --git a/src/components/side_drawer/side_drawer.js b/src/components/side_drawer/side_drawer.js
index 369177c7ac..a1dca81618 100644
--- a/src/components/side_drawer/side_drawer.js
+++ b/src/components/side_drawer/side_drawer.js
@@ -1,138 +1,138 @@
import { mapActions, mapState } from 'pinia'
import { mapGetters } from 'vuex'
import { USERNAME_ROUTES } from 'src/components/navigation/navigation.js'
import UserCard from 'src/components/user_card/user_card.vue'
import GestureService from '../../services/gesture_service/gesture_service'
import { unseenNotificationsFromStore } from '../../services/notification_utils/notification_utils'
import { useAnnouncementsStore } from 'src/stores/announcements'
import { useChatsStore } from 'src/stores/chats.js'
import { useInstanceStore } from 'src/stores/instance.js'
import { useInstanceCapabilitiesStore } from 'src/stores/instance_capabilities.js'
import { useInterfaceStore } from 'src/stores/interface'
import { useMergedConfigStore } from 'src/stores/merged_config.js'
import { useShoutStore } from 'src/stores/shout'
import { library } from '@fortawesome/fontawesome-svg-core'
import {
faBell,
faBullhorn,
faCog,
faComments,
faCompass,
faFilePen,
faHome,
faInfoCircle,
faList,
faSearch,
faSignInAlt,
faSignOutAlt,
faTachometerAlt,
faUserPlus,
} from '@fortawesome/free-solid-svg-icons'
library.add(
faSignInAlt,
faSignOutAlt,
faHome,
faComments,
faBell,
faUserPlus,
faBullhorn,
faSearch,
faTachometerAlt,
faCog,
faInfoCircle,
faCompass,
faList,
faFilePen,
)
const SideDrawer = {
props: ['logout'],
data: () => ({
closed: true,
closeGesture: undefined,
}),
created() {
this.closeGesture = GestureService.swipeGesture(
GestureService.DIRECTION_LEFT,
this.toggleDrawer,
)
- if (this.currentUser && this.currentUser.locked) {
+ if (this.currentUser?.locked) {
this.$store.dispatch('startFetchingFollowRequests')
}
},
components: {
UserCard,
},
computed: {
currentUser() {
return this.$store.state.users.currentUser
},
shout() {
return useShoutStore().joined
},
unseenNotifications() {
return unseenNotificationsFromStore(
this.$store,
useMergedConfigStore().mergedConfig.notificationVisibility,
useMergedConfigStore().mergedConfig.ignoreInactionableSeen,
)
},
unseenNotificationsCount() {
return this.unseenNotifications.length
},
followRequestCount() {
return this.$store.state.api.followRequests.length
},
timelinesRoute() {
let name
if (useInterfaceStore().lastTimeline) {
name = useInterfaceStore().lastTimeline
}
name = this.currentUser ? 'friends' : 'public-timeline'
if (USERNAME_ROUTES.has(name)) {
return { name, params: { username: this.currentUser.screen_name } }
} else {
return { name }
}
},
...mapState(useAnnouncementsStore, [
'supportsAnnouncements',
'unreadAnnouncementCount',
]),
...mapState(useInstanceCapabilitiesStore, [
'pleromaChatMessagesAvailable',
'suggestionsEnabled',
]),
...mapState(useInstanceStore, ['privateMode', 'federating']),
...mapState(useInstanceStore, {
logo: (store) => store.instanceIdentity.logo,
sitename: (store) => store.instanceIdentity.name,
hideSitename: (store) => store.instanceIdentity.hideSitename,
}),
...mapState(useChatsStore, ['unreadChatsCount']),
...mapGetters(['draftCount']),
},
methods: {
toggleDrawer() {
this.closed = !this.closed
},
doLogout() {
this.logout()
this.toggleDrawer()
},
touchStart(e) {
GestureService.beginSwipe(e, this.closeGesture)
},
touchMove(e) {
GestureService.updateSwipe(e, this.closeGesture)
},
...mapActions(useInterfaceStore, ['openSettingsModal']),
},
}
export default SideDrawer
diff --git a/src/components/status/status.js b/src/components/status/status.js
index 61a553ef66..a33d295257 100644
--- a/src/components/status/status.js
+++ b/src/components/status/status.js
@@ -1,590 +1,589 @@
import { uniqBy } from 'lodash'
import { defineAsyncComponent } from 'vue'
import AvatarList from 'src/components/avatar_list/avatar_list.vue'
import EmojiReactions from 'src/components/emoji_reactions/emoji_reactions.vue'
import MentionLink from 'src/components/mention_link/mention_link.vue'
import MentionsLine from 'src/components/mentions_line/mentions_line.vue'
import PostStatusForm from 'src/components/post_status_form/post_status_form.vue'
import StatusActionButtons from 'src/components/status_action_buttons/status_action_buttons.vue'
import StatusContent from 'src/components/status_content/status_content.vue'
import StatusPopover from 'src/components/status_popover/status_popover.vue'
import Timeago from 'src/components/timeago/timeago.vue'
import UserAvatar from 'src/components/user_avatar/user_avatar.vue'
import UserLink from 'src/components/user_link/user_link.vue'
import UserListPopover from 'src/components/user_list_popover/user_list_popover.vue'
import UserPopover from 'src/components/user_popover/user_popover.vue'
import { muteFilterHits } from '../../services/status_parser/status_parser.js'
import {
highlightClass,
highlightStyle,
} from '../../services/user_highlighter/user_highlighter.js'
import { useInstanceStore } from 'src/stores/instance.js'
import { useInstanceCapabilitiesStore } from 'src/stores/instance_capabilities.js'
import { useMergedConfigStore } from 'src/stores/merged_config.js'
import { useSyncConfigStore } from 'src/stores/sync_config.js'
import { useUserHighlightStore } from 'src/stores/user_highlight.js'
import generateProfileLink from 'src/services/user_profile_link_generator/user_profile_link_generator'
import { library } from '@fortawesome/fontawesome-svg-core'
import {
faAngleDoubleRight,
faChevronDown,
faChevronUp,
faEllipsisH,
faEnvelope,
faEye,
faEyeSlash,
faGlobe,
faIgloo,
faLock,
faLockOpen,
faPlay,
faPlusSquare,
faReply,
faRetweet,
faSmileBeam,
faStar,
faThumbtack,
faTimes,
} from '@fortawesome/free-solid-svg-icons'
library.add(
faEnvelope,
faGlobe,
faIgloo,
faLock,
faLockOpen,
faTimes,
faRetweet,
faReply,
faPlusSquare,
faStar,
faSmileBeam,
faEllipsisH,
faEyeSlash,
faEye,
faThumbtack,
faChevronUp,
faChevronDown,
faAngleDoubleRight,
faPlay,
)
const Status = {
name: 'Status',
components: {
PostStatusForm,
UserAvatar,
AvatarList,
Timeago,
StatusPopover,
UserListPopover,
EmojiReactions,
StatusContent,
MentionLink,
MentionsLine,
UserPopover,
UserLink,
Quote: defineAsyncComponent(() => import('src/components/quote/quote.vue')),
StatusActionButtons,
},
props: {
statusoid: Object,
replies: Array,
expandable: Boolean,
focused: Boolean,
compact: Boolean,
isPreview: Boolean,
noHeading: Boolean,
inlineExpanded: Boolean,
showPinned: Boolean,
inProfile: Boolean,
inConversation: Boolean,
inQuote: Boolean,
profileUserId: String,
simpleTree: Boolean,
showOtherRepliesAsButton: Boolean,
canDive: Boolean,
ignoreMute: Boolean,
threadDisplayStatus: String,
},
emits: ['goto', 'dive', 'toggleExpanded', 'suspendableStateChange'],
data() {
return {
replying: false,
unmuted: false,
userExpanded: false,
mediaPlaying: new Set(),
error: null,
headTailLinks: null,
}
},
computed: {
showReasonMutedThread() {
return (
- (this.status.thread_muted ||
- (this.status.reblog && this.status.reblog.thread_muted)) &&
+ (this.status.thread_muted || this.status.reblog?.thread_muted) &&
!this.inConversation
)
},
allowNonSquareEmoji() {
return this.mergedConfig.nonSquareEmoji
},
pauseMfm() {
return this.mergedConfig.pauseMfm
},
scaleMfm() {
return this.mergedConfig.scaleMfm
},
repeaterClass() {
const user = this.statusoid.user
return highlightClass(user)
},
userClass() {
const user = this.retweet
? this.statusoid.retweeted_status.user
: this.statusoid.user
return highlightClass(user)
},
deleted() {
return this.statusoid.deleted
},
repeaterStyle() {
const user = this.statusoid.user
return highlightStyle(useUserHighlightStore().get(user.screen_name))
},
userStyle() {
if (this.noHeading) return
const user = this.retweet
? this.statusoid.retweeted_status.user
: this.statusoid.user
return highlightStyle(useUserHighlightStore().get(user.screen_name))
},
userProfileLink() {
return this.generateUserProfileLink(
this.status.user.id,
this.status.user.screen_name,
)
},
replyProfileLink() {
if (this.isReply) {
const user = this.$store.getters.findUser(
this.status.in_reply_to_user_id,
)
// FIXME Why user not found sometimes???
return user ? user.statusnet_profile_url : 'NOT_FOUND'
}
},
retweet() {
return !!this.statusoid.retweeted_status
},
retweeterUser() {
return this.statusoid.user
},
retweeter() {
return this.statusoid.user.name || this.statusoid.user.screen_name_ui
},
retweeterHtml() {
return this.statusoid.user.name
},
retweeterProfileLink() {
return this.generateUserProfileLink(
this.statusoid.user.id,
this.statusoid.user.screen_name,
)
},
status() {
if (this.retweet) {
return this.statusoid.retweeted_status
} else {
return this.statusoid
}
},
statusFromGlobalRepository() {
// NOTE: Consider to replace status with statusFromGlobalRepository
return this.$store.state.statuses.allStatusesObject[this.status.id]
},
loggedIn() {
return !!this.currentUser
},
muteFilterHits() {
return muteFilterHits(
Object.values(
useSyncConfigStore().prefsStorage.simple.muteFilters || {},
),
this.status,
)
},
botStatus() {
return this.status.user.actor_type === 'Service'
},
showActorTypeIndicator() {
return !this.hideBotIndication
},
sensitiveStatus() {
return this.status.nsfw
},
mentionsLine() {
if (!this.headTailLinks) return []
const writtenSet = new Set(
this.headTailLinks.writtenMentions.map((_) => _.url),
)
return this.status.attentions
.filter((attn) => {
// no reply user
return (
attn.id !== this.status.in_reply_to_user_id &&
// no self-replies
attn.statusnet_profile_url !==
this.status.user.statusnet_profile_url &&
// don't include if mentions is written
!writtenSet.has(attn.statusnet_profile_url)
)
})
.map((attn) => ({
url: attn.statusnet_profile_url,
content: attn.screen_name,
userId: attn.id,
}))
},
hasMentionsLine() {
return this.mentionsLine.length > 0
},
muteReasons() {
return [
this.userIsMuted ? 'user' : null,
this.status.thread_muted ? 'thread' : null,
this.muteFilterHits.length > 0 ? 'filtered' : null,
this.muteBotStatuses && this.botStatus ? 'bot' : null,
this.muteSensitiveStatuses && this.sensitiveStatus ? 'nsfw' : null,
- ].filter((_) => _)
+ ].filter(Boolean)
},
muteLocalized() {
if (this.muteReasons.length === 0) return null
const mainReason = () => {
switch (this.muteReasons[0]) {
case 'user':
return this.$t('status.muted_user')
case 'thread':
return this.$t('status.thread_muted')
case 'filtered':
return this.$t(
'status.muted_filters',
{
name: this.muteFilterHits[0].name,
filterMore: this.muteFilterHits.length - 1,
},
this.muteFilterHits.length,
)
case 'bot':
return this.$t('status.bot_muted')
case 'nsfw':
return this.$t('status.sensitive_muted')
}
}
if (this.muteReasons.length > 1) {
return this.$t(
'status.multi_reason_mute',
{
main: mainReason(),
numReasonsMore: this.muteReasons.length - 1,
},
this.muteReasons.length - 1,
)
} else {
return mainReason()
}
},
muted() {
if (this.ignoreMute) return false
if (this.statusoid.user.id === this.currentUser.id) return false
return !this.unmuted && !this.shouldNotMute && this.muteReasons.length > 0
},
userIsMuted() {
if (this.statusoid.user.id === this.currentUser.id) return false
const { status } = this
const { reblog } = status
const relationship = this.$store.getters.relationship(status.user.id)
const relationshipReblog =
reblog && this.$store.getters.relationship(reblog.user.id)
return (
(status.muted && !status.thread_muted) ||
// Reprööt of a muted post according to BE
(reblog?.muted && !reblog.thread_muted) ||
// Muted user
relationship.muting ||
// Muted user of a reprööt
relationshipReblog?.muting
)
},
shouldNotMute() {
if (this.ignoreMute) return true
if (this.focused) return true
const { status } = this
const { reblog } = status
return (
((this.inProfile &&
// Don't mute user's posts on user timeline (except reblogs)
((!reblog && status.user.id === this.profileUserId) ||
// Same as above but also allow self-reblogs
reblog?.user.id === this.profileUserId)) ||
// Don't mute statuses in muted conversation when said conversation is opened
(this.inConversation && status.thread_muted)) &&
// No excuses if post has muted words
!this.muteFilterHits.length > 0
)
},
hideMutedUsers() {
return this.mergedConfig.hideMutedPosts
},
hideMutedThreads() {
return this.mergedConfig.hideMutedThreads
},
hideFilteredStatuses() {
return this.mergedConfig.hideFilteredStatuses
},
hideWordFilteredPosts() {
return this.mergedConfig.hideWordFilteredPosts
},
hideStatus() {
return (
!this.shouldNotMute &&
((this.muted && this.hideFilteredStatuses) ||
(this.userIsMuted && this.hideMutedUsers) ||
(this.status.thread_muted && this.hideMutedThreads) ||
(this.muteFilterHits.length > 0 && this.hideWordFilteredPosts) ||
this.muteFilterHits.some((x) => x.hide))
)
},
isReply() {
return !!(
this.status.in_reply_to_status_id && this.status.in_reply_to_user_id
)
},
replyToName() {
if (this.status.in_reply_to_screen_name) {
return this.status.in_reply_to_screen_name
} else {
const user = this.$store.getters.findUser(
this.status.in_reply_to_user_id,
)
return user?.screen_name_ui
}
},
combinedFavsAndRepeatsUsers() {
// Use the status from the global status repository since favs and repeats are saved in it
const combinedUsers = [].concat(
this.statusFromGlobalRepository.favoritedBy,
this.statusFromGlobalRepository.rebloggedBy,
)
return uniqBy(combinedUsers, 'id')
},
tags() {
return this.status.tags
.filter((tagObj) => Object.hasOwn(tagObj, 'name'))
.map((tagObj) => tagObj.name)
.join(' ')
},
hidePostStats() {
return this.mergedConfig.hidePostStats
},
shouldDisplayFavsAndRepeats() {
return (
!this.hidePostStats &&
this.focused &&
(this.combinedFavsAndRepeatsUsers.length > 0 ||
this.statusFromGlobalRepository.quotes_count)
)
},
muteBotStatuses() {
return this.mergedConfig.muteBotStatuses
},
muteSensitiveStatuses() {
return this.mergedConfig.muteSensitiveStatuses
},
hideBotIndication() {
return this.mergedConfig.hideBotIndication
},
currentUser() {
return this.$store.state.users.currentUser
},
mergedConfig() {
return useMergedConfigStore().mergedConfig
},
isSuspendable() {
return !this.replying && this.mediaPlaying.size === 0
},
inThreadForest() {
return !!this.threadDisplayStatus
},
threadShowing() {
return this.threadDisplayStatus === 'showing'
},
visibilityLocalized() {
return this.$i18n.t('general.scope_in_timeline.' + this.status.visibility)
},
isEdited() {
return this.status.edited_at !== null
},
editingAvailable() {
return useInstanceCapabilitiesStore().editingAvailable
},
quoteId() {
return this.status.quote_id
},
quoteUrl() {
return this.status.quote_url
},
quoteVisible() {
return this.status.quote_visible
},
quoteExpanded() {
return !this.inQuote
},
scrobblePresent() {
if (this.mergedConfig.hideScrobbles) return false
if (!this.status.user?.latestScrobble) return false
const value = this.mergedConfig.hideScrobblesAfter.match(/\d+/gs)[0]
const unit = this.mergedConfig.hideScrobblesAfter.match(/\D+/gs)[0]
let multiplier = 60 * 1000 // minutes is smallest unit
switch (unit) {
case 'm':
break
case 'h':
multiplier *= 60 // hour
break
case 'd':
multiplier *= 60 // hour
multiplier *= 24 // day
break
}
const maxAge = Number(value) * multiplier
const createdAt = Date.parse(this.status.user.latestScrobble.created_at)
const age = Date.now() - createdAt
if (age > maxAge) return false
return this.status.user.latestScrobble.artist
},
scrobble() {
return this.status.user?.latestScrobble
},
},
methods: {
visibilityIcon(visibility) {
switch (visibility) {
case 'private':
return 'lock'
case 'unlisted':
return 'lock-open'
case 'direct':
return 'envelope'
case 'local':
return 'igloo'
default:
return 'globe'
}
},
showError(error) {
this.error = error
},
clearError() {
this.error = undefined
},
toggleReplyForm() {
if (this.replying) {
// This emits 'close-accepted' if successful
// which in turn callse closeReply()
this.$refs.postStatusForm.requestClose()
} else {
this.replying = true
}
},
closeReplyForm() {
this.replying = false
},
gotoOriginal(id) {
if (this.inConversation) {
this.$emit('goto', id)
}
},
toggleExpanded() {
this.$emit('toggleExpanded')
},
toggleMute() {
this.unmuted = !this.unmuted
},
toggleUserExpanded() {
this.userExpanded = !this.userExpanded
},
generateUserProfileLink(id, name) {
return generateProfileLink(
id,
name,
useInstanceStore().restrictedNicknames,
)
},
addMediaPlaying(id) {
this.mediaPlaying.add(id)
},
removeMediaPlaying(id) {
this.mediaPlaying.delete(id)
},
setHeadTailLinks(headTailLinks) {
this.headTailLinks = headTailLinks
},
toggleThreadDisplay() {
this.controlledToggleThreadDisplay()
},
scrollIfFocused(focused) {
if (this.$el.getBoundingClientRect == null) return
if (focused) {
const rect = this.$el.getBoundingClientRect()
if (rect.top < 100) {
// Post is above screen, match its top to screen top
window.scrollBy(0, rect.top - 100)
} else if (rect.height >= window.innerHeight - 50) {
// Post we want to see is taller than screen so match its top to screen top
window.scrollBy(0, rect.top - 100)
} else if (rect.bottom > window.innerHeight - 50) {
// Post is below screen, match its bottom to screen bottom
window.scrollBy(0, rect.bottom - window.innerHeight + 50)
}
}
},
},
watch: {
focused: function (id) {
this.scrollIfFocused(id)
},
'status.repeat_num': function (num) {
// refetch repeats when repeat_num is changed in any way
if (
this.focused &&
this.statusFromGlobalRepository.rebloggedBy &&
this.statusFromGlobalRepository.rebloggedBy.length !== num
) {
this.$store.dispatch('fetchRepeats', this.status.id)
}
},
'status.fave_num': function (num) {
// refetch favs when fave_num is changed in any way
if (
this.focused &&
this.statusFromGlobalRepository.favoritedBy &&
this.statusFromGlobalRepository.favoritedBy.length !== num
) {
this.$store.dispatch('fetchFavs', this.status.id)
}
},
isSuspendable: function (suspend) {
this.$emit('suspendableStateChange', { id: this.statusoid.id, suspend })
},
},
}
export default Status
diff --git a/src/components/status_action_buttons/status_action_buttons.vue b/src/components/status_action_buttons/status_action_buttons.vue
index 34e5e25ea8..c829a1f50e 100644
--- a/src/components/status_action_buttons/status_action_buttons.vue
+++ b/src/components/status_action_buttons/status_action_buttons.vue
@@ -1,140 +1,140 @@
<template>
<div class="StatusActionButtons">
<span
class="quick-action-buttons"
:class="{ '-pin': showPin }"
>
<span
v-for="button in quickButtons"
:key="button.name"
class="quick-action"
:class="{ '-pin': showPin, '-toggle': button.dropdown?.(), '-with-extra': button.name === 'bookmark' }"
>
<ActionButtonContainer
:class="{ '-pin': showPin }"
:button="button"
:status="status"
:extra="false"
:func-arg="funcArg"
:get-class="getClass"
:get-component="getComponent"
:close="() => { /* no-op */ }"
:do-action="doAction"
- @emoji-picker-shown="onEmojiPickerShown"
:default-button-style="useDefaultButtons"
:hide-label="hideLabels"
+ @emoji-picker-shown="onEmojiPickerShown"
/>
<button
v-if="showPin && currentUser"
type="button"
class="button-unstyled pin-action-button"
:title="$t('general.unpin')"
:aria-pressed="true"
@click.stop.prevent="unpin(button)"
>
<FAIcon
v-if="showPin && currentUser"
fixed-width
icon="thumbtack"
/>
</button>
</span>
<Popover
trigger="click"
:trigger-attrs="triggerAttrs"
:normal-button="useDefaultButtons"
class="quick-action"
:tabindex="0"
placement="bottom"
:offset="{ y: 5 }"
remove-padding
@close="onExtraClose"
>
<template #trigger>
<FAIcon
class="action-button-inner"
icon="ellipsis-h"
/>
</template>
<template #content="{close, resize}">
<div
:id="`popup-menu-${randomSeed}`"
class="dropdown-menu extra-action-buttons"
role="menu"
>
<div
v-for="button in extraButtons"
:key="button.name"
class="menu-item dropdown-item extra-action -icon"
:disabled="getClass(button).disabled"
:class="{ disabled: getClass(button).disabled }"
>
<ActionButtonContainer
:button="button"
:status="status"
:extra="true"
:func-arg="funcArg"
:get-class="getClass"
:get-component="getComponent"
:outer-close="close"
:do-action="doAction"
:default-button-style="useDefaultButtons"
:hide-label="hideLabels"
/>
<button
v-if="showPin && currentUser"
type="button"
class="button-unstyled pin-action-button extra-button"
:title="$t('general.pin')"
:aria-pressed="false"
@click.stop.prevent="pin(button)"
>
<FAIcon
v-if="showPin && currentUser"
fixed-width
class="fa-scale-110"
transform="rotate-45"
icon="thumbtack"
/>
</button>
</div>
<div
v-if="currentUser"
class="menu-item dropdown-item extra-action -icon"
>
<button
class="main-button"
role="menuitem"
:tabindex="0"
@click.stop="() => { resize(); showPin = !showPin }"
>
<FAIcon
class="fa-scale-110"
fixed-width
icon="wrench"
/><span>{{ $t('nav.edit_pinned') }}</span>
</button>
</div>
</div>
</template>
</Popover>
</span>
<teleport to="#modal">
<ConfirmModal
v-if="showingConfirmDialog"
:title="currentConfirmTitle"
:confirm-text="currentConfirmOkText"
:cancel-text="currentConfirmCancelText"
@accepted="currentConfirmAction"
@cancelled="showingConfirmDialog = false"
>
{{ currentConfirmBody }}
</ConfirmModal>
</teleport>
</div>
</template>
<script src="./status_action_buttons.js"></script>
<style lang="scss" src="./status_action_buttons.scss"></style>
diff --git a/src/components/status_body/status_body.js b/src/components/status_body/status_body.js
index c76e04b9c0..dc74426ca2 100644
--- a/src/components/status_body/status_body.js
+++ b/src/components/status_body/status_body.js
@@ -1,200 +1,199 @@
import { mapState } from 'pinia'
import RichContent from 'src/components/rich_content/rich_content.jsx'
import { useMergedConfigStore } from 'src/stores/merged_config.js'
import { library } from '@fortawesome/fontawesome-svg-core'
import {
faFile,
faImage,
faLink,
faMusic,
faPollH,
} from '@fortawesome/free-solid-svg-icons'
library.add(faFile, faMusic, faImage, faLink, faPollH)
const StatusBody = {
name: 'StatusBody',
components: {
RichContent,
},
props: {
status: {
// Main thing
type: Object,
required: true,
},
compact: {
// Resizes emoji and minimizes vertical space used
// Primarily used for showing status in react notifications
type: Boolean,
default: false,
},
collapse: {
// replaces newlines with spaces
type: Boolean,
default: false,
},
singleLine: {
// Show entire thing (subject and content) in a single line
// Primarily used in chats
type: Boolean,
default: false,
},
inConversation: {
// Is status rendered within open conversation?
// Used to automatically expand subjects (if collapsed)
type: Boolean,
default: false,
},
ignoreSubject: {
// Pretend subject line doesn't exist. Useful for chat messages
// to indicate what post reply belongs to
type: Boolean,
default: false,
},
},
data() {
return {
postLength: this.status.text.length,
parseReadyDone: false,
showingTall: false,
showingLongSubject: false,
expandingSubject: null,
}
},
emits: ['parseReady'],
computed: {
allowNonSquareEmoji() {
return this.mergedConfig.nonSquareEmoji
},
pauseMfm() {
return this.mergedConfig.pauseMfm
},
scaleMfm() {
return this.mergedConfig.scaleMfm
},
// This is a bit hacky, but we want to approximate post height before rendering
// so we count newlines (masto uses <p> for paragraphs, GS uses <br> between them)
// as well as approximate line count by counting characters and approximating ~80
// per line.
//
// Using max-height + overflow: auto for status components resulted in false positives
// very often with japanese characters, and it was very annoying.
hasLongSubject() {
return this.status.summary.length > 240
},
hasSubject() {
return !!this.status.summary && !this.ignoreSubject
},
// When a status has a subject and is also tall, we should only have one show more/less
// button. If the default is to collapse statuses with subjects, we just treat it like
// a status with a subject; otherwise, we just treat it like a tall status.
mightHideBecauseSubject() {
return (
!this.inConversation &&
this.hasSubject &&
this.mergedConfig.collapseMessageWithSubject
)
},
mightHideBecauseTall() {
if (this.singleLine || this.compact) return false
const lengthScore =
this.status.raw_html.split(/<p|<br/).length + this.postLength / 80
return lengthScore > 20
},
hideSubjectStatus() {
return this.mightHideBecauseSubject && !this.expandingSubject
},
hideTallStatus() {
return this.mightHideBecauseTall && !this.showingTall
},
shouldShowExpandToggle() {
return this.mightHideBecauseSubject || this.mightHideBecauseTall
},
toggleButtonClasses() {
return {
'cw-status-hider': !this.showingMore && this.mightHideBecauseSubject,
'tall-status-hider': !this.showingMore && this.mightHideBecauseTall,
'status-unhider': this.showingMore,
}
},
toggleText() {
if (this.showingMore) {
return this.mightHideBecauseSubject
? this.$t('status.hide_content')
: this.$t('general.show_less')
} else {
return this.mightHideBecauseSubject
? this.$t('status.show_content')
: this.$t('general.show_more')
}
},
shouldHide() {
return (
!this.showingMore && this.mightHideBecauseSubject && this.hasSubject
)
},
showingMore() {
return (
(this.mightHideBecauseTall && this.showingTall) ||
(this.mightHideBecauseSubject && this.expandingSubject)
)
},
attachmentTypes() {
return this.status.attachments.map((file) => file.type)
},
collapsedStatus() {
- return this.status.raw_html.replace(/(\n|<br\s?\/?>)/g, ' ')
+ return this.status.raw_html.replaceAll('(\n|<br\s?\/?>)', ' ')
},
...mapState(useMergedConfigStore, ['mergedConfig']),
},
mounted() {
- this.status.attentions &&
- this.status.attentions.forEach((attn) => {
- const { id } = attn
- this.$store.dispatch('fetchUserIfMissing', id)
- })
+ this.status.attentions?.forEach((attn) => {
+ const { id } = attn
+ this.$store.dispatch('fetchUserIfMissing', id)
+ })
},
methods: {
onParseReady(event) {
if (this.parseReadyDone) return
this.parseReadyDone = true
this.$emit('parseReady', event)
const { writtenMentions, invisibleMentions } = event
writtenMentions
.filter((mention) => !mention.notifying)
.forEach((mention) => {
const { content, url } = mention
- const cleanedString = content.replace(/<[^>]+?>/gi, '') // remove all tags
+ const cleanedString = content.replaceAll(/<[^>]+?>/gi, '') // remove all tags
if (!cleanedString.startsWith('@')) return
const handle = cleanedString.slice(1)
const host = url.replace(/^https?:\/\//, '').replace(/\/.+?$/, '')
this.$store.dispatch('fetchUserIfMissing', `${handle}@${host}`)
})
/* This is a bit of a hack to make current tall status detector work
* with rich mentions. Invisible mentions are detected at RichContent level
* and also we generate plaintext version of mentions by stripping tags
* so here we subtract from post length by each mention that became invisible
* via MentionsLine
*/
this.postLength = invisibleMentions.reduce((acc, mention) => {
return acc - mention.textContent.length - 1
}, this.postLength)
},
toggleShowMore() {
if (this.mightHideBecauseTall) {
this.showingTall = !this.showingTall
} else if (this.mightHideBecauseSubject) {
this.expandingSubject = !this.expandingSubject
}
},
generateTagLink(tag) {
return `/tag/${tag}`
},
},
}
export default StatusBody
diff --git a/src/components/still-image/still-image.js b/src/components/still-image/still-image.js
index 29a4ed1ded..809bd8c7d1 100644
--- a/src/components/still-image/still-image.js
+++ b/src/components/still-image/still-image.js
@@ -1,77 +1,77 @@
import { useMergedConfigStore } from 'src/stores/merged_config.js'
const StillImage = {
props: [
'src',
'referrerpolicy',
'mimetype',
'imageLoadError',
'imageLoadHandler',
'alt',
'height',
'width',
'dataSrc',
'loading',
],
data() {
return {
// for lazy loading, see loadLazy()
realSrc: this.src,
stopGifs: useMergedConfigStore().mergedConfig.stopGifs,
}
},
computed: {
animated() {
if (!this.realSrc) {
return false
}
return (
this.stopGifs &&
(this.mimetype === 'image/gif' || this.realSrc.endsWith('.gif'))
)
},
style() {
const appendPx = (str) => (/\d$/.test(str) ? str + 'px' : str)
return {
height: this.height ? appendPx(this.height) : null,
width: this.width ? appendPx(this.width) : null,
}
},
},
methods: {
loadLazy() {
if (this.dataSrc) {
this.realSrc = this.dataSrc
}
},
onLoad() {
if (!this.realSrc) {
return
}
const image = this.$refs.src
if (!image) return
- this.imageLoadHandler && this.imageLoadHandler(image)
+ this.imageLoadHandler?.(image)
const canvas = this.$refs.canvas
if (!canvas) return
const width = image.naturalWidth
const height = image.naturalHeight
canvas.width = width
canvas.height = height
canvas.getContext('2d').drawImage(image, 0, 0, width, height)
},
onError() {
- this.imageLoadError && this.imageLoadError()
+ this.imageLoadError?.()
},
},
watch: {
src() {
this.realSrc = this.src
},
dataSrc() {
this.$el.removeAttribute('data-loaded')
},
},
}
export default StillImage
diff --git a/src/components/tab_switcher/tab_switcher.jsx b/src/components/tab_switcher/tab_switcher.jsx
index 2c86983d8a..01913f7099 100644
--- a/src/components/tab_switcher/tab_switcher.jsx
+++ b/src/components/tab_switcher/tab_switcher.jsx
@@ -1,183 +1,187 @@
// eslint-disable-next-line no-unused
import { Fragment } from 'vue'
import { FontAwesomeIcon as FAIcon } from '@fortawesome/vue-fontawesome'
import './tab_switcher.scss'
const findFirstUsable = (slots) => slots.findIndex((_) => _.props)
export default {
name: 'TabSwitcher',
props: {
renderOnlyFocused: {
required: false,
type: Boolean,
default: false,
},
onSwitch: {
required: false,
type: Function,
default: undefined,
},
activeTab: {
required: false,
type: String,
default: undefined,
},
scrollableTabs: {
required: false,
type: Boolean,
default: false,
},
bodyScrollLock: {
required: false,
type: Boolean,
default: false,
},
},
data() {
return {
active: findFirstUsable(this.slots()),
}
},
computed: {
activeIndex() {
// In case of controlled component
if (this.activeTab) {
return this.slots().findIndex(
(slot) => slot?.props && this.activeTab === slot.props.key,
)
} else {
return this.active
}
},
isActive() {
return (tabName) => {
const isWanted = (slot) =>
slot.props && slot.props['data-tab-name'] === tabName
return this.$slots.default().findIndex(isWanted) === this.activeIndex
}
},
},
beforeUpdate() {
const currentSlot = this.slots()[this.active]
if (!currentSlot.props) {
this.active = findFirstUsable(this.slots())
}
},
methods: {
clickTab(index) {
return (e) => {
e.preventDefault()
this.setTab(index)
}
},
// DO NOT put it to computed, it doesn't work (caching?)
slots() {
if (this.$slots.default()[0].type === Fragment) {
return this.$slots.default()[0].children
}
return this.$slots.default()
},
setTab(index) {
if (typeof this.onSwitch === 'function') {
this.onSwitch.call(null, this.slots()[index].key)
}
this.active = index
if (this.scrollableTabs && this.$refs.contents) {
this.$refs.contents.scrollTop = 0
}
},
},
render() {
const tabs = this.slots().map((slot, index) => {
const props = slot.props
if (!props) return
const classesTab = ['tab']
const classesWrapper = ['tab-wrapper']
if (this.activeIndex === index) {
classesTab.push('active')
classesWrapper.push('active')
}
if (props.image) {
return (
<div class={classesWrapper.join(' ')}>
<button
disabled={props.disabled}
onClick={this.clickTab(index)}
class={classesTab.join(' ')}
type="button"
role="tab"
>
- <img src={props.image} title={props['image-tooltip']} />
+ <img
+ src={props.image}
+ alt={props['image-tooltip']}
+ title={props['image-tooltip']}
+ />
{props.label ? '' : props.label}
</button>
</div>
)
}
return (
<div class={classesWrapper.join(' ')}>
<button
disabled={props.disabled}
onClick={this.clickTab(index)}
class={classesTab.join(' ')}
type="button"
role="tab"
>
{!props.icon ? (
''
) : (
<FAIcon
class="tab-icon"
size="2x"
fixed-width
icon={props.icon}
/>
)}
<span class="text">{props.label}</span>
</button>
</div>
)
})
const contents = this.slots().map((slot, index) => {
const props = slot.props
if (!props) return
const active = this.activeIndex === index
const classes = [active ? 'active' : 'hidden']
if (props.fullHeight || props['full-height']) {
classes.push('-full-height')
}
if (props.fullWidth || props['full-width']) {
classes.push('-full-width')
}
let delayRender = slot.props['delay-render']
if (delayRender && active) {
slot.props['delay-render'] = false
delayRender = false
}
const renderSlot =
!delayRender && (!this.renderOnlyFocused || active) ? slot : ''
return <div class={classes}>{renderSlot}</div>
})
return (
<div class="tab-switcher top-tabs" ref="root">
<div class="tabs" role="tablist" ref="nav">
{tabs}
</div>
<div
role="tabpanel"
class={'contents' + (this.scrollableTabs ? ' scrollable-tabs' : '')}
v-body-scroll-lock={this.bodyScrollLock}
ref="content"
>
{contents}
</div>
</div>
)
},
}
diff --git a/src/components/timeline/timeline.js b/src/components/timeline/timeline.js
index 17065a9ef3..c3f4d381d9 100644
--- a/src/components/timeline/timeline.js
+++ b/src/components/timeline/timeline.js
@@ -1,342 +1,345 @@
import { debounce, keyBy, throttle } from 'lodash'
import { mapState } from 'pinia'
import Conversation from 'src/components/conversation/conversation.vue'
import QuickFilterSettings from 'src/components/quick_filter_settings/quick_filter_settings.vue'
import QuickViewSettings from 'src/components/quick_view_settings/quick_view_settings.vue'
import ScrollTopButton from 'src/components/scroll_top_button/scroll_top_button.vue'
import TimelineMenu from 'src/components/timeline_menu/timeline_menu.vue'
import { useInterfaceStore } from 'src/stores/interface.js'
import { useMergedConfigStore } from 'src/stores/merged_config.js'
import timelineFetcher from 'src/services/timeline_fetcher/timeline_fetcher.service.js'
import { library } from '@fortawesome/fontawesome-svg-core'
import {
faArrowUp,
faCheck,
faCircleNotch,
faCirclePlus,
faCog,
faMinus,
} from '@fortawesome/free-solid-svg-icons'
library.add(faCircleNotch, faCog, faMinus, faArrowUp, faCirclePlus, faCheck)
const Timeline = {
props: [
'timeline',
'timelineName',
'title',
'userId',
'listId',
'statusId',
'bookmarkFolderId',
'tag',
'embedded',
'count',
'pinnedStatusIds',
'inProfile',
'footerSlipgate', // reference to an element where we should put our footer
],
data() {
return {
showScrollTop: false,
paused: false,
unfocused: false,
bottomedOut: false,
virtualScrollIndex: 0,
blockingClicks: false,
}
},
components: {
ScrollTopButton,
Conversation,
TimelineMenu,
QuickFilterSettings,
QuickViewSettings,
},
computed: {
filteredVisibleStatuses() {
return this.timeline.visibleStatuses.filter(
(status) =>
this.timelineName !== 'user' ||
(status.id >= this.timeline.minId &&
status.id <= this.timeline.maxId),
)
},
filteredPinnedStatusIds() {
return (this.pinnedStatusIds || []).filter(
(statusId) => this.timeline.statusesObject[statusId],
)
},
newStatusCount() {
return this.timeline.newStatusCount
},
showLoadButton() {
return this.timeline.newStatusCount > 0 || this.timeline.flushMarker !== 0
},
loadButtonString() {
if (this.timeline.flushMarker !== 0) {
return this.$t('timeline.reload')
} else {
return `${this.$t('timeline.show_new')} (${this.newStatusCount})`
}
},
mobileLoadButtonString() {
if (this.timeline.flushMarker !== 0) {
return '+'
} else {
return this.newStatusCount > 99 ? '∞' : this.newStatusCount
}
},
classes() {
let rootClasses = !this.embedded
? ['panel', 'panel-default']
: ['-embedded']
if (this.blockingClicks)
rootClasses = rootClasses.concat(['-blocked', '_misclick-prevention'])
return {
root: rootClasses,
header: ['timeline-heading'].concat(
!this.embedded ? ['panel-heading', '-sticky'] : ['panel-body'],
),
body: ['timeline-body'].concat(
!this.embedded ? ['panel-body'] : ['panel-body'],
),
footer: ['timeline-footer'].concat(
!this.embedded ? ['panel-footer'] : ['panel-body'],
),
}
},
// id map of statuses which need to be hidden in the main list due to pinning logic
pinnedStatusIdsObject() {
return keyBy(this.pinnedStatusIds)
},
statusesToDisplay() {
const amount = this.timeline.visibleStatuses.length
const statusesPerSide = Math.ceil(Math.max(3, window.innerHeight / 80))
const nonPinnedIndex =
this.virtualScrollIndex - this.filteredPinnedStatusIds.length
const min = Math.max(0, nonPinnedIndex - statusesPerSide)
const max = Math.min(amount, nonPinnedIndex + statusesPerSide)
return this.timeline.visibleStatuses.slice(min, max).map((_) => _.id)
},
virtualScrollingEnabled() {
return useMergedConfigStore().mergedConfig.virtualScrolling
},
...mapState(useInterfaceStore, {
mobileLayout: (store) => store.layoutType === 'mobile',
}),
},
created() {
const store = this.$store
const credentials = store.state.users.currentUser.credentials
const showImmediately = this.timeline.visibleStatuses.length === 0
window.addEventListener('scroll', this.handleScroll)
if (store.state.api.fetchers[this.timelineName]) {
return false
}
timelineFetcher.fetchAndUpdate({
store,
credentials,
timeline: this.timelineName,
showImmediately,
userId: this.userId,
listId: this.listId,
statusId: this.statusId,
bookmarkFolderId: this.bookmarkFolderId,
tag: this.tag,
})
},
mounted() {
if (document.hidden !== undefined) {
document.addEventListener(
'visibilitychange',
this.handleVisibilityChange,
false,
)
this.unfocused = document.hidden
}
window.addEventListener('keydown', this.handleShortKey)
setTimeout(this.determineVisibleStatuses, 250)
},
unmounted() {
window.removeEventListener('scroll', this.handleScroll)
window.removeEventListener('keydown', this.handleShortKey)
if (document.hidden !== undefined)
document.removeEventListener(
'visibilitychange',
this.handleVisibilityChange,
false,
)
this.$store.commit('setLoading', {
timeline: this.timelineName,
value: false,
})
},
methods: {
stopBlockingClicks: debounce(function () {
this.blockingClicks = false
}, 1000),
blockClicksTemporarily() {
if (!this.blockingClicks) {
this.blockingClicks = true
}
this.stopBlockingClicks()
},
handleShortKey(e) {
// Ignore when input fields are focused
if (['textarea', 'input'].includes(e.target.tagName.toLowerCase())) return
if (e.key === '.') this.showNewStatuses()
},
showNewStatuses() {
if (this.timeline.flushMarker !== 0) {
this.$store.commit('clearTimeline', {
timeline: this.timelineName,
excludeUserId: true,
})
this.$store.commit('queueFlush', { timeline: this.timelineName, id: 0 })
if (this.timelineName === 'user') {
this.$store.dispatch('fetchPinnedStatuses', this.userId)
}
this.fetchOlderStatuses()
} else {
this.blockClicksTemporarily()
this.$store.commit('showNewStatuses', { timeline: this.timelineName })
this.paused = false
}
window.scrollTo({ top: 0 })
},
fetchOlderStatuses: throttle(
function () {
const store = this.$store
const credentials = store.state.users.currentUser.credentials
store.commit('setLoading', { timeline: this.timelineName, value: true })
timelineFetcher
.fetchAndUpdate({
store,
credentials,
timeline: this.timelineName,
older: true,
showImmediately: true,
userId: this.userId,
listId: this.listId,
statusId: this.statusId,
bookmarkFolderId: this.bookmarkFolderId,
tag: this.tag,
})
.then(({ statuses }) => {
if (statuses?.length === 0) {
this.bottomedOut = true
}
})
.finally(() =>
store.commit('setLoading', {
timeline: this.timelineName,
value: false,
}),
)
},
1000,
this,
),
determineVisibleStatuses() {
if (!this.$refs.timeline) return
if (!this.virtualScrollingEnabled) return
const statuses = this.$refs.timeline.children
const cappedScrollIndex = Math.max(
0,
Math.min(this.virtualScrollIndex, statuses.length - 1),
)
if (statuses.length === 0) return
const height = Math.max(document.body.offsetHeight, window.pageYOffset)
const centerOfScreen = window.pageYOffset + window.innerHeight * 0.5
// Start from approximating the index of some visible status by using the
// the center of the screen on the timeline.
- let approxIndex = Math.floor(statuses.length * (centerOfScreen / height))
+ let approxIndex = Math.min(
+ Math.floor(statuses.length * (centerOfScreen / height)),
+ statuses.length - 1,
+ )
let err = statuses[approxIndex].getBoundingClientRect().y
// if we have a previous scroll index that can be used, test if it's
// closer than the previous approximation, use it if so
const virtualScrollIndexY =
statuses[cappedScrollIndex].getBoundingClientRect().y
if (Math.abs(err) > virtualScrollIndexY) {
approxIndex = cappedScrollIndex
err = virtualScrollIndexY
}
// if the status is too far from viewport, check the next/previous ones if
// they happen to be better
while (err < -20 && approxIndex < statuses.length - 1) {
err += statuses[approxIndex].offsetHeight
approxIndex++
}
while (err > window.innerHeight + 100 && approxIndex > 0) {
approxIndex--
err -= statuses[approxIndex].offsetHeight
}
// this status is now the center point for virtual scrolling and visible
// statuses will be nearby statuses before and after it
this.virtualScrollIndex = approxIndex
},
scrollLoad() {
const bodyBRect = document.body.getBoundingClientRect()
const height = Math.max(bodyBRect.height, -bodyBRect.y)
if (
this.timeline.loading === false &&
this.$el.offsetHeight > 0 &&
window.innerHeight + window.pageYOffset >= height - 750
) {
this.fetchOlderStatuses()
}
},
handleScroll: throttle(function (e) {
this.determineVisibleStatuses()
this.scrollLoad(e)
}, 200),
handleVisibilityChange() {
this.unfocused = document.hidden
},
},
watch: {
filteredVisibleStatuses() {
this.determineVisibleStatuses()
},
newStatusCount(count) {
if (!useMergedConfigStore().mergedConfig.streaming) {
return
}
if (count > 0) {
// only 'stream' them when you're scrolled to the top
const doc = document.documentElement
const top = (window.pageYOffset || doc.scrollTop) - (doc.clientTop || 0)
if (
top < 15 &&
!this.paused &&
!(
this.unfocused &&
useMergedConfigStore().mergedConfig.pauseOnUnfocused
)
) {
this.showNewStatuses()
} else {
this.paused = true
}
}
},
},
}
export default Timeline
diff --git a/src/components/user_card/user_card.js b/src/components/user_card/user_card.js
index 9f6488b34d..b6e720c6fb 100644
--- a/src/components/user_card/user_card.js
+++ b/src/components/user_card/user_card.js
@@ -1,625 +1,625 @@
import {
isEqual,
escape as ldEscape,
unescape as ldUnescape,
merge,
} from 'lodash'
import { mapState } from 'pinia'
import { defineAsyncComponent } from 'vue'
import Checkbox from 'src/components/checkbox/checkbox.vue'
import ColorInput from 'src/components/color_input/color_input.vue'
import EmojiInput from 'src/components/emoji_input/emoji_input.vue'
import suggestor from 'src/components/emoji_input/suggestor.js'
import FollowButton from 'src/components/follow_button/follow_button.vue'
import ProgressButton from 'src/components/progress_button/progress_button.vue'
import Select from 'src/components/select/select.vue'
import UserAvatar from 'src/components/user_avatar/user_avatar.vue'
import UserLink from 'src/components/user_link/user_link.vue'
import { useEmojiStore } from 'src/stores/emoji.js'
import { useInstanceStore } from 'src/stores/instance.js'
import { useInstanceCapabilitiesStore } from 'src/stores/instance_capabilities.js'
import { useInterfaceStore } from 'src/stores/interface'
import { useMediaViewerStore } from 'src/stores/media_viewer'
import { useMergedConfigStore } from 'src/stores/merged_config.js'
import { usePostStatusStore } from 'src/stores/post_status'
import { useUserHighlightStore } from 'src/stores/user_highlight.js'
import { updateProfile } from 'src/api/user.js'
import { propsToNative } from 'src/services/attributes_helper/attributes_helper.service.js'
import localeService from 'src/services/locale/locale.service.js'
import generateProfileLink from 'src/services/user_profile_link_generator/user_profile_link_generator'
import { library } from '@fortawesome/fontawesome-svg-core'
import {
faBell,
faBirthdayCake,
faClockRotateLeft,
faEdit,
faExpandAlt,
faExternalLinkAlt,
faRss,
faSave,
faSearchPlus,
faTimes,
} from '@fortawesome/free-solid-svg-icons'
library.add(
faSave,
faRss,
faBell,
faSearchPlus,
faExternalLinkAlt,
faEdit,
faTimes,
faExpandAlt,
faBirthdayCake,
faClockRotateLeft,
)
const KNOWN_TAGS = new Set([
'mrf_tag:media-force-nsfw',
'mrf_tag:media-strip',
'mrf_tag:force-unlisted',
'mrf_tag:sandbox',
'mrf_tag:disable-remote-subscription',
'mrf_tag:disable-any-subscription',
])
export default {
props: {
// Enables all the options for profile editing, used in settings -> profile tab
editable: {
required: false,
default: false,
type: Boolean,
},
// ID of user to show data of
userId: {
required: true,
type: String,
},
// Use a compact layout that hides bio, stats etc.
hideBio: {
required: false,
default: false,
type: Boolean,
},
// Hide action buttons
hideButtons: {
required: false,
default: false,
type: Boolean,
},
// default - open profile, 'zoom' - zoom, function - call function
avatarAction: {
required: false,
type: String,
default: 'default',
},
// Show note editor if supported
hasNoteEditor: {
required: false,
type: Boolean,
default: false,
},
// Show close icon (for popovers)
showClose: {
required: false,
type: Boolean,
default: false,
},
// Show close icon (for popovers)
showExpand: {
required: false,
type: Boolean,
default: false,
},
// Disable forced 3:1 aspect ratio
compact: {
required: false,
type: Boolean,
default: false,
},
},
components: {
DialogModal: defineAsyncComponent(
() => import('src/components/dialog_modal/dialog_modal.vue'),
),
UserAvatar,
Checkbox,
RemoteFollow: defineAsyncComponent(
() => import('src/components/remote_follow/remote_follow.vue'),
),
ModerationTools: defineAsyncComponent(
() => import('src/components/moderation_tools/moderation_tools.vue'),
),
AccountActions: defineAsyncComponent(
() => import('src/components/account_actions/account_actions.vue'),
),
ProgressButton,
FollowButton,
Select,
UserLink,
UserNote: defineAsyncComponent(
() => import('src/components/user_note/user_note.vue'),
),
UserTimedFilterModal: defineAsyncComponent(
() =>
import(
'src/components/user_timed_filter_modal/user_timed_filter_modal.vue'
),
),
ColorInput,
EmojiInput,
ImageCropper: defineAsyncComponent(
() => import('src/components/image_cropper/image_cropper.vue'),
),
},
data() {
const user = this.$store.getters.findUser(this.userId)
return {
followRequestInProgress: false,
// Editable stuff
editImage: false,
newName: user.name_unescaped,
editingName: false,
newBio: ldUnescape(user.description),
editingBio: false,
newAvatar: null,
newAvatarFile: null,
newBanner: null,
newBannerFile: null,
newActorType: user.actor_type,
newBirthday: user.birthday,
newShowBirthday: user.show_birthday,
newShowRole: user.show_role,
newFields: user.fields?.map((field) => ({
name: field.name,
value: field.value,
})),
editingFields: false,
}
},
created() {
this.$store.dispatch('fetchUserRelationship', this.user.id)
},
computed: {
escapedNewBio() {
- return ldEscape(this.newBio).replace(/\n/g, '<br>')
+ return ldEscape(this.newBio).replaceAll('\n', '<br>')
},
somethingToSave() {
if (this.newName !== this.user.name_unescaped) return true
if (this.newBio !== ldUnescape(this.user.description)) return true
if (this.newAvatar !== null) return true
if (this.newBanner !== null) return true
if (this.newActorType !== this.user.actor_type) return true
if (this.newBirthday !== this.user.birthday) return true
if (this.newShowBirthday !== this.user.show_birthday) return true
if (this.newShowRole !== this.user.show_role) return true
if (
!isEqual(
this.newFields,
this.user.fields?.map((field) => ({
name: field.name,
value: field.value,
})),
)
)
return true
return false
},
groupActorAvailable() {
return useInstanceCapabilitiesStore().groupActorAvailable
},
availableActorTypes() {
return this.groupActorAvailable
? ['Person', 'Service', 'Group']
: ['Person', 'Service']
},
user() {
return this.$store.getters.findUser(this.userId)
},
role() {
return this.user.role
},
relationship() {
return this.$store.getters.relationship(this.userId)
},
isOtherUser() {
return this.user.id !== this.$store.state.users.currentUser.id
},
subscribeUrl() {
const serverUrl = new URL(this.user.statusnet_profile_url)
return `${serverUrl.protocol}//${serverUrl.host}/main/ostatus`
},
loggedIn() {
return this.$store.state.users.currentUser
},
dailyAvg() {
const days = Math.ceil(
(new Date() - new Date(this.user.created_at)) / (60 * 60 * 24 * 1000),
)
return Math.round(this.user.statuses_count / days)
},
emoji() {
return useEmojiStore().customEmoji.map((e) => ({
shortcode: e.displayText,
static_url: e.imageUrl,
url: e.imageUrl,
}))
},
userHighlightType: {
get() {
return useUserHighlightStore().get(this.user.screen_name).type
},
set(type) {
if (type !== 'disabled') {
useUserHighlightStore().setAndSave({
user: this.user.screen_name,
value: { type },
})
} else {
useUserHighlightStore().unsetAndSave({ user: this.user.screen_name })
}
},
},
userHighlightColor: {
get() {
return useUserHighlightStore().get(this.user.screen_name).color
},
set(color) {
useUserHighlightStore().setAndSave({
user: this.user.screen_name,
value: { color },
})
},
},
visibleRole() {
if (!this.user.show_role && !this.user.adminData) {
return
}
const rights = this.user.rights
if (!rights) {
return
}
const validRole = rights.admin || rights.moderator
const roleTitle = rights.admin ? 'admin' : 'moderator'
return validRole && roleTitle
},
hideFollowsCount() {
return this.isOtherUser && this.user.hide_follows_count
},
hideFollowersCount() {
return this.isOtherUser && this.user.hide_followers_count
},
showModerationMenu() {
const privileges = this.loggedIn.privileges
return (
this.loggedIn.role === 'admin' ||
privileges.has('users_manage_activation_state') ||
privileges.has('users_delete') ||
privileges.has('users_manage_tags')
)
},
hasNote() {
return this.relationship.note
},
supportsNote() {
return 'note' in this.relationship
},
muteExpiryAvailable() {
return Object.hasOwn(this.user, 'mute_expires_at')
},
muteExpiry() {
return this.user.mute_expires_at === false
? this.$t('user_card.mute_expires_forever')
: this.$t('user_card.mute_expires_at', [
new Date(this.user.mute_expires_at).toLocaleString(),
])
},
blockExpiryAvailable() {
return Object.hasOwn(this.user, 'block_expires_at')
},
blockExpiry() {
return this.user.block_expires_at == null
? this.$t('user_card.block_expires_forever')
: this.$t('user_card.block_expires_at', [
new Date(this.user.mute_expires_at).toLocaleString(),
])
},
formattedBirthday() {
const browserLocale = localeService.internalToBrowserLocale(
this.$i18n.locale,
)
return (
this.user.birthday &&
new Date(Date.parse(this.user.birthday)).toLocaleDateString(
browserLocale,
{ timeZone: 'UTC', day: 'numeric', month: 'long', year: 'numeric' },
)
)
},
formattedJoinDate() {
const browserLocale = localeService.internalToBrowserLocale(
this.$i18n.locale,
)
return (
this.user.created_at &&
new Date(Date.parse(this.user.created_at)).toLocaleDateString(
browserLocale,
{ timeZone: 'UTC', day: 'numeric', month: 'long', year: 'numeric' },
)
)
},
// Editable stuff
avatarImgSrc() {
const currentUrl =
this.user.profile_image_url_original || this.defaultAvatar
if (!this.editable) return currentUrl
const newUrl =
this.newAvatar === null ? this.defaultAvatar : this.newAvatar
return this.newAvatar === null ? currentUrl : newUrl
},
bannerImgSrc() {
const currentUrl = this.user.cover_photo || this.defaultBanner
if (!this.editable) return currentUrl
const newUrl =
this.newBanner === null ? this.defaultBanner : this.newBanner
return this.newBanner === null ? currentUrl : newUrl
},
defaultAvatar() {
return (
useInstanceStore().server +
useInstanceStore().instanceIdentity.defaultAvatar
)
},
defaultBanner() {
return (
useInstanceStore().server +
useInstanceStore().instanceIdentity.defaultBanner
)
},
isDefaultAvatar() {
const baseAvatar = useInstanceStore().instanceIdenitity.defaultAvatar
return (
!this.$store.state.users.currentUser.profile_image_url ||
this.$store.state.users.currentUser.profile_image_url.includes(
baseAvatar,
)
)
},
isDefaultBanner() {
const baseBanner = useInstanceStore().instanceIdentity.defaultBanner
return (
!this.$store.state.users.currentUser.cover_photo ||
this.$store.state.users.currentUser.cover_photo.includes(baseBanner)
)
},
fieldsLimits() {
return useInstanceStore().limits.fieldsLimits
},
maxFields() {
return this.fieldsLimits ? this.fieldsLimits.maxFields : 0
},
emojiUserSuggestor() {
return suggestor({
emoji: [
...useEmojiStore().standardEmojiList,
...useEmojiStore().customEmoji,
],
store: this.$store,
})
},
emojiSuggestor() {
return suggestor({
emoji: [
...useEmojiStore().standardEmojiList,
...useEmojiStore().customEmoji,
],
})
},
allowNonSquareEmoji() {
return this.mergedConfig.nonSquareEmoji
},
pauseMfm() {
return this.mergedConfig.pauseMfm
},
scaleMfm() {
return this.mergedConfig.scaleMfm
},
hideUserStats() {
return this.mergedConfig.hideUserStats
},
hideRemarks() {
return this.mergedConfig.userCardHidePersonalMarks
},
...mapState(useMergedConfigStore, ['mergedConfig']),
},
methods: {
isKnownTag(tag) {
return KNOWN_TAGS.has(tag)
},
muteUser() {
this.$refs.timedMuteDialog.optionallyPrompt()
},
unmuteUser() {
this.$store.dispatch('unmuteUser', this.user.id)
},
subscribeUser() {
return this.$store.dispatch('subscribeUser', this.user.id)
},
unsubscribeUser() {
return this.$store.dispatch('unsubscribeUser', this.user.id)
},
linkClicked({ target }) {
if (target.tagName === 'SPAN') {
target = target.parentNode
}
if (target.tagName === 'A') {
window.open(target.href, '_blank')
}
},
userProfileLink(user) {
return generateProfileLink(
user.id,
user.screen_name,
useInstanceStore().restrictedNicknames,
)
},
openProfileTab() {
useInterfaceStore().openSettingsModalTab('profile')
},
zoomAvatar() {
const attachment = {
url: this.user.profile_image_url_original,
type: 'image',
}
useMediaViewerStore().setMedia([attachment])
useMediaViewerStore().setCurrentMedia(attachment)
},
mentionUser() {
usePostStatusStore().openPostStatusModal({
profileMention: this.user,
})
},
onAvatarClickHandler(e) {
if (this.onAvatarClick) {
e.preventDefault()
this.onAvatarClick()
}
},
// Editable stuff
changeAvatar() {
this.editImage = 'avatar'
},
changeBanner() {
this.editImage = 'banner'
},
submitImage({ canvas, file }) {
if (canvas) {
return canvas.toBlob((data) =>
this.submitImage({ canvas: null, file: data }),
)
}
const reader = new window.FileReader()
reader.onload = (e) => {
const dataUrl = e.target.result
if (this.editImage === 'avatar') {
this.newAvatar = dataUrl
this.newAvatarFile = file
} else {
this.newBanner = dataUrl
this.newBannerFile = file
}
this.editImage = false
}
reader.readAsDataURL(file)
},
resetImage() {
if (this.editImage === 'avatar') {
this.newAvatar = null
this.newAvatarFile = null
} else {
this.newBanner = null
this.newBannerFile = null
}
this.editImage = false
},
addField() {
if (this.newFields.length < this.maxFields) {
this.newFields.push({ name: '', value: '' })
}
},
deleteField(index) {
this.newFields.splice(index, 1)
},
propsToNative(props) {
return propsToNative(props)
},
cancelImageText() {
return
},
resetState() {
const user = this.$store.state.users.currentUser
this.newName = user.name_unescaped
this.newBio = ldUnescape(user.description)
this.newAvatar = null
this.newAvatarFile = null
this.newBanner = null
this.newBannerFile = null
this.newActorType = user.actor_type
this.newBirthday = user.birthday
this.newShowBirthday = user.show_birthday
this.newShowRole = user.show_role
this.newFields = user.fields.map((field) => ({
name: field.name,
value: field.value,
}))
},
updateProfile() {
const params = {
note: this.newBio,
// Backend notation.
display_name: this.newName,
fields_attributes: this.newFields.filter((el) => el != null),
show_role: !!this.newShowRole,
birthday: this.newBirthday || '',
show_birthday: !!this.newShowBirthday,
}
if (this.newActorType) {
params.actor_type = this.newActorType
}
if (this.newAvatarFile !== null) {
params.avatar = this.newAvatarFile
}
if (this.newBannerFile !== null) {
params.header = this.newBannerFile
}
updateProfile({ params })
.then(({ data: user }) => {
this.newFields.splice(this.newFields.length)
merge(this.newFields, user.fields)
this.$store.commit('addNewUsers', [user])
this.$store.commit('setCurrentUser', user)
this.resetState()
})
.catch((error) => {
this.displayUploadError(error)
})
},
displayUploadError(error) {
useInterfaceStore().pushGlobalNotice({
messageKey: 'upload.error.message',
messageArgs: [error.message],
level: 'error',
})
},
},
}
diff --git a/src/i18n/compare b/src/i18n/compare
index 94e5aab555..7afe9355c3 100755
--- a/src/i18n/compare
+++ b/src/i18n/compare
@@ -1,49 +1,49 @@
#!/usr/bin/env node
const arg = process.argv[2]
-if (typeof arg === 'undefined') {
+if (arg === undefined) {
console.info('This is a very simple and tiny tool that checks en.json with any other language and')
console.info('outputs all the things present in english but missing in foreign language.')
console.info('')
console.info('Usage: ./compare.js <lang> ')
console.info(' or')
console.info(' node ./compare.js <lang>')
console.info('')
console.info('Where <lang> is name of .json file containing language. For ./fi.json it should be:')
console.info(' ./compare.js fi ')
console.info('')
console.info('Limitations: ')
console.info('* This program does not work with languages left over in messages.js')
console.info('* This program does not check for extra strings present in foreign language but missing')
console.info(' in english.js (for now)')
console.info('')
console.info('There are no other arguments or options. Make an issue if you encounter a bug or want')
console.info('some feature to be implemented. Merge requests are welcome as well.')
process.exit()
}
const english = require('./en.json')
const foreign = require(`./${arg}.json`)
function walker (a, b, path = []) {
Object.keys(a).forEach(k => {
const aVal = a[k]
const bVal = b[k]
const aType = typeof aVal
const bType = typeof bVal
const currentPath = [...path, k]
const article = aType[0] === 'o' ? 'an' : 'a'
if (bType === 'undefined') {
console.warn(`Foreign language is missing ${article} ${aType} at path ${currentPath.join('.')}`)
} else if (aType === 'object') {
if (bType !== 'object') {
console.warn(`Type mismatch! English has ${aType} while foreign has ${bType} at path ${currentPath.join['.']}`)
} else {
walker(aVal, bVal, currentPath)
}
}
})
}
walker(english, foreign)
diff --git a/src/i18n/en.json b/src/i18n/en.json
index 60fcaf1dd4..4b257be9ff 100644
--- a/src/i18n/en.json
+++ b/src/i18n/en.json
@@ -1,2084 +1,2086 @@
{
"about": {
"mrf": {
"federation": "Federation",
"keyword": {
"keyword_policies": "Keyword policies",
"ftl_removal": "Removal from \"The Whole Known Network\" Timeline",
"reject": "Reject",
"replace": "Replace",
"is_replaced_by": "→"
},
"mrf_policies": "Enabled MRF policies",
"mrf_policies_desc": "MRF policies manipulate the federation behaviour of the instance. The following policies are enabled:",
"simple": {
"simple_policies": "Instance-specific policies",
"instance": "Instance",
"reason": "Reason",
"not_applicable": "N/A",
"accept": "Accept",
"accept_desc": "This instance only accepts messages from the following instances:",
"reject": "Reject",
"reject_desc": "This instance will not accept messages from the following instances:",
"quarantine": "Quarantine",
"quarantine_desc": "This instance will send only public posts to the following instances:",
"ftl_removal": "Removal from \"Known Network\" Timeline",
"ftl_removal_desc": "This instance removes these instances from \"Known Network\" timeline:",
"media_removal": "Media Removal",
"media_removal_desc": "This instance removes media from posts on the following instances:",
"media_nsfw": "Media force-set as sensitive",
"media_nsfw_desc": "This instance forces media to be set sensitive in posts on the following instances:"
}
},
"staff": "Staff",
"terms": "Terms of Service"
},
"announcements": {
"page_header": "Announcements",
"title": "Announcement",
"mark_as_read_action": "Mark as read",
"post_form_header": "Post announcement",
"post_placeholder": "Type your announcement content here...",
"post_action": "Post",
"post_error": "Error: {error}",
"close_error": "Close",
"delete_action": "Delete",
"start_time_prompt": "Start time: ",
"end_time_prompt": "End time: ",
"all_day_prompt": "This is an all-day event",
"published_time_display": "Published at {time}",
"start_time_display": "Starts at {time}",
"end_time_display": "Ends at {time}",
"edit_action": "Edit",
"submit_edit_action": "Submit",
"cancel_edit_action": "Cancel",
"inactive_message": "This announcement is inactive"
},
"shoutbox": {
"title": "Shoutbox"
},
"domain_mute_card": {
"mute": "Mute",
"mute_progress": "Muting…",
"unmute": "Unmute",
"unmute_progress": "Unmuting…"
},
"exporter": {
"export": "Export",
"processing": "Processing, you'll soon be asked to download your file"
},
"features_panel": {
"shout": "Shoutbox",
"pleroma_chat_messages": "Pleroma Chat",
"gopher": "Gopher",
"media_proxy": "Media proxy",
"scope_options": "Scope options",
"text_limit": "Text limit",
"title": "Features",
"who_to_follow": "Who to follow",
"upload_limit": "Upload limit"
},
"finder": {
"error_fetching_user": "Error fetching user",
"find_user": "Find user"
},
"general": {
"apply": "Apply",
"submit": "Submit",
"more": "More",
"no_more": "No more items",
"loading": "Loading…",
"generic_error": "An error occured",
"generic_error_message": "An error occured: {0}",
"generic_error_details": "Technical info:",
"error_retry": "Please try again",
"refresh_required": "Refresh required",
"refresh_required_content": "Failed to load UI code. Most likely frontend was updated on server, you'll need to refresh the page.",
"refresh_required_refresh": "Refresh page",
"retry": "Try again",
"optional": "optional",
"show_more": "Show more",
"show_less": "Show less",
"never_show_again": "Never show again",
"dismiss": "Dismiss",
"cancel": "Cancel",
"disable": "Disable",
"enable": "Enable",
"confirm": "Confirm",
"verify": "Verify",
"close": "Close",
"undo": "Undo",
"yes": "Yes",
"no": "No",
"none": "None",
"not_applicable": "N/A",
"not_available": "N/A",
"peek": "Peek",
"scroll_to_top": "Scroll to top",
"role": {
"admin": "Admin",
"moderator": "Moderator"
},
"unpin": "Unpin item",
"pin": "Pin item",
"flash_content": "Click to show Flash content using Ruffle (Experimental, may not work).",
"flash_security": "Note that this can be potentially dangerous since Flash content is still arbitrary code.",
"flash_fail": "Failed to load flash content, see console for details.",
"scope_in_timeline": {
"local": "Non-federated",
"direct": "Direct",
"private": "Followers-only",
"public": "Public",
"unlisted": "Unlisted"
}
},
"image_cropper": {
"crop_picture": "Crop picture",
"save": "Save",
"save_without_cropping": "Save without cropping",
"cancel": "Cancel"
},
"importer": {
"submit": "Submit",
"import": "Import",
"success": "Imported successfully.",
"error": "An error occured while importing this file."
},
"login": {
"login": "Log in",
"description": "Log in with OAuth",
"logout": "Log out",
"logout_confirm_title": "Logout confirmation",
"logout_confirm": "Do you really want to logout?",
"logout_confirm_accept_button": "Logout",
"logout_confirm_cancel_button": "Do not logout",
"password": "Password",
"placeholder": "e.g. lain",
"register": "Register",
"username": "Username",
"hint": "Log in to join the discussion",
"authentication_code": "Authentication code",
"enter_recovery_code": "Enter a recovery code",
"enter_two_factor_code": "Enter a two-factor code",
"recovery_code": "Recovery code",
"heading": {
"totp": "Two-factor authentication",
"recovery": "Two-factor recovery"
}
},
"media_modal": {
"previous": "Previous",
"next": "Next",
"counter": "{current} / {total}",
"hide": "Close media viewer"
},
"nav": {
"about": "About",
"administration": "Administration",
"back": "Back",
"friend_requests": "Follow requests",
"mentions": "Mentions",
"interactions": "Interactions",
"dms": "Direct messages",
"public_tl": "Public timeline",
"bubble": "Bubble timeline",
"timeline": "Timeline",
"home_timeline": "Home timeline",
"twkn": "Known Network",
"bookmarks": "Bookmarks",
"all_bookmarks": "All bookmarks",
"bookmark_folders": "Bookmark folders",
"user_search": "User Search",
"search": "Search",
"search_close": "Close search bar",
"who_to_follow": "Who to follow",
"preferences": "Preferences",
"timelines": "Timelines",
"chats": "Chats",
"lists": "Lists",
"edit_nav_mobile": "Customize navigation bar",
"edit_pinned": "Edit pinned items",
"edit_finish": "Done editing",
"mobile_sidebar": "Toggle mobile sidebar",
"mobile_notifications": "Open notifications (there are unread ones)",
"mobile_notifications_close": "Close notifications",
"mobile_notifications_mark_as_seen": "Mark all as seen",
"announcements": "Announcements",
"quotes": "Quotes",
"drafts": "Drafts"
},
"notifications": {
"broken_favorite": "Unknown status, searching for it…",
"error": "Error fetching notifications: {0}",
"favorited_you": "favorited your status",
"followed_you": "followed you",
"follow_request": "wants to follow you",
"load_older": "Load older notifications",
"notifications": "Notifications",
"read": "Read!",
"repeated_you": "repeated your status",
"no_more_notifications": "No more notifications",
"migrated_to": "migrated to",
"reacted_with": "reacted with {0}",
"submitted_report": "submitted a report",
"poll_ended": "poll has ended",
"unread_announcements": "{num} unread announcement | {num} unread announcements",
"unread_chats": "{num} unread chat | {num} unread chats",
"unread_follow_requests": "{num} new follow request | {num} new follow requests",
"configuration_tip": "You can customize what to display here in {theSettings}. {dismiss}",
"configuration_tip_settings": "the settings",
"configuration_tip_dismiss": "Do not show again",
"subscribed_status": "posted"
},
"polls": {
"add_poll": "Add poll",
"add_option": "Add option",
"option": "Option",
"votes": "votes",
"people_voted_count": "{count} person voted | {count} people voted",
"votes_count": "{count} vote | {count} votes",
"vote": "Vote",
"type": "Poll type",
"single_choice": "Single choice",
"multiple_choices": "Multiple choices",
"expiry": "Poll age",
+ "expiry_unit": "Expiry unit",
+ "expiry_amount": "Expiry amount",
"expires_at": "Poll ends {0}",
"expires_in": "Poll ends in {0}",
"expired": "Poll ended {0} ago",
"expired_at": "Poll ended {0}",
"not_enough_options": "Too few unique options in poll",
"non_anonymous": "Public poll",
"non_anonymous_title": "Other instances may display the options you voted for"
},
"emoji": {
"stickers": "Stickers",
"emoji": "Emoji",
"keep_open": "Keep picker open",
"search_emoji": "Search for an emoji",
"add_emoji": "Insert emoji",
"custom": "Custom emoji",
"hide_custom_emoji": "Hide custom emojis",
"unpacked": "Unpacked emoji",
"unicode": "Unicode emoji",
"unicode_groups": {
"activities": "Activities",
"animals-and-nature": "Animals & Nature",
"flags": "Flags",
"food-and-drink": "Food & Drink",
"objects": "Objects",
"people-and-body": "People & Body",
"smileys-and-emotion": "Smileys & Emotion",
"symbols": "Symbols",
"travel-and-places": "Travel & Places"
},
"load_all_hint": "Loaded first {saneAmount} emoji, loading all emoji may cause performance issues.",
"load_all": "Loading all {emojiAmount} emoji",
"regional_indicator": "Regional indicator {letter}"
},
"errors": {
"storage_unavailable": "Pleroma could not access browser storage. Your login or your local settings won't be saved and you might encounter unexpected issues. Try enabling cookies."
},
"interactions": {
"favs_repeats": "Repeats and favorites",
"follows": "New follows",
"emoji_reactions": "Emoji Reactions",
"reports": "Reports",
"moves": "User migrates",
"load_older": "Load older interactions",
"statuses": "Subscriptions"
},
"post_status": {
"edit_status": "Edit status",
"new_status": "Post new status",
"reply_option": "Reply to this status",
"quote_option": "Quote this status",
"quote_url": "Link to quoted post",
"account_not_locked_warning": "Your account is not {0}. Anyone can follow you to view your follower-only posts.",
"account_not_locked_warning_link": "locked",
"attachments_sensitive": "Mark attachments as sensitive",
"media_description": "Media description",
"content_type": {
"text/plain": "Plain text",
"text/html": "HTML",
"text/markdown": "Markdown",
"text/bbcode": "BBCode",
"text/x.misskeymarkdown": "MFM"
},
"content_type_selection": "Post format",
"content_warning": "Subject (optional)",
"mentions_line": "Mentioned users",
"enter_submits": "Enter key sends the post",
"enter_newline": "Enter key adds a newline",
"default": "Just landed in L.A.",
"direct_warning_to_all": "This post will be visible to all the mentioned users.",
"direct_warning_to_first_only": "This post will only be visible to the mentioned users at the beginning of the message.",
"edit_remote_warning": "Other remote instances may not support editing and unable to receive the latest version of your post.",
"edit_unsupported_warning": "Pleroma does not support editing mentions or polls.",
"posting": "Posting",
"post": "Post",
"preview": "Preview",
"preview_empty": "Empty",
"empty_status_error": "Can't post an empty status with no files",
"media_description_error": "Failed to update media, try again",
"scope_notice": {
"public": "This post will be visible to everyone",
"private": "This post will be visible to your followers only",
"unlisted": "This post will not be visible in Public Timeline and The Whole Known Network"
},
"scope_notice_dismiss": "Close this notice",
"scope": {
"direct": "Direct - post to mentioned users only",
"private": "Followers-only - post to followers only",
"public": "Public - post to public timelines",
"unlisted": "Unlisted - do not post to public timelines"
},
"close_confirm_title": "Closing post form",
"close_confirm": "What do you want to do with your current writing?",
"close_confirm_save_button": "Save",
"close_confirm_discard_button": "Discard",
"close_confirm_continue_composing_button": "Continue composing",
"auto_save_nothing_new": "Nothing new to save.",
"auto_save_saved": "Saved.",
"auto_save_saving": "Saving...",
"save_to_drafts_button": "Save to drafts",
"save_to_drafts_and_close_button": "Save to drafts and close",
"more_post_actions": "More post actions..."
},
"registration": {
"bio_optional": "Bio (optional)",
"email": "Email",
"email_optional": "Email (optional)",
"fullname": "Display name",
"password_confirm": "Password confirmation",
"registration": "Registration",
"token": "Invite token",
"captcha": "CAPTCHA",
"new_captcha": "Click the image to get a new captcha",
"username_placeholder": "e.g. lain",
"fullname_placeholder": "e.g. Lain Iwakura",
"bio_placeholder": "e.g.\nHi, I'm Lain.\nI’m an anime girl living in suburban Japan. You may know me from the Wired.",
"reason": "Reason to register",
"reason_placeholder": "This instance approves registrations manually.\nLet the administration know why you want to register.",
"register": "Register",
"validations": {
"username_required": "cannot be left blank",
"fullname_required": "cannot be left blank",
"email_required": "cannot be left blank",
"password_required": "cannot be left blank",
"password_confirmation_required": "cannot be left blank",
"password_confirmation_match": "should be the same as password",
"birthday_required": "cannot be left blank",
"birthday_min_age": "must be on or before {date}"
},
"email_language": "In which language do you want to receive emails from the server?",
"birthday": "Birthday:",
"birthday_optional": "Birthday (optional):"
},
"remote_user_resolver": {
"remote_user_resolver": "Remote user resolver",
"searching_for": "Searching for",
"error": "Not found."
},
"report": {
"reporter": "Reporter:",
"reported_user": "Reported user:",
"reported_statuses": "Reported statuses:",
"notes": "Notes:",
"state": "State:",
"state_open": "Open",
"state_closed": "Closed",
"state_resolved": "Resolved"
},
"selectable_list": {
"select_all": "Select all"
},
"settings": {
"invalid_settings_imported": "Error importing settings",
"add_language": "Add fallback language",
"remove_language": "Remove",
"primary_language": "Primary language:",
"fallback_language": "Fallback language {index}:",
"actor_type": "This account is:",
"actor_type_description": "Marking your account as a group will make it automatically repeat statuses that mention it.",
"actor_type_Person": "a normal user",
"actor_type_person_proper": "a person",
"actor_type_Service": "a bot",
"actor_type_Group": "a group",
"mobile_center_dialog": "Vertically center dialogs on mobile",
"app_name": "App name",
"expert_mode": "Show advanced",
"save": "Save changes",
"reset": "Reset changes",
"security": "Security",
"toggle_edit": "Edit",
"change_banner": "Change banner",
"change_avatar": "Change avatar",
"setting_changed": "Setting is different from default",
"setting_server_side": "This setting is tied to your profile and affects all sessions and clients",
"setting_local_side": "This setting is tied to current session and doesn't affects other devices and browsers",
"enter_current_password_to_confirm": "Enter your current password to confirm your identity",
"post_look_feel": "Posts Look & Feel",
"posts": "Posts",
"developer": "Developer",
"debug": "Debug",
"mention_links": "Mention Links",
"appearance": "Appearance",
"confirm_new_setting": "Confirm new setting?",
"confirm_new_question": "Does this look ok? Setting will be reverted in 10 seconds.",
"confirm_new_question_countdown": "Does this look ok? Setting will be reverted in 1 second. | Does this look ok? Setting will be reverted in {count} seconds.",
"revert": "Revert",
"confirm": "Confirm",
"text_size": "Text and interface size",
"text_size_tip": "Use {0} for absolute values, {1} will scale with browser default text size.",
"text_size_tip2": "Values other than {0} might break some things and themes",
"emoji_size": "Emoji size",
"navbar_size": "Top bar size",
"panel_header_size": "Panel header size",
"visual_tweaks": "Minor visual tweaks",
"theme_debug": "Show what background theme engine assumes when dealing with transparancy",
"scale_and_layout": "Interface scale and layout",
"timelines": "Timelines",
"format_and_language": "Format and Language",
"confirmations": "Confirmations",
"layout": "Layout",
"enabled": "Enabled",
"clutter": "Clutter",
"filter": {
"clutter": "Remove clutter",
"mute_filter": "Mute Filters",
"type": "Filter type",
"regexp": "RegExp",
"plain": "Simple",
"user": "User (Simple)",
"user_regexp": "User (RegExp)",
"case_sensitive": "Case-sensitive",
"hide": "Hide completely",
"name": "Name",
"value": "Value",
"expires": "Expires",
"expired": "Expired",
"copy": "Duplicate",
"save": "Save",
"delete": "Remove",
"new": "Create new",
"import": "Import",
"export": "Export",
"regexp_error": "Invalid Regular Expression",
"never_expires": "Never",
"total_count": "Total {count} custom filter|Total {count} custom filters",
"expired_count": "{count} expired filter|{count} expired filters",
"custom_filters": "Custom filters",
"purge_expired": "Remove expired filters",
"import_failure": "The selected file is not a supported Pleroma filter.",
"help": {
"word": "Simple and RegExp filters test against post's content and subject.",
"user": "User filter matches full user handle (user{'@'}domain) in the following: author, reply-to and mentions",
"regexp": "Regex variants are more advanced and use {link} to match instead of simple substring search.",
"regexp_link": "Regular Expressions",
"regexp_url": "https://en.wikipedia.org/wiki/Regular_expression"
}
},
"mfa": {
"otp": "OTP",
"setup_otp": "Setup OTP",
"wait_pre_setup_otp": "presetting OTP",
"confirm_and_enable": "Confirm & enable OTP",
"title": "Two-factor Authentication",
"generate_new_recovery_codes": "Generate new recovery codes",
"warning_of_generate_new_codes": "When you generate new recovery codes, your old codes won’t work anymore.",
"recovery_codes": "Recovery codes.",
"waiting_a_recovery_codes": "Receiving backup codes…",
"recovery_codes_warning": "Write the codes down or save them somewhere secure - otherwise you won't see them again. If you lose access to your 2FA app and recovery codes you'll be locked out of your account.",
"authentication_methods": "Authentication methods",
"scan": {
"title": "Scan",
"desc": "Using your two-factor app, scan this QR code or enter text key:",
"secret_code": "Key"
},
"verify": {
"desc": "To enable two-factor authentication, enter the code from your two-factor app:"
}
},
"units": {
"time": {
"m": "minutes",
"s": "seconds",
"h": "hours",
"d": "days"
}
},
"lists_navigation": "Show lists in navigation",
"allow_following_move": "Allow auto-follow when following account moves",
"attachmentRadius": "Attachments",
"attachments": "Attachments",
"image_compression": "Compress images before uploading",
"always_use_jpeg": "Always convert images to JPEG format",
"avatar": "Avatar",
"avatarAltRadius": "Avatars (notifications)",
"avatarRadius": "Avatars",
"background": "Background",
"bio": "Bio",
"profile_other": "Other",
"email_language": "Language for receiving emails from the server",
"block_export": "Block export",
"block_export_button": "Export your blocks to a csv file",
"block_import": "Block import",
"block_import_error": "Error importing blocks",
"blocks_imported": "Blocks imported! Processing them will take a while.",
"mute_export": "Mute export",
"mute_export_button": "Export your mutes to a csv file",
"mute_import": "Mute import",
"mute_import_error": "Error importing mutes",
"mutes_imported": "Mutes imported! Processing them will take a while.",
"import_mutes_from_a_csv_file": "Import mutes from a csv file",
"account_backup": "Account backup",
"account_backup_description": "This allows you to download an archive of your account information and your posts, but they cannot yet be imported into a Pleroma account.",
"account_backup_table_head": "Backup",
"download_backup": "Download",
"backup_not_ready": "This backup is not ready yet.",
"backup_running": "This backup is in progress, processed {number} record. | This backup is in progress, processed {number} records.",
"backup_failed": "This backup has failed.",
"remove_backup": "Remove",
"list_backups_error": "Error fetching backup list: {error}",
"add_backup": "Create a new backup",
"added_backup": "Added a new backup.",
"add_backup_error": "Error adding a new backup: {error}",
"blocks_tab": "Blocks",
"btnRadius": "Buttons",
"cBlue": "Blue (Reply, follow)",
"cGreen": "Green (Retweet)",
"cOrange": "Orange (Favorite)",
"cRed": "Red (Cancel)",
"change_email": "Change email",
"change_email_error": "There was an issue changing your email.",
"changed_email": "Email changed successfully!",
"change_password": "Change password",
"change_password_error": "There was an issue changing your password.",
"changed_password": "Password changed successfully!",
"chatMessageRadius": "Chat message",
"collapse_subject": "Collapse posts with subjects",
"composing": "Composing",
"replies": "Replying",
"confirm_new_password": "Confirm new password",
"current_password": "Current password",
"confirm_dialogs": "Ask for confirmation when",
"confirm_dialogs_repeat": "repeating a status",
"confirm_dialogs_unfollow": "unfollowing a user",
"confirm_dialogs_block": "blocking a user",
"confirm_dialogs_mute": "muting a user",
"confirm_dialogs_mute_domain": "muting domains",
"confirm_dialogs_mute_conversation": "muting conversations",
"confirm_dialogs_delete": "deleting a status",
"confirm_dialogs_logout": "logging out",
"confirm_dialogs_approve_follow": "approving a follower",
"confirm_dialogs_deny_follow": "denying a follower",
"confirm_dialogs_remove_follower": "removing a follower",
"mutes_and_blocks": "Mutes and Blocks",
"data_import_export_tab": "Data import / export",
"default_vis": "Default visibility scope",
"delete_account": "Delete account",
"delete_account_description": "Permanently delete your data and deactivate your account.",
"delete_account_error": "There was an issue deleting your account. If this persists please contact your instance administrator.",
"delete_account_instructions": "Type your password in the input below to confirm account deletion.",
"account_alias": "Account aliases",
"account_alias_table_head": "Alias",
"list_aliases_error": "Error fetching aliases: {error}",
"hide_list_aliases_error_action": "Close",
"remove_alias": "Remove this alias",
"new_alias_target": "Add a new alias (e.g. {example})",
"added_alias": "Alias is added.",
"add_alias_error": "Error adding alias: {error}",
"move_account": "Move account",
"move_account_notes": "If you want to move the account somewhere else, you must go to your target account and add an alias pointing here.",
"move_account_target": "Target account (e.g. {example})",
"moved_account": "Account is moved.",
"move_account_error": "Error moving account: {error}",
"discoverable": "Allow discovery of this account in search results and other services",
"domain_mutes": "Domains",
"domain_mutes2": "Excluded domains",
"user_mutes2": "Muted users",
"user_blocks": "Blocked users",
"avatar_size_instruction": "The recommended minimum size for avatar images is 150x150 pixels. Recommended aspect ratio is 1:1",
"banner_size_instruction": "The recommended minimum size for banner images is 450x150 pixels. Recommended aspect ratio is 3:1",
"pad_emoji": "Pad emoji with spaces when adding from picker",
"autocomplete_select_first": "Automatically select the first candidate when autocomplete results are available",
"unsaved_post_action": "When you try to close an unsaved posting form",
"unsaved_post_action_save": "Save it to drafts",
"unsaved_post_action_discard": "Discard it",
"unsaved_post_action_confirm": "Ask every time",
"auto_save_draft": "Save drafts as you compose",
"emoji_reactions_on_timeline": "Show emoji reactions on timeline",
"emoji_reactions_scale": "Reactions scale factor",
"absolute_time_format": "Use absolute time format",
"absolute_time_format_min_age": "Only use for time older than this amount of time",
"absolute_time_format_12h": "Time format",
"absolute_time_format_12h_12h": "12 hour format (i.e. 10:00 PM)",
"absolute_time_format_12h_24h": "24 hour format (i.e. 22:00)",
"export_theme": "Save preset",
"filtering": "Filtering",
"wordfilter": "Wordfilter",
"filtering_explanation": "All statuses containing these words will be muted, one per line",
"word_filter_and_more": "Word filter and more...",
"follow_export": "Follow export",
"follow_export_button": "Export your follows to a csv file",
"follow_import": "Follow import",
"follow_import_error": "Error importing followers",
"import_export": {
"title": "Import / Export",
"follows": "List of users you follow",
"blocks": "List of users you block",
"mutes": "List of users you mute"
},
"follows_imported": "Follows imported! Processing them will take a while.",
"accent": "Accent",
"foreground": "Foreground",
"general": "General",
"hide_attachments_in_convo": "Hide attachments in conversations",
"hide_attachments_in_tl": "Hide attachments in timeline",
"hide_media_previews": "Hide media previews",
"hide_muted_posts": "Hide posts of muted users",
"mute_bot_posts": "Mute bot posts",
"hide_actor_type_indication": "Hide actor type (bots, groups, etc.) indication in posts",
"hide_scrobbles": "Hide scrobbles",
"hide_scrobbles_after": "Hide scrobbles older than",
"mute_sensitive_posts": "Mute sensitive posts",
"hide_all_muted_posts": "Hide muted posts",
"max_thumbnails": "Maximum amount of thumbnails per post (empty = no limit)",
"hide_isp": "Hide instance-specific panel",
"hide_shoutbox": "Hide instance shoutbox",
"right_sidebar": "Reverse order of columns",
"navbar_column_stretch": "Stretch navbar to columns width",
"always_show_post_button": "Always show floating New Post button",
"hide_wallpaper": "Hide instance wallpaper",
"foreign_user_background": "Allow other user's profiles to override wallpaper",
"preload_images": "Preload images",
"use_one_click_nsfw": "Open NSFW attachments with just one click",
"hide_post_stats": "Hide post statistics (e.g. the number of favorites)",
"hide_user_stats": "Hide user statistics (e.g. the number of followers)",
"hide_filtered_statuses": "Hide all filtered posts",
"hide_muted_statuses": "Completely hide all muted posts",
"hide_wordfiltered_statuses": "Hide word-filtered statuses",
"hide_muted_threads": "Hide muted threads",
"import_blocks_from_a_csv_file": "Import blocks from a csv file",
"import_followers_from_a_csv_file": "Import follows from a csv file",
"import_theme": "Load preset",
"inputRadius": "Input fields",
"checkboxRadius": "Checkboxes",
"instance_default": "(default: {value})",
"instance_default_simple": "(default)",
"interface": "Interface",
"interfaceLanguage": "Interface language",
"invalid_theme_imported": "The selected file is not a supported Pleroma theme. No changes to your theme were made.",
"limited_availability": "Unavailable in your browser",
"links": "Links",
"lock_account_description": "Restrict your account to approved followers only",
"loop_video": "Loop videos",
"loop_video_silent_only": "Loop only videos without sound (i.e. Mastodon's \"gifs\")",
"mutes_tab": "Mutes",
"play_videos_in_modal": "Play videos in a popup frame",
"url": "URL",
"preview": "Preview",
"file_export_import": {
"backup_restore": "Settings backup",
"backup_settings": "Backup settings to file",
"backup_settings_theme": "Backup settings and theme to file",
"restore_settings": "Restore settings from file",
"errors": {
"invalid_file": "The selected file is not a supported Pleroma settings backup. No changes were made.",
"file_too_new": "Incompatile major version: {fileMajor}, this PleromaFE (settings ver {feMajor}) is too old to handle it",
"file_too_old": "Incompatile major version: {fileMajor}, file version is too old and not supported (min. set. ver. {feMajor})",
"file_slightly_new": "File minor version is different, some settings might not load"
}
},
"profile_fields": {
"label": "Profile metadata",
"add_field": "Add field",
"name": "Label",
"value": "Content"
},
"birthday": {
"label": "Birthday",
"show_birthday": "Show my birthday"
},
"account_profile_edit": "Edit Profile",
"account_privacy": "Privacy",
"use_contain_fit": "Don't crop the attachment in thumbnails",
"name": "Name",
"name_bio": "Name & bio",
"new_email": "New email",
"new_password": "New password",
"user_profiles": "User Profiles",
"notification_visibility": "Types of notifications to show",
"notification_visibility_in_column": "Show in notifications column/drawer",
"notification_visibility_native_notifications": "Show a native notification",
"notification_visibility_follows": "Follows",
"notification_visibility_follow_requests": "Follow requests",
"notification_visibility_likes": "Favorites",
"notification_visibility_mentions": "Mentions",
"notification_visibility_repeats": "Repeats",
"notification_visibility_reports": "Reports",
"notification_visibility_moves": "User Migrates",
"notification_visibility_emoji_reactions": "Reactions",
"notification_visibility_polls": "Ends of polls you voted in",
"notification_visibility_statuses": "Subscriptions",
"notification_show_extra": "Show extra notifications in the notifications column",
"notification_extra_chats": "Show unread chats",
"notification_extra_announcements": "Show unread announcements",
"notification_extra_follow_requests": "Show new follow requests",
"notification_extra_tip": "Show the customization tip for extra notifications",
"no_rich_text_description": "Strip rich text formatting from all posts",
"no_blocks": "No blocks",
"no_mutes": "No mutes",
"hide_favorites_description": "Don't show list of my favorites (people still get notified)",
"hide_follows_description": "Don't show who I'm following",
"hide_followers_description": "Don't show who's following me",
"hide_follows_count_description": "Don't show follow count",
"hide_followers_count_description": "Don't show follower count",
"show_admin_badge": "Show \"Admin\" badge in my profile",
"show_moderator_badge": "Show \"Moderator\" badge in my profile",
"nsfw_clickthrough": "Hide sensitive/NSFW media",
"oauth_tokens": "OAuth tokens",
"token": "Token",
"refresh_token": "Refresh token",
"valid_until": "Valid until",
"revoke_token": "Revoke",
"panelRadius": "Panels",
"pause_on_unfocused": "Pause when tab is not focused",
"presets": "Presets",
"profile_background": "Profile background",
"profile_banner": "Profile banner",
"profile_tab": "Profile",
"radii_help": "Set up interface edge rounding (in pixels)",
"replies_in_timeline": "Replies in timeline",
"reply_visibility_all": "Show all replies",
"reply_visibility_following": "Only show replies directed at me or users I'm following",
"reply_visibility_self": "Only show replies directed at me",
"reply_visibility_following_short": "Show replies to my follows",
"reply_visibility_self_short": "Show replies to self only",
"autohide_floating_post_button": "Automatically hide New Post button (mobile)",
"saving_err": "Error saving settings",
"saving_ok": "Settings saved",
"search_user_to_block": "Search whom you want to block",
"search_user_to_mute": "Search whom you want to mute",
"security_tab": "Security",
"scope_copy": "Copy scope when replying (DMs are always copied)",
"minimal_scopes_mode": "Minimize post scope selection options",
"set_new_avatar": "Set new avatar",
"set_new_profile_background": "Set new profile background",
"set_new_background": "Set new background",
"set_new_profile_banner": "Set new profile banner",
"reset_avatar": "Reset avatar",
"reset_banner": "Reset banner",
"reset_profile_background": "Reset profile background",
"reset_profile_banner": "Reset profile banner",
"reset_avatar_confirm": "Do you really want to reset the avatar?",
"reset_banner_confirm": "Do you really want to reset the banner?",
"reset_background_confirm": "Do you really want to reset the background?",
"settings": "Settings",
"subject_input_always_show": "Always show subject field",
"subject_line_behavior": "Copy subject when replying",
"subject_line_email": "Like email: \"re: subject\"",
"subject_line_mastodon": "Like mastodon: copy as is",
"subject_line_noop": "Do not copy",
"submit_on_enter_in_chats": "Send message on Enter in chats",
"force_theme_recompilation_debug": "Disable theme cahe, force recompile on each boot",
"conversation_display": "Conversation display style",
"conversation_display_tree": "Tree-style",
"conversation_display_tree_quick": "Tree view",
"disable_sticky_headers": "Don't stick column headers to top of the screen",
"show_scrollbars": "Show side column's scrollbars",
"third_column_mode": "When there's enough space, show third column containing",
"third_column_mode_none": "Don't show third column at all",
"third_column_mode_notifications": "Notifications column",
"third_column_mode_postform": "Main post form and navigation",
"columns": "Columns",
"column_sizes": "Column sizes",
"column_sizes_sidebar": "Sidebar",
"column_sizes_content": "Content",
"column_sizes_notifs": "Notifications",
"scale_and_font": "Scale and Font",
"theme_editor_min_width": "Minimum width of theme editor (0 for \"fit-content\")",
"tree_advanced": "Allow more flexible navigation in tree view",
"tree_fade_ancestors": "Display ancestors of the current status in faint text",
"conversation_display_linear": "Linear-style",
"conversation_display_linear_quick": "Linear view",
"conversation_other_replies_button": "Show the \"other replies\" button",
"conversation_other_replies_button_below": "Below statuses",
"conversation_other_replies_button_inside": "Inside statuses",
"max_depth_in_thread": "Maximum number of levels in thread to display by default",
"post_status_content_type": "Post status content type",
"default_post_status_content_type": "Default post status content type",
"sensitive_by_default": "Mark posts as sensitive by default",
"stop_gifs": "Pause animated images until you hover on them",
"non_square_emoji": "Allow non-square emoji",
"pause_mfm": "Pause MFM animations until you hover on them",
"scale_mfm": "Scale MFM animations with emoji size",
"streaming": "Automatically show new posts when scrolled to the top",
"auto_update": "Show new posts automatically",
"user_mutes": "Users",
"useStreamingApi": "Receive posts and notifications real-time",
"use_websockets": "Use websockets (Realtime updates)",
"text": "Text",
"theme": "Theme",
"theme_old": "Theme editor (old)",
"theme_help": "Use hex color codes (#rrggbb) to customize your color theme.",
"theme_help_v2_1": "You can also override certain component's colors and opacity by toggling the checkbox, use \"Clear all\" button to clear all overrides.",
"theme_help_v2_2": "Icons underneath some entries are background/text contrast indicators, hover over for detailed info. Please keep in mind that when using transparency contrast indicators show the worst possible case.",
"tooltipRadius": "Tooltips/alerts",
"type_domains_to_mute": "Search domains to mute",
"upload_a_photo": "Upload a photo",
"upload_picture": "Upload picture",
"select_picture": "Select picture",
"user_settings": "User Settings",
"values": {
"false": "no",
"true": "yes"
},
"virtual_scrolling": "Optimize timeline rendering",
"use_at_icon": "Display {'@'} symbol as an icon instead of text",
"mention_link_display": "Display mention links",
"mention_link_display_short": "always as short names (e.g. {'@'}foo)",
"mention_link_display_full_for_remote": "as full names only for remote users (e.g. {'@'}foo{'@'}example.org)",
"mention_link_display_full": "always as full names (e.g. {'@'}foo{'@'}example.org)",
"mention_link_use_tooltip": "Show user card when clicking mention links",
"mention_link_show_avatar": "Show user avatar beside the link",
"mention_link_show_avatar_quick": "Show user avatar next to mentions",
"mention_link_fade_domain": "Fade domains (e.g. {'@'}example.org in {'@'}foo{'@'}example.org)",
"mention_link_bolden_you": "Highlight mention of you when you are mentioned",
"user_popover_avatar_action": "Popover avatar click action",
"user_popover_avatar_action_zoom": "Zoom the avatar",
"user_popover_avatar_action_close": "Close the popover",
"user_popover_avatar_action_open": "Open profile",
"user_popover_avatar_overlay": "Show user popover over user avatar",
"user_card_left_justify": "Justify user bio to the left",
"user_card_hide_personal_marks": "Hide personal marks (highlight/note) in user profiles",
"posts_appearance": "Posts Appearance",
"fun": "Fun",
"greentext": "Meme arrows",
"plaintext_quotes": "Highlight plaintext {0}",
"greentext_quotes": ">quotes",
"show_yous": "Show (You)s",
"notifications": "Notifications",
"notification_setting_annoyance": "Annoyance",
"notification_setting_drawer_marks_as_seen": "Closing drawer (mobile) marks all notifications as read",
"notification_setting_ignore_inactionable_seen": "Ignore read state of inactionable notifications (likes, repeats etc)",
"notification_setting_ignore_inactionable_seen_tip": "This will not actually mark those notifications as read, and you'll still get desktop notifications about them if you chose so",
"notification_setting_unseen_at_top": "Show unread notifications above others",
"notification_setting_filters": "Filters",
"notification_setting_filters_chrome_push": "On some browsers (chrome) it might be impossible to completely filter out notifications by type when they arrive by Push",
"notification_setting_block_from_strangers": "Block notifications from users who you do not follow",
"notification_setting_privacy": "Privacy",
"notification_setting_hide_notification_contents": "Hide the sender and contents of push notifications",
"notification_mutes": "To stop receiving notifications from a specific user, use a mute.",
"notification_blocks": "Blocking a user stops all notifications as well as unsubscribes them.",
"enable_web_push_notifications": "Enable web push notifications",
"enable_web_push_always_show": "Always show web push notifications",
"enable_web_push_always_show_tip": "Some browsers (Chromium, Chrome) require that push messages always result in a notification, otherwise generic 'Website was updated in background' is shown, enable this to prevent this notification from showing, as Chrome seem to hide push notifications if tab is in focus. Can result in showing duplicate notifications on other browsers.",
"more_settings": "More settings",
"style": {
"style_section": "Style",
"custom_theme_used": "(Custom theme)",
"custom_style_used": "(Custom style)",
"stock_theme_used": "(Stock theme)",
"themes2_outdated": "Editor for Themes V2 is being phased out and will eventually be replaced with a new one that takes advantage of new Themes V3 engine. It should still work but experience might be degraded and inconsistent.",
"appearance_tab_note": "Changes on this tab do not affect the theme used, so exported theme will be different from what seen in the UI",
"visual_tweaks_section_note": "Changes in this section do not affect the theme used, exported theme will be different from what seen in the UI",
"update_preview": "Update preview",
"themes3": {
"define": "Override",
"palette": {
"label": "Color schemes",
"name_label": "Color scheme name",
"import": "Import palette",
"export": "Export palette",
"apply": "Apply palette",
"bg": "Panel background",
"fg": "Buttons etc.",
"text": "Text",
"link": "Links",
"accent": "Accent color",
"cRed": "Red color",
"cBlue": "Blue color",
"cGreen": "Green color",
"cOrange": "Orange color",
"wallpaper": "Wallpaper",
"v2_unsupported": "Older v2 themes don't support palettes. Switch to v3 theme to make use of palettes",
"bundled": "Bundled palettes",
"style": "Palettes provided by selected style",
"user": "Custom palette",
"imported": "Imported"
},
"editor": {
"title": "Style editor",
"reset_style": "Reset",
"load_style": "Open from file",
"save_style": "Save",
"style_name": "Stylesheet name",
"style_author": "Made by",
"style_license": "License",
"style_website": "Website",
"component_selector": "Component",
"variant_selector": "Variant",
"states_selector": "States",
"main_tab": "Main",
"shadows_tab": "Shadows",
"background": "Background color",
"text_color": "Text color",
"icon_color": "Icon color",
"link_color": "Link color",
"contrast": "Text contrast",
"roundness": "Roundness",
"opacity": "Opacity",
"border_color": "Border color",
"include_in_rule": "Add to rule",
"test_string": "TEST",
"invalid": "Invalid",
"refresh_preview": "Refresh preview",
"apply_preview": "Apply",
"text_auto": {
"label": "Auto-contrast",
"no-preserve": "Black or White",
"preserve": "Keep color",
"no-auto": "Disabled"
},
"component_tab": "Components style",
"palette_tab": "Color schemes",
"variables_tab": "Variables (Advanced)",
"variables": {
"label": "Variables",
"name_label": "Name:",
"type_label": "Type:",
"type_shadow": "Shadow",
"type_color": "Color",
"type_generic": "Generic",
"virtual_color": "Variable color value"
}
},
"hacks": {
"underlay_overrides": "Change underlay",
"underlay_override_mode_none": "Theme default",
"underlay_override_mode_opaque": "Replace with solid color",
"underlay_override_mode_transparent": "Remove entirely (might break some themes)",
"force_interface_roundness": "Override interface roundness/sharpness",
"forced_roundness_mode_disabled": "Use theme defaults",
"forced_roundness_mode_sharp": "Force sharp edges",
"forced_roundness_mode_nonsharp": "Force not-so-sharp (1px roundness) edges",
"forced_roundness_mode_round": "Force round edges"
},
"font": {
"group-builtin": "Browser default fonts",
"builtin": {
"serif": "Serif",
"sans-serif": "Sans-serif",
"monospace": "Monospace",
"inherit": "Unchanged"
},
"group-local": "Locally installed fonts",
"local-unavailable1": "List of locally installed fonts unavailable",
"local-unavailable2": "Use manual entry to specify custom font",
"font_list_unavailable": "Couldn't get locally installed fonts: {error}",
"lookup_local_fonts": "Load list of fonts installed on this computer",
"enter_manually": "Enter font name family manually",
"entry": "Enter {fontFamily}",
"select": "Select font",
"label": "{label} font"
}
},
"interface_font_user_override": "Override theme/browser font used",
"switcher": {
"keep_color": "Keep colors",
"keep_shadows": "Keep shadows",
"keep_opacity": "Keep opacity",
"keep_roundness": "Keep roundness",
"keep_fonts": "Keep fonts",
"save_load_hint": "\"Keep\" options preserve currently set options when selecting or loading themes, it also stores said options when exporting a theme. When all checkboxes unset, exporting theme will save everything.",
"reset": "Reset",
"clear_all": "Clear all",
"clear_opacity": "Clear opacity",
"load_theme": "Load theme",
"keep_as_is": "Keep as is",
"use_snapshot": "Old version",
"use_source": "New version",
"help": {
"upgraded_from_v2": "PleromaFE has been upgraded, theme could look a little bit different than you remember.",
"v2_imported": "File you imported was made for older FE. We try to maximize compatibility but there still could be inconsistencies.",
"future_version_imported": "File you imported was made in newer version of FE.",
"older_version_imported": "File you imported was made in older version of FE.",
"snapshot_present": "Theme snapshot is loaded, so all values are overriden. You can load theme's actual data instead.",
"snapshot_missing": "No theme snapshot was in the file so it could look different than originally envisioned.",
"fe_upgraded": "PleromaFE's theme engine upgraded after version update.",
"fe_downgraded": "PleromaFE's version rolled back.",
"migration_snapshot_ok": "Just to be safe, theme snapshot loaded. You can try loading theme data.",
"migration_napshot_gone": "For whatever reason snapshot was missing, some stuff could look different than you remember.",
"snapshot_source_mismatch": "Versions conflict: most likely FE was rolled back and updated again, if you changed theme using older version of FE you most likely want to use old version, otherwise use new version."
}
},
"common": {
"color": "Color",
"opacity": "Opacity",
"contrast": {
"hint": "Contrast ratio is {ratio}, it {level} {context}",
"level": {
"aa": "meets Level AA guideline (minimal)",
"aaa": "meets Level AAA guideline (recommended)",
"bad": "doesn't meet any accessibility guidelines"
},
"context": {
"18pt": "for large (18pt+) text",
"text": "for text"
}
}
},
"common_colors": {
"_tab_label": "Common",
"main": "Common colors",
"foreground_hint": "See \"Advanced\" tab for more detailed control",
"rgbo": "Icons, accents, badges"
},
"advanced_colors": {
"_tab_label": "Advanced",
"alert": "Alert background",
"alert_error": "Error",
"alert_warning": "Warning",
"alert_neutral": "Neutral",
"post": "Posts/User bios",
"badge": "Badge background",
"popover": "Tooltips, menus, popovers",
"badge_notification": "Notification",
"panel_header": "Panel header",
"top_bar": "Top bar",
"borders": "Borders",
"buttons": "Buttons",
"inputs": "Input fields",
"faint_text": "Faded text",
"underlay": "Underlay",
"wallpaper": "Wallpaper",
"poll": "Poll graph",
"icons": "Icons",
"highlight": "Highlighted elements",
"pressed": "Pressed",
"selectedPost": "Selected post",
"selectedMenu": "Selected menu item",
"disabled": "Disabled",
"toggled": "Toggled",
"tabs": "Tabs",
"chat": {
"incoming": "Incoming",
"outgoing": "Outgoing",
"border": "Border"
}
},
"radii": {
"_tab_label": "Roundness"
},
"shadows": {
"_tab_label": "Shadow and lighting",
"component": "Component",
"override": "Override",
"shadow_id": "Shadow #{value}",
"offset": "Shadow offset",
"zoom": "Zoom",
"offset-x": "x:",
"offset-y": "y:",
"light_grid": "Use light checkerboard",
"color_override": "Use different color",
"name": "Name",
"blur": "Blur",
"spread": "Spread",
"inset": "Inset",
"raw": "Plain shadow",
"expression": "Expression (advanced)",
"empty_expression": "Empty expression",
"hintV3": "For shadows you can also use the {0} notation to use other color slot.",
"filter_hint": {
"always_drop_shadow": "Warning, this shadow always uses {0} when browser supports it.",
"drop_shadow_syntax": "{0} does not support {1} parameter and {2} keyword.",
"avatar_inset_short": "Separate inset shadow",
"avatar_inset": "Please note that combining both inset and non-inset shadows on avatars might give unexpected results with transparent avatars.",
"spread_zero": "Shadows with spread > 0 will appear as if it was set to zero",
"inset_classic": "Inset shadows will be using {0}"
},
"components": {
"panel": "Panel",
"panelHeader": "Panel header",
"topBar": "Top bar",
"avatar": "User avatar (in profile view)",
"avatarStatus": "User avatar (in post display)",
"popup": "Popups and tooltips",
"button": "Button",
"buttonHover": "Button (hover)",
"buttonPressed": "Button (pressed)",
"buttonPressedHover": "Button (pressed+hover)",
"input": "Input field"
}
},
"fonts": {
"_tab_label": "Fonts",
"help": "Select font to use for elements of UI. For \"custom\" you have to enter exact font name as it appears in system.",
"components": {
"interface": "Interface",
"input": "Input fields",
"post": "Post text",
"monospace": "Monospaced text"
},
"components_inline": {
"interface": "interface",
"input": "input fields",
"post": "post text",
"monospace": "monospaced text"
},
"override": "Override {0} font",
"family": "Font name",
"size": "Size (in px)",
"weight": "Weight (boldness)",
"custom": "Custom"
},
"preview": {
"header": "Preview",
"content": "Content",
"error": "Example error",
"button": "Button",
"text": "A bunch of more {0} and {1}",
"mono": "content",
"input": "Just landed in L.A.",
"faint_link": "helpful manual",
"fine_print": "Read our {0} to learn nothing useful!",
"header_faint": "This is fine",
"checkbox": "I have skimmed over terms and conditions",
"link": "a nice lil' link"
}
},
"version": {
"title": "Version",
"backend_version": "Backend version",
"frontend_version": "Frontend version"
},
"commit_value": "Save",
"commit_value_tooltip": "Value is not saved, press this button to commit your changes",
"reset_value": "Reset",
"reset_value_tooltip": "Reset draft",
"hard_reset_value": "Hard reset",
"hard_reset_value_tooltip": "Remove setting from storage, forcing use of default value",
"cache": "Cache",
"clear_asset_cache": "Clear asset cache",
"clear_emoji_cache": "Clear emoji cache",
"compact_profiles": "Reduce profile height on user pages"
},
"admin_dash": {
"window_title": "Administration",
"wip_notice": "This admin dashboard is experimental and WIP, {adminFeLink}.",
"old_ui_link": "old admin UI available here",
"reset_all": "Reset all",
"commit_all": "Save all",
"tabs": {
"nodb": "No DB Config",
"instance": "Instance",
"users": "Users",
"limits": "Limits",
"frontends": "Front-ends",
"mailer": "EMails",
"media_proxy": "Media Proxy",
"emoji": "Emoji",
"uploads": "Uploads",
"monitoring": "Monitoring",
"registrations": "Registrations",
"links": "Links",
"job_queues": "Job Queues",
"auth": "Auth",
"posts": "Posts",
"rate_limit": "Rate Limits",
"http": "HTTP",
"federation": "Federation",
"other": "Other"
},
"posts": {
"global": "Global settings",
"local": "Local posts",
"remote": "Remote posts"
},
"other": {
"uncategorized": "Uncategorized",
"user_backup": "User Backup",
"reports": "Reports",
"privileges": "Privileges"
},
"monitoring": {
"builtins": "Built-in Tools",
"prometheus": "Prometheus Exporter"
},
"federation": {
"global": "Global settings",
"restrictions": "Restrictions",
"activitypub": "ActivityPub"
},
"auth": {
"MFA": "Multi-factor Authentication",
"LDAP": "LDAP Settings",
"OAuth": "Oauth2 settings",
"TOTP": "One-time Passwords (TOTP)",
"backup_codes": "Backup codes"
},
"job_queues": {
"Gun": {
"title": "Gun queues",
"connections_pools": "Gun connections pool",
"pools": {
"title": "Gun worker pools",
"default": "Default pool",
"federation": "Federation pool",
"media": "Media pool",
"rich_media": "Rich media pool",
"upload": "Upload pool"
}
},
"Hackney": {
"title": "Hackney pools",
"federation": "Federation",
"media": "Media",
"rich_media": "Rich media",
"upload": "Upload"
},
"queues": "Queues"
},
"rate_limit": {
"rate_limit": "Rate Limit",
"amount": "Amount",
"unauthenticated": "Unauthenticated",
"authenticated": "Authenticated",
"period": "Time period",
"separate": "Separate rate limits for authenticated/unauthenticated users"
},
"nodb": {
"heading": "Database config is disabled",
"text": "You need to change backend config files so that {property} is set to {value}, see more in {documentation}.",
"documentation": "documentation",
"text2": "Most configuration options will be unavailable."
},
"captcha": {
"native": "Native",
"kocaptcha": "KoCaptcha"
},
"http": {
"outbound": "Outgoing connections",
"incoming": "Incoming connections",
"security": "HTTP Security",
"web_push": "Web Push",
"web_push_description": "Web Push VAPID settings. You can use the mix task web_push.gen.keypair to generate it."
},
"registrations": {
"welcome": {
"title": "Welcome message",
"description": "Send new users a message when they sign up",
"direct_message": "Via direct message",
"chat_message": "Via chat",
"email_message": "Via email"
},
"restrictions": "Restrictions",
"autofollow": "Autofollow"
},
"links": {
"no_scheme": "No scheme",
"link_previews": "Link previews",
"link_formatter": "Link formatter"
},
"uploads": {
"attachments": "Attachments settings",
"upload": "Upload",
"local_uploader": "Local files",
"filenames": "Filenames, Titles and Descriptions",
"uploader_settings": "Uploader settings"
},
"media_proxy": {
"basic": "Basic Settings",
"invalidation": "Cache Invalidation",
"limits": "Limits",
"thumbnails": "Thumbnail Generation",
"invalidation_settings": "Cache Invalidation"
},
"mailer": {
"styling": "Styling",
"assets": "Assets",
"colors": "Color palette",
"adapter": "Mailing Adapter",
"auth": "Authentication"
},
"users": {
"title": "Users",
"local_id": "Local ID",
"no_users_found": "No users found",
"labels": {
"query": "Search",
"nickname": "{'@'}handle",
"name": "Display Name",
"name_colon": "Name:",
"email": "Email",
"email_colon": "Email:",
"handle_colon": "Handle:",
"origin": "Origin",
"activity": "Activity",
"privileges": "Privileges"
},
"tags": {
"add_new": "Add New Tag",
"new_title": "Enter New Tag And Confirm",
"yes": "Add",
"no": "Abort"
},
"options": {
"all": "All",
"only_local": "Only Local",
"only_external": "Only External",
"only_active": "Only Active",
"only_deactivated": "Only Deactivated",
"only_admins": "Only Admins",
"only_privileged": "Only Privileged",
"only_moderators": "Only Moderators",
"only_unapproved": "Exclude Approved",
"only_unconfirmed": "Exclude Confirmed"
},
"filters": {
"show_direct": "Show Direct Messages",
"show_reblogs": "Show Reblogs"
},
"indicator": {
"admin": "Admin",
"moderator": "Moderator",
"active": "Active",
"deactivated": "Deactivated",
"confirmed": "Confirmed",
"unconfirmed": "Pending confirmation",
"approved": "Approved",
"suggested": "Suggested",
"unapproved": "Pending approval"
}
},
"limits": {
"arbitrary_limits": "Arbitrary limits",
"posts": "Post limits",
"other": "Misc. limits",
"uploads": "Attachments limits",
"users": "User profile limits",
"profile_fields": "Profile fields limits",
"user_uploads": "Profile media limits"
},
"frontend": {
"title": "Frontend management",
"repository": "Repository link",
"versions": "Available versions",
"build_url": "Build URL",
"reinstall": "Reinstall",
"is_default": "(Default)",
"is_default_custom": "(Default, version: {version})",
"install": "Install",
"install_version": "Install version {version}",
"more_install_options": "More install options",
"more_default_options": "More default setting options",
"set_default": "Set default",
"set_default_version": "Set version {version} as default",
"wip_notice": "Please note that this section is a WIP and lacks certain features as backend implementation of front-end management is incomplete.",
"default_frontend": "Default frontend",
"default_frontend_tip": "Default frontend will be shown to all users. Currently there's no way to for a user to select personal frontend. If you switch away from PleromaFE you'll most likely have to use old and buggy AdminFE to do instance configuration until we replace it.",
"default_frontend_unavail": "Default frontend settings are not available, as this requires configuration in the database",
"available_frontends": "Available for install",
"failure_installing_frontend": "Failed to install frontend {version}: {reason}",
"success_installing_frontend": "Frontend {version} successfully installed"
},
"emoji": {
"global_actions": "Global actions",
"reload": "Reload emoji",
"reload_short": "Refresh",
"advanced": "Advanced",
"importFS": "Import emoji from filesystem",
"import_pack": "Upload emoji pack",
"import_pack_short": "Import",
"error": "Error: {0}",
"create_pack": "Create pack",
"delete_pack": "Delete pack",
"new_pack_name": "New pack name",
"create": "Create",
"emoji_packs": "Emoji packs",
"remote_packs": "Remote packs",
"remote_packs_short": "Remote",
"do_list": "List",
"remote_pack_instance": "Remote pack instance",
"emoji_pack": "Emoji pack",
"edit_pack": "Edit pack",
"metadata": "Metadata",
"description": "Description",
"homepage": "Homepage",
"fallback_src": "Fallback source",
"fallback_sha256": "Fallback SHA256",
"share": "Share",
"save": "Save",
"save_meta": "Save metadata",
"revert_meta": "Revert metadata",
"delete": "Delete",
"revert": "Revert",
"add_file": "Add file",
"adding_new": "Adding new emoji",
"shortcode": "Shortcode",
"filename": "Filename",
"emoji_source": "Emoji file source",
"upload_url": "Upload from URL",
"new_shortcode": "Shortcode, leave blank to infer",
"new_filename": "Filename, leave blank to infer",
"delete_confirm": "Are you sure you want to delete {0}?",
"download_pack": "Download pack",
"downloading_pack": "Downloading {0}",
"download": "Download",
"download_as_name": "New name",
"download_as_name_full": "New name, leave blank to reuse",
"files": "Files",
"editing": "Editing {0}",
"copying": "Copying {0}",
"copy_to": "Copy to",
"copy_to_pack": "Copy to local pack",
"delete_title": "Delete?",
"metadata_changed": "Metadata different from saved",
"emoji_changed": "Unsaved emoji file changes, check highlighted emoji",
"replace_warning": "This will REPLACE the local pack of the same name",
"copied_successfully": "Successfully copied emoji \"{0}\" to pack \"{1}\""
},
"generic_enforcement": {
"if_available": "If available",
"always": "Always",
"never": "Never"
},
"instance": {
"instance": "Instance information",
"registrations": "User sign-ups",
"captcha_header": "CAPTCHA",
"kocaptcha": "KoCaptcha settings",
"pwa": {
"manifest": "PWA Manifest",
"optional": "(optional)",
"no_icons": "No icons defined",
"icon": {
"purpose": "Icon purpose",
"any": "Any",
"monochrome": "Monochrome",
"maskable": "Maskable"
}
},
"misc_brand": "Miscellaneous elements",
"branding": "Branding",
"access": "Instance access",
"rich_metadata": "Metadata",
"restrict": {
"header": "Restrict access for anonymous visitors",
"description": "Detailed setting for allowing/disallowing access to certain aspects of API. By default (indeterminate state) it will disallow if instance is not public, ticked checkbox means disallow access even if instance is public, unticked means allow access even if instance is private. Please note that unexpected behavior might happen if some settings are set, i.e. if profile access is disabled posts will show without profile information.",
"timelines": "Timelines access",
"profiles": "User profiles access",
"activities": "Statuses/activities access"
},
":unauthenticated": "Unauthenticated",
":all": "Everyone"
},
"temp_overrides": {
":pleroma": {
":connections_pool": {
":max_idle_time": {
"label": "Maximum idle time",
"description": "Maximum idle time before CONFIRM"
},
":retry": {
"label": "Retry",
"description": "Number of retries for making a connection CONFIRM"
}
},
":hackney_pools": {
":rich_media": {
"label": "Rich media",
"description": "idk",
":max_connections": {
"label": "Max connections",
"description": "Number workers in the pool."
},
":timeout": {
"label": "Timeout",
"description": "Timeout while `hackney` will wait for response."
}
}
},
":pools": {
":rich_media": {
"label": "Rich media",
"description": "idk",
":size": {
"label": "Size",
"description": "Maximum number of concurrent requests in the pool."
},
":max_waiting": {
"label": "Max waiting",
"description": "Maximum number of requests waiting for other requests to finish. After this number is reached, the pool will start returning errors when a new request is made"
},
":recv_timeout": {
"label": "Recv timeout",
"description": "Timeout for the pool while gun will wait for response"
}
}
},
":rate_limit": {
":oauth_app_creation": {
"label": "OAuth app creation",
"description": "For registering new OAuth App ID"
},
":ap_routes": {
"label": "ActivityPub",
"description": "Federation endpoints"
},
":account_confirmation_resend": {
"label": "Account confirmation resend",
"description": "How often user can resend confirmation mail"
}
},
"Pleroma_DOT_Formatter": {
":attribute_toggle": {
"label": "Set {attr} attribute"
},
":truncate_toggle": {
"label": "Truncate"
},
":class": {
"label": "Value"
},
":rel": {
"label": "Value"
}
},
"Pleroma_DOT_Uploaders_DOT_Uploader": {
":timeout": {
"label": "Timeout",
"description": "Amount of milliseconds before dropping connection"
}
},
"Pleroma_DOT_Upload": {
":default_description": {
"label": "Default description",
"description": "Default description to give to a file. Setting it to ':filename' will use file's filename as description."
}
},
":instance": {
":public": {
"label": "Instance is public",
"description": "Disabling this will make all API accessible only for logged-in users, this will make Public and Federated timelines inaccessible to anonymous visitors."
},
":limit_to_local_content": {
"label": "Limit search to local content",
"description": "Disables global network search for unauthenticated (default), all users or none"
},
":description_limit": {
"label": "Limit",
"description": "Character limit for attachment descriptions"
},
":background_image": {
"label": "Background image",
"description": "Background image (primarily used by PleromaFE)"
}
},
":http_security": {
":allow_unsafe_eval": {
"label": "Allow unsafe-eval",
"description": "Allow unsafe evaluation of scripts (required for Flash support)"
},
":report_url": {
"label": "Report URL",
"description": "URL to report security violations to"
}
}
}
}
},
"time": {
"unit": {
"days": "{0} day | {0} days",
"days_short": "{0}d",
"days_suffix": "day(s)",
"hours": "{0} hour | {0} hours",
"hours_short": "{0}h",
"hours_suffix": "hour(s)",
"minutes": "{0} minute | {0} minutes",
"minutes_short": "{0}min",
"minutes_suffix": "minute(s)",
"months": "{0} month | {0} months",
"months_short": "{0}mo",
"months_suffix": "month(s)",
"seconds": "{0} second | {0} seconds",
"seconds_short": "{0}s",
"seconds_suffix": "second(s)",
"weeks": "{0} week | {0} weeks",
"weeks_short": "{0}w",
"weeks_suffix": "week(s)",
"years": "{0} year | {0} years",
"years_short": "{0}y",
"years_suffix": "year(s)"
},
"in_future": "in {0}",
"in_past": "{0} ago",
"now": "just now",
"now_short": "now"
},
"timeline": {
"collapse": "Collapse",
"conversation": "Conversation",
"error": "Error fetching timeline: {0}",
"load_older": "Load older statuses",
"no_retweet_hint": "Post is marked as followers-only or direct and cannot be repeated",
"repeated": "repeated",
"show_new": "Show new",
"reload": "Reload",
"up_to_date": "Up-to-date",
"no_more_statuses": "No more statuses",
"no_statuses": "No statuses",
"socket_reconnected": "Realtime connection established",
"socket_broke": "Realtime connection lost: CloseEvent code {0}",
"quick_view_settings": "Quick view settings",
"quick_filter_settings": "Quick filter settings",
"filter_settings": "Filter"
},
"status": {
"favorites": "Favorites",
"repeats": "Repeats",
"quotes": "Quotes",
"repeat_confirm": "Do you really want to repeat this status?",
"repeat_confirm_title": "Repeat confirmation",
"repeat_confirm_accept_button": "Repeat",
"repeat_confirm_cancel_button": "Do not repeat",
"delete": "Delete status",
"delete_error": "Error deleting status: {0}",
"edit": "Edit status",
"edited_at": "(last edited {time})",
"open_in_chat_view": "Open in chat view",
"open_in_thread_view": "Open in thread view",
"pin": "Pin on profile",
"unpin": "Unpin from profile",
"pinned": "Pinned",
"bookmark": "Bookmark",
"unbookmark": "Unbookmark",
"delete_confirm": "Do you really want to delete this status?",
"delete_confirm_title": "Delete confirmation",
"delete_confirm_accept_button": "Delete",
"delete_confirm_cancel_button": "Keep",
"reply_to": "Reply to",
"reply_to_selected": "Replying to selected message",
"reply_to_last": "Replying to last message",
"reply_to_with_icon": "{icon} {replyTo}",
"broken_reply": "Message belongs to the thread but is not a reply",
"reply_to_with_arg": "{replyToWithIcon} {user}",
"mentions": "Mentions",
"replies_list": "Replies:",
"replies_list_with_others": "Replies (+{numReplies} other): | Replies (+{numReplies} others):",
"mute_ellipsis": "Mute…",
"mute_user": "Mute user",
"unmute_user": "Unmute user",
"mute_domain": "Mute domain",
"unmute_domain": "Unmute domain",
"mute_conversation": "Mute conversation",
"unmute_conversation": "Unmute conversation",
"status_unavailable": "Status unavailable",
"copy_link": "Copy link to status",
"external_source": "External source",
"muted_words": "Wordfiltered: {word} | Wordfiltered: {word} and {numWordsMore} more words",
"muted_filters": "Filtered: {name} | Wordfiltered: {name} and {filtersMore} more words",
"multi_reason_mute": "{main} + one more reason | {main} + {numReasonsMore} more reasons",
"muted_user": "User muted",
"thread_muted": "Thread muted",
"thread_muted_and_words": ", has words:",
"sensitive_muted": "Muting sensitive content",
"bot_muted": "Muting bot content",
"show_full_subject": "Show full subject",
"hide_full_subject": "Hide full subject",
"show_content": "Show content",
"hide_content": "Hide content",
"status_deleted": "This post was deleted",
"unknown_user": "unknown user",
"unknown_user_info": "Unable to fetch information about this user",
"nsfw": "NSFW",
"expand": "Expand",
"you": "(You)",
"plus_more": "+{number} more",
"many_attachments": "Post has {number} attachment(s)",
"collapse_attachments": "Collapse attachments",
"show_all_attachments": "Show all attachments",
"show_attachment_in_modal": "Show in media modal",
"show_attachment_description": "Preview description (open attachment for full description)",
"attachment_description": "Attachment description",
"hide_attachment": "Hide attachment",
"remove_attachment": "Remove attachment",
"attachment_stop_flash": "Stop Flash player",
"move_up": "Shift attachment left",
"move_down": "Shift attachment right",
"open_gallery": "Open gallery",
"thread_hide": "Hide this thread",
"thread_show": "Show this thread",
"thread_show_full": "Show everything under this thread ({numStatus} status in total, max depth {depth}) | Show everything under this thread ({numStatus} statuses in total, max depth {depth})",
"thread_show_full_with_icon": "{icon} {text}",
"thread_follow": "See the remaining part of this thread ({numStatus} status in total) | See the remaining part of this thread ({numStatus} statuses in total)",
"thread_follow_with_icon": "{icon} {text}",
"ancestor_follow": "See {numReplies} other reply under this status | See {numReplies} other replies under this status",
"ancestor_follow_with_icon": "{icon} {text}",
"show_all_conversation_with_icon": "{icon} {text}",
"show_all_conversation": "Show full conversation ({numStatus} other status) | Show full conversation ({numStatus} other statuses)",
"show_only_conversation_under_this": "Only show replies to this status",
"status_history": "Status history",
"reaction_count_label": "{num} person reacted | {num} people reacted",
"hide_quote": "Hide the quoted status",
"display_quote": "Display the quoted status",
"invisible_quote": "Quoted status unavailable: {link}",
"more_actions": "More actions on this status",
"loading": "Loading...",
"load_error": "Unable to load status: {error}",
"admin_change_scope": "Change visibility",
"mark_as_sensitive": "Sensitive",
"mark_as_non-sensitive": "Non-sensitive"
},
"user_card": {
"approve": "Approve",
"approve_confirm_title": "Approve confirmation",
"approve_confirm_accept_button": "Approve",
"approve_confirm_cancel_button": "Do not approve",
"approve_confirm": "Do you want to approve {user}'s follow request?",
"block": "Block",
"blocked": "Blocked!",
"block_confirm_title": "Block confirmation",
"block_confirm": "Do you really want to block {user}?",
"block_confirm_accept_button": "Block",
"block_confirm_cancel_button": "Do not block",
"deactivated": "Deactivated",
"deny": "Deny",
"deny_confirm_title": "Deny confirmation",
"deny_confirm_accept_button": "Deny",
"deny_confirm_cancel_button": "Do not deny",
"deny_confirm": "Do you want to deny {user}'s follow request?",
"edit_profile": "Edit profile",
"favorites": "Favorites",
"follow": "Follow",
"follow_cancel": "Cancel request",
"follow_sent": "Request sent!",
"follow_progress": "Requesting…",
"follow_unfollow": "Unfollow",
"unfollow_confirm_title": "Unfollow confirmation",
"unfollow_confirm": "Do you really want to unfollow {user}?",
"unfollow_confirm_accept_button": "Unfollow",
"unfollow_confirm_cancel_button": "Do not unfollow",
"followees": "Following",
"followers": "Followers",
"following": "Following!",
"follows_you": "Follows you!",
"hidden": "Hidden",
"its_you": "It's you!",
"media": "Media",
"mention": "Mention",
"message": "Message",
"mute": "Mute",
"muted": "Muted",
"mute_confirm_title": "Mute confirmation",
"mute_confirm": "Do you really want to mute {user}?",
"mute_domain_confirm": "Do you really want to mute entire {domain}?",
"mute_confirm_accept_button": "Mute",
"mute_confirm_cancel_button": "Do not mute",
"mute_or": "or",
"expire_in": "Expire in",
"expire_mute_message": "Are you sure you want to mute {0}?",
"expire_block_message": "Are you sure you want to block {0}?",
"dont_ask_again_mute": "Always mute users this way",
"dont_ask_again_block": "Always block users this way",
"mute_block_temporarily": "Temporarily",
"mute_block_forever": "Forever",
"mute_block_never": "Never",
"mute_block_ask": "Ask",
"default_mute_expiration": "Always mute users",
"default_block_expiration": "Always block users",
"default_expiration_time": "Expire in",
"mute_expires_forever": "Muted forever",
"mute_expires_at": "Muted until {0}",
"block_expires_forever": "Blocked forever",
"block_expires_at": "Blocked until {0}",
"mute_duration_prompt": "Mute this user for (0 for indefinite time):",
"statuses_per_day": "Statuses per day",
"remote_follow": "Remote follow",
"remove_follower": "Remove follower",
"remove_follower_confirm_title": "Remove follower confirmation",
"remove_follower_confirm_accept_button": "Remove",
"remove_follower_confirm_cancel_button": "Keep",
"remove_follower_confirm": "Do you really want to remove {user} from your followers?",
"report": "Report",
"statuses": "Statuses",
"subscribe": "Subscribe",
"unsubscribe": "Unsubscribe",
"unblock": "Unblock",
"unblock_progress": "Unblocking…",
"block_progress": "Blocking…",
"unmute": "Unmute",
"unmute_progress": "Unmuting…",
"mute_progress": "Muting…",
"hide_repeats": "Hide repeats",
"show_repeats": "Show repeats",
"bot": "Bot",
"group": "Group",
"birthday": "Born {birthday}",
"joined": "Joined",
"admin_data": {
"data": "Administrative info",
"registration_reason": "Registration reason",
"tags": "Tags"
},
"admin_menu": {
"moderation": "Moderation",
"grant_admin": "Grant Admin",
"revoke_admin": "Revoke Admin",
"grant_moderator": "Grant Moderator",
"revoke_moderator": "Revoke Moderator",
"activate_account": "Activate",
"deactivate_account": "Deactivate",
"delete_account": "Delete",
"suggest_account": "Add to suggested",
"remove_suggested_account": "Remove from suggested",
"approve_account": "Approve",
"confirm_account": "Confirm",
"show_statuses": "Show all posts",
"disable_mfa": "Disable MFA",
"force_nsfw": "Mark all posts as NSFW",
"strip_media": "Remove media from posts",
"force_unlisted": "Force posts to be unlisted",
"sandbox": "Force posts to be followers-only",
"disable_remote_subscription": "Disallow following user from remote instances",
"disable_any_subscription": "Disallow following user at all",
"quarantine": "Disallow user posts from federating",
"require_password_change": "Require Password Change",
"resend_confirmation": "Resend Confirmation Email",
"confirm_modal": {
"delete_title": "User deletion",
"delete_content": "Delete user {user}? | Delete {count} users?",
"delete_content_2": "This will permanently delete the data from this accounts and deactivate it. Are you absolutely sure?",
"activate_title": "User activation",
"activate_content": "Activate user {user}? | Activate {count} users?",
"deactivate_content": "Dectivate user {user}? | Dectivate {count} users?",
"approval_title": "Approve users",
"approval_content": "Approve user {user}? | Approve {count} users?",
"confirm_title": "Confirm users",
"confirm_content": "Approve user {user}? | Approve {count} users?",
"suggest_title": "Suggest users",
"add_suggest_content": "Add user {user} to suggested users list? | Add {count} users to suggested users list?",
"remove_suggest_content": "Remove user {user} from suggested users list? | Add {count} users to suggested users list?",
"rights_title": "Promote users",
"grant_rights_content": "Grant user {user} {name} role? | Grant {count} users {name} role?",
"revoke_rights_content": "Revoke {name} role from user {user}? | Revoke {name} from {count} users?",
"tag_title": "Assign user policy",
"assign_tag_content": "Assign {user} a {name} policy? | Assign {name} policy to {count} users?",
"unassign_tag_content": "Unassign policy {name} from {user}? | Unassign policy {name} from {count} users?",
"resend_confirmation_title": "Email confirmation resend",
"resend_confirmation_content": "Resend confirmation email to {count} users?",
"disable_mfa_title": "Disable MFA",
"disable_mfa_content": "Disable Mult-Factor Authentication for {count} users?",
"require_password_change_title": "Force password change",
"require_password_change_content": "Force {count} users to change password on next login?",
"add": "Add",
"remove": "Remove",
"delete": "Delete",
"activate": "Activate",
"deactivate": "Deactivate",
"grant": "Grant",
"revoke": "Revoke",
"approve": "Approve",
"confirm": "Confirm",
"assign": "Assign",
"unassign": "Unassign",
"send": "Send"
}
},
"highlight_new": {
"disabled": "Don't highlight",
"solid": "Solid background",
"striped": "Striped background",
"side": "Side stripe"
},
"personal_note": "Personal note",
"note_blank_click": "Click to add note",
"highlight_header": "Highlight user's posts and mentions",
"tags": {
"mrf_tag:media-force-nsfw": "Mark as sensitive",
"mrf_tag:media-strip": "Remove attachments",
"mrf_tag:force-unlisted": "Force unlisted",
"mrf_tag:sandbox": "Remove from public timelines",
"mrf_tag:disable-remote-subscription": "Reject non-local follow requests",
"mrf_tag:disable-any-subscription": "Reject any follow requests"
}
},
"user_profile": {
"timeline_title": "User timeline",
"profile_does_not_exist": "Sorry, this profile does not exist.",
"profile_loading_error": "Sorry, there was an error loading this profile."
},
"user_reporting": {
"title": "Reporting {0}",
"add_comment_description": "The report will be sent to your instance moderators. You can provide an explanation of why you are reporting this account below:",
"additional_comments": "Additional comments",
"forward_description": "The account is from another server. Send a copy of the report there as well?",
"forward_to": "Forward to {0}",
"submit": "Submit",
"generic_error": "An error occurred while processing your request."
},
"who_to_follow": {
"more": "More",
"who_to_follow": "Who to follow"
},
"tool_tip": {
"media_upload": "Upload media",
"mentions": "Mentions",
"repeat": "Repeat",
"unrepeat": "Unrepeat",
"reply": "Reply",
"favorite": "Favorite",
"unfavorite": "Unfavorite",
"add_reaction": "Add Reaction",
"add_quote": "Add quote",
"user_settings": "User Settings",
"accept_follow_request": "Accept follow request",
"reject_follow_request": "Reject follow request",
"bookmark": "Bookmark",
"toggle_expand": "Expand or collapse notification to show post in full",
"toggle_mute": "Expand or collapse notification to reveal muted content",
"autocomplete_available": "{number} result is available. Use up and down keys to navigate through them. | {number} results are available. Use up and down keys to navigate through them."
},
"upload": {
"error": {
"base": "Upload failed.",
"message": "Upload failed: {0}",
"file_too_big": "File too big [{filesize}{filesizeunit} / {allowedsize}{allowedsizeunit}]",
"default": "Try again later"
},
"file_size_units": {
"B": "B",
"KiB": "KiB",
"MiB": "MiB",
"GiB": "GiB",
"TiB": "TiB"
}
},
"search": {
"people": "People",
"hashtags": "Hashtags",
"person_talking": "{count} person talking",
"people_talking": "{count} people talking",
"no_results": "No results",
"no_more_results": "No more results",
"load_more": "Load more results"
},
"password_reset": {
"forgot_password": "Forgot password?",
"password_reset": "Password reset",
"instruction": "Enter your email address or username. We will send you a link to reset your password.",
"placeholder": "Your email or username",
"check_email": "Check your email for a link to reset your password.",
"return_home": "Return to the home page",
"too_many_requests": "You have reached the limit of attempts, try again later.",
"password_reset_disabled": "Password reset is disabled. Please contact your instance administrator.",
"password_reset_required": "You must reset your password to log in.",
"password_reset_required_but_mailer_is_disabled": "You must reset your password, but password reset is disabled. Please contact your instance administrator."
},
"chats": {
"you": "You:",
"message_user": "Message {nickname}",
"delete": "Delete",
"chats": "Chats",
"new": "New Chat",
"empty_message_error": "Cannot post empty message",
"more": "More",
"delete_confirm": "Do you really want to delete this message?",
"error_loading_chat": "Something went wrong when loading the chat.",
"error_sending_message": "Something went wrong when sending the message.",
"empty_chat_list_placeholder": "You don't have any chats yet. Start a new chat!"
},
"bookmarks": {
"manage_bookmark_folders": "Manage bookmark folders"
},
"lists": {
"lists": "Lists",
"new": "New List",
"title": "List title",
"search": "Search users",
"create": "Create",
"save": "Save changes",
"delete": "Delete list",
"following_only": "Limit to Following",
"manage_lists": "Manage lists",
"manage_members": "Manage list members",
"add_members": "Search for more users",
"remove_from_list": "Remove from list",
"add_to_list": "Add to list",
"is_in_list": "Already in list",
"editing_list": "Editing list {listTitle}",
"creating_list": "Creating new list",
"update_title": "Save Title",
"really_delete": "Really delete list?",
"error": "Error manipulating lists: {0}"
},
"file_type": {
"audio": "Audio",
"video": "Video",
"image": "Image",
"file": "File"
},
"display_date": {
"today": "Today"
},
"update": {
"big_update_title": "Please bear with us",
"big_update_content": "We haven't had a release in a while, so things might look and feel different than what you're used to.",
"big_update_content2": "We implemented synchronized settings! This means (nearly) all your settings are now properly synchronized between devices and sessions. We try to migrate settings from old config but migration is performed only once, so some settings might be incorrect.",
"update_bugs": "Please report any issues and bugs on {pleromaForgejo}, as we have changed a lot, and although we test thoroughly and use development versions ourselves, we may have missed some things. We welcome your feedback and suggestions on issues you might encounter, or how to improve Pleroma and Pleroma-FE.",
"update_bugs2": "Let us know if you don't want certain setting synchronized on {pleromaForgejo}.",
"update_bugs_gitlab": "Pleroma GitLab",
"update_bugs_forgejo": "Pleroma Forgejo",
"update_changelog": "For more details on what's changed, see {theFullChangelog}.",
"update_changelog_here": "the full changelog",
"art_by": "Art by {linkToArtist}"
},
"unicode_domain_indicator": {
"tooltip": "This domain contains non-ascii characters."
},
"drafts": {
"drafts": "Drafts",
"no_drafts": "You have no drafts",
"clean_drafts": "Remove all drafts",
"empty": "(No content)",
"poll_tooltip": "Draft contains a poll",
"continue": "Continue composing",
"save": "Save without posting",
"abandon": "Abandon draft",
"abandon_confirm_title": "Abandon confirmation",
"abandon_confirm": "Do you really want to abandon this draft?",
"abandon_confirm_accept_button": "Abandon",
"abandon_confirm_cancel_button": "Keep",
"abandon_all_confirm": "Do you really want to abandon all drafts?",
"replying": "Replying to {statusLink}",
"editing": "Editing {statusLink}",
"unavailable": "(unavailable)"
},
"splash": {
"loading": "Loading...",
"theme": "Applying theme, please wait warmly...",
"fun_1": "Drink more water",
"fun_2": "Take it easy!",
"fun_3": "Suya...",
"fun_4": "My Pleroma machine is full power!",
"error": "Something went wrong"
},
"bookmark_folders": {
"select_folder": "Select bookmark folder",
"creating_folder": "Creating bookmark folder",
"editing_folder": "Editing folder {folderName}",
"emoji": "Emoji",
"name": "Folder name",
"new": "New Folder",
"create": "Create folder",
"delete": "Delete folder",
"update_folder": "Save changes",
"really_delete": "Do you really want to delete the folder?",
"error": "Error manipulating bookmark folders: {0}"
}
}
diff --git a/src/i18n/messages.js b/src/i18n/messages.js
index 76d6b53860..51b8d920cd 100644
--- a/src/i18n/messages.js
+++ b/src/i18n/messages.js
@@ -1,58 +1,58 @@
// When contributing, please sort JSON before committing so it would be easier to see what's missing and what's being added compared to English and other languages. It's not obligatory, but just an advice.
// To sort json use jq https://stedolan.github.io/jq and invoke it like `jq -S . xx.json > xx.sorted.json`, AFAIK, there's no inplace edit option like in sed
// Also, when adding a new language to "messages" variable, please do it alphabetically by language code so that users can search or check their custom language easily.
// For anyone contributing to old huge messages.js and in need to quickly convert it to JSON
// sed command for converting currently formatted JS to JSON:
// sed -i -e "s/'//gm" -e 's/"/\\"/gm' -re 's/^( +)(.+?): ((.+?))?(,?)(\{?)$/\1"\2": "\4"/gm' -e 's/\"\{\"/{/g' -e 's/,"$/",/g' file.json
// There's only problem that apostrophe character ' gets replaced by \\ so you have to fix it manually, sorry.
import { isEqual } from 'lodash'
import enMessages from './en.json'
import { langCodeToJsonName, languages } from './languages.js'
const ULTIMATE_FALLBACK_LOCALE = 'en'
const hasLanguageFile = (code) => languages.includes(code)
const languageFileMap = import.meta.glob(['./*.json', '!./en.json'])
-const loadLanguageFile = (code) => {
+const loadLanguageFile = async (code) => {
const jsonName = langCodeToJsonName(code)
- if (jsonName === 'en') return Promise.resolve({ default: enMessages })
+ if (jsonName === 'en') return { default: enMessages }
return languageFileMap[`./${jsonName}.json`]()
}
const messages = {
languages,
default: {
en: enMessages,
},
setLanguage: async (i18n, language) => {
const languages = (Array.isArray(language) ? language : [language]).filter(
(k) => k,
)
if (!languages.includes(ULTIMATE_FALLBACK_LOCALE)) {
languages.push(ULTIMATE_FALLBACK_LOCALE)
}
const [first, ...rest] = languages
if (first === i18n.locale && isEqual(rest, i18n.fallbackLocale)) {
return
}
for (const lang of languages) {
if (hasLanguageFile(lang)) {
const messages = await loadLanguageFile(lang)
i18n.setLocaleMessage(lang, messages.default)
}
}
i18n.fallbackLocale = rest
i18n.locale = first
},
}
export default messages
diff --git a/src/lib/persisted_state.js b/src/lib/persisted_state.js
index f6375dfede..5fcf259aa5 100644
--- a/src/lib/persisted_state.js
+++ b/src/lib/persisted_state.js
@@ -1,264 +1,264 @@
import { cloneDeep, each, get, merge, set } from 'lodash'
import { storage } from './storage.js'
import { useInterfaceStore } from 'src/stores/interface'
let loaded = false
const defaultReducer = (state, paths) =>
paths.length === 0
? state
: paths.reduce((substate, path) => {
set(substate, path, get(state, path))
return substate
}, {})
const saveImmedeatelyActions = [
'markNotificationsAsSeen',
'clearCurrentUser',
'setCurrentUser',
'setHighlight',
'setOption',
'setClientData',
'setToken',
'clearToken',
]
const defaultStorage = (() => {
return storage
})()
export default function createPersistedState({
key = 'vuex-lz',
paths = [],
getState = (key, storage) => {
const value = storage.getItem(key)
return value
},
setState = (key, state, storage) => {
if (!loaded) {
console.info('waiting for old state to be loaded...')
return Promise.resolve()
} else {
return storage.setItem(key, state)
}
},
reducer = defaultReducer,
storage = defaultStorage,
subscriber = (store) => (handler) => store.subscribe(handler),
} = {}) {
return getState(key, storage).then((savedState) => {
return (store) => {
try {
if (savedState !== null && typeof savedState === 'object') {
// build user cache
const usersState = savedState.users || {}
usersState.usersObject = {}
const users = usersState.users || []
each(users, (user) => {
usersState.usersObject[user.id] = user
})
savedState.users = usersState
store.replaceState(merge({}, store.state, savedState))
}
loaded = true
} catch (e) {
console.error("Couldn't load state")
console.error(e)
loaded = true
}
subscriber(store)((mutation, state) => {
try {
if (saveImmedeatelyActions.includes(mutation.type)) {
setState(key, reducer(cloneDeep(state), paths), storage).then(
(success) => {
- if (typeof success !== 'undefined') {
+ if (success !== undefined) {
if (
mutation.type === 'setOption' ||
mutation.type === 'setCurrentUser'
) {
useInterfaceStore().settingsSaved({ success })
}
}
},
(error) => {
if (
mutation.type === 'setOption' ||
mutation.type === 'setCurrentUser'
) {
useInterfaceStore().settingsSaved({ error })
}
},
)
}
} catch (e) {
console.error("Couldn't persist state:")
console.error(e)
}
})
}
})
}
/**
* This persists state for pinia, which falls back to read from the vuex state
* if pinia persisted state does not exist.
*
* When you migrate a module from vuex to pinia, you have to keep the original name.
* If the module was called `xxx`, the name of the store has to be `xxx` too.
*
* This adds one property to the store, $persistLoaded, which is a promise
* that resolves when the initial state is loaded. If the plugin is not enabled,
* $persistLoaded is a promise that resolves immediately.
* If we are not able to get the stored state because storage.getItem() throws or
* rejects, $persistLoaded will be a rejected promise with the thrown error.
*
* Call signature:
*
* defineStore(name, {
* ...,
* // setting the `persist` property enables this plugin
* // IMPORTANT: by default it is disabled, you have to set `persist` to at least an empty object
* persist: {
* // set to list of individual paths, or undefined/unset to persist everything
* paths: [],
* // function to call after loading initial state
* // if afterLoad is a function, it must return a state object that will be sent to `store.$patch`, or a promise to the state object
* // by default afterLoad is undefined
* afterLoad: (originalState) => {
* // ...
* return modifiedState
* },
* // if it exists, only persist state after these actions
* // if it doesn't exist or is undefined, persist state after every mutation of the state
* saveImmediatelyActions: [],
* // what to do after successfully saving the state
* onSaveSuccess: () => {},
* // what to do after there is an error saving the state
* onSaveError: () => {}
* }
* })
*
*/
export const piniaPersistPlugin =
({
vuexKey = 'vuex-lz',
keyFunction = (id) => `pinia-local-${id}`,
storage = defaultStorage,
reducer = defaultReducer,
} = {}) =>
({ store, options }) => {
if (!options.persist) {
return {
$persistLoaded: Promise.resolve(),
}
}
let resolveLoaded
let rejectLoaded
const loadedPromise = new Promise((resolve, reject) => {
resolveLoaded = resolve
rejectLoaded = reject
})
const {
afterLoad,
paths = [],
saveImmediatelyActions,
onSaveSuccess = () => {
/* no-op */
},
onSaveError = () => {
/* no-op */
},
} = options.persist || {}
const loadedGuard = { loaded: false }
const key = keyFunction(store.$id)
const getState = async () => {
const id = store.$id
const value = await storage.getItem(key)
if (value) {
return value
}
const fallbackValue = await storage.getItem(vuexKey)
- if (fallbackValue && fallbackValue[id]) {
+ if (fallbackValue?.[id]) {
console.info(`Migrating ${id} store data from vuex to pinia`)
const res = fallbackValue[id]
await storage.setItem(key, res)
return res
}
return {}
}
const setState = (state) => {
if (!loadedGuard.loaded) {
console.info('waiting for old state to be loaded...')
- return Promise.reject()
+ throw new Error('Waiting')
} else {
return storage.setItem(key, state)
}
}
const getMaybeAugmentedState = async () => {
const savedRawState = await getState()
if (typeof afterLoad === 'function') {
try {
return await afterLoad(savedRawState)
} catch (e) {
console.error('Error running afterLoad:', e)
return savedRawState
}
} else {
return savedRawState
}
}
const persistCurrentState = async (state) => {
const stateClone = cloneDeep(state)
const stateToPersist = reducer(stateClone, paths)
try {
const res = await setState(stateToPersist)
onSaveSuccess(res)
} catch (e) {
console.error('Cannot persist state:', e)
onSaveError(e)
}
}
getMaybeAugmentedState().then(
(savedState) => {
if (savedState) {
store.$patch(savedState)
}
loadedGuard.loaded = true
resolveLoaded()
// only subscribe after we have done setting the initial state
if (!saveImmediatelyActions) {
store.$subscribe(async (_mutation, state) => {
await persistCurrentState(state)
})
} else {
store.$onAction(({ name, store, after }) => {
if (saveImmediatelyActions.includes(name)) {
after(() => persistCurrentState(store.$state))
}
})
}
},
(error) => {
console.error('Cannot load storage:', error)
rejectLoaded(error)
},
)
return {
$persistLoaded: loadedPromise,
}
}
diff --git a/src/modules/api.js b/src/modules/api.js
index 3d223d6ccd..6d0c41d48e 100644
--- a/src/modules/api.js
+++ b/src/modules/api.js
@@ -1,345 +1,345 @@
import { Socket } from 'phoenix'
import { maybeShowChatNotification } from '../services/chat_utils/chat_utils.js'
import { useChatsStore } from 'src/stores/chats.js'
import { useInstanceCapabilitiesStore } from 'src/stores/instance_capabilities.js'
import { useInterfaceStore } from 'src/stores/interface.js'
import { useOAuthStore } from 'src/stores/oauth.js'
import { useShoutStore } from 'src/stores/shout.js'
import { fetchTimeline } from 'src/api/timelines.js'
import {
getMastodonSocketURI,
ProcessedWS,
WSConnectionStatus,
} from 'src/api/websocket.js'
import followRequestFetcher from 'src/services/follow_request_fetcher/follow_request_fetcher.service'
import notificationsFetcher from 'src/services/notifications_fetcher/notifications_fetcher.service.js'
import timelineFetcher from 'src/services/timeline_fetcher/timeline_fetcher.service.js'
const retryTimeout = (multiplier) => 1000 * multiplier
const api = {
state: {
retryMultiplier: 1,
fetchers: {},
socket: null,
mastoUserSocket: null,
mastoUserSocketStatus: null,
followRequests: [],
},
getters: {
followRequestCount: (state) => state.followRequests.length,
},
mutations: {
addFetcher(state, { fetcherName, fetcher }) {
state.fetchers[fetcherName] = fetcher
},
removeFetcher(state, { fetcherName }) {
state.fetchers[fetcherName].stop()
delete state.fetchers[fetcherName]
},
setWsToken(state, token) {
state.wsToken = token
},
setSocket(state, socket) {
state.socket = socket
},
setFollowRequests(state, value) {
state.followRequests = value
},
setMastoUserSocketStatus(state, value) {
state.mastoUserSocketStatus = value
},
incrementRetryMultiplier(state) {
state.retryMultiplier = Math.max(++state.retryMultiplier, 3)
},
resetRetryMultiplier(state) {
state.retryMultiplier = 1
},
},
actions: {
/**
* Global MastoAPI socket control, in future should disable ALL sockets/(re)start relevant sockets
*
* @param {Boolean} [initial] - whether this enabling happened at boot time or not
*/
enableMastoSockets(store, initial) {
const { state, dispatch, commit } = store
// Do not initialize unless nonexistent or closed
if (
state.mastoUserSocket &&
![WebSocket.CLOSED, WebSocket.CLOSING].includes(
state.mastoUserSocket.getState(),
)
) {
return
}
if (initial) {
commit('setMastoUserSocketStatus', WSConnectionStatus.STARTING_INITIAL)
} else {
commit('setMastoUserSocketStatus', WSConnectionStatus.STARTING)
}
return dispatch('startMastoUserSocket')
},
disableMastoSockets(store) {
const { state, dispatch, commit } = store
if (!state.mastoUserSocket) return
commit('setMastoUserSocketStatus', WSConnectionStatus.DISABLED)
return dispatch('stopMastoUserSocket')
},
// MastoAPI 'User' sockets
startMastoUserSocket(store) {
return new Promise((resolve, reject) => {
try {
const { state, commit, dispatch, rootState } = store
const timelineData = rootState.statuses.timelines.friends
const credentials = useOAuthStore().token
const url = getMastodonSocketURI({ credentials })
state.mastoUserSocket = ProcessedWS({
url,
id: 'Unified',
credentials,
})
state.mastoUserSocket.addEventListener(
'pleroma:authenticated',
() => {
state.mastoUserSocket.subscribe('user')
},
)
state.mastoUserSocket.addEventListener(
'message',
({ detail: message }) => {
if (!message) return // pings
if (message.event === 'notification') {
dispatch('addNewNotifications', {
notifications: [message.notification],
older: false,
})
} else if (message.event === 'update') {
dispatch('addNewStatuses', {
statuses: [message.status],
userId: false,
showImmediately: timelineData.visibleStatuses.length === 0,
timeline: 'friends',
})
} else if (message.event === 'status.update') {
dispatch('addNewStatuses', {
statuses: [message.status],
userId: false,
showImmediately:
message.status.id in timelineData.visibleStatusesObject,
timeline: 'friends',
})
} else if (message.event === 'delete') {
dispatch('deleteStatusById', message.id)
} else if (message.event === 'pleroma:chat_update') {
// The setTimeout wrapper is a temporary band-aid to avoid duplicates for the user's own messages when doing optimistic sending.
// The cause of the duplicates is the WS event arriving earlier than the HTTP response.
// This setTimeout wrapper can be removed once the commit `8e41baff` is in the stable Pleroma release.
// (`8e41baff` adds the idempotency key to the chat message entity, which PleromaFE uses when it's available, and it makes this artificial delay unnecessary).
setTimeout(() => {
dispatch('addChatMessages', {
chatId: message.chatUpdate.id,
messages: [message.chatUpdate.lastMessage],
})
dispatch('updateChat', { chat: message.chatUpdate })
maybeShowChatNotification(store, message.chatUpdate)
}, 100)
}
},
)
state.mastoUserSocket.addEventListener('open', () => {
// Do not show notification when we just opened up the page
if (
state.mastoUserSocketStatus !==
WSConnectionStatus.STARTING_INITIAL
) {
useInterfaceStore().pushGlobalNotice({
level: 'success',
messageKey: 'timeline.socket_reconnected',
timeout: 5000,
})
}
// Stop polling if we were errored or disabled
if (
new Set([
WSConnectionStatus.ERROR,
WSConnectionStatus.DISABLED,
]).has(state.mastoUserSocketStatus)
) {
dispatch('stopFetchingTimeline', { timeline: 'friends' })
dispatch('stopFetchingNotifications')
useChatsStore().stopFetchingChats()
}
commit('resetRetryMultiplier')
commit('setMastoUserSocketStatus', WSConnectionStatus.JOINED)
})
state.mastoUserSocket.addEventListener(
'error',
({ detail: error }) => {
console.error('Error in MastoAPI websocket:', error)
// TODO is this needed?
dispatch('clearOpenedChats')
},
)
state.mastoUserSocket.addEventListener(
'close',
({ detail: closeEvent }) => {
const ignoreCodes = new Set([
1000, // Normal (intended) closure
1001, // Going away
])
const { code } = closeEvent
if (ignoreCodes.has(code)) {
console.debug(
`Not restarting socket becasue of closure code ${code} is in ignore list`,
)
commit('setMastoUserSocketStatus', WSConnectionStatus.CLOSED)
} else {
console.warn(
`MastoAPI websocket disconnected, restarting. CloseEvent code: ${code}`,
)
setTimeout(() => {
dispatch('startMastoUserSocket')
}, retryTimeout(state.retryMultiplier))
commit('incrementRetryMultiplier')
if (state.mastoUserSocketStatus !== WSConnectionStatus.ERROR) {
dispatch('startFetchingTimeline', { timeline: 'friends' })
dispatch('startFetchingNotifications')
useChatsStore().startFetchingChats()
useInterfaceStore().pushGlobalNotice({
level: 'error',
messageKey: 'timeline.socket_broke',
messageArgs: [code],
timeout: 5000,
})
}
commit('setMastoUserSocketStatus', WSConnectionStatus.ERROR)
}
dispatch('clearOpenedChats')
},
)
resolve()
} catch (e) {
reject(e)
}
})
},
stopMastoUserSocket({ state, dispatch }) {
dispatch('startFetchingTimeline', { timeline: 'friends' })
dispatch('startFetchingNotifications')
useChatsStore().startFetchingChats()
state.mastoUserSocket.close()
},
// Timelines
startFetchingTimeline(
store,
{
timeline = 'friends',
tag = false,
userId = false,
listId = false,
statusId = false,
bookmarkFolderId = false,
},
) {
if (
timeline === 'favourites' &&
!useInstanceCapabilitiesStore().pleromaPublicFavouritesAvailable
)
return
if (store.state.fetchers[timeline]) return
const fetcher = timelineFetcher.startFetching({
timeline,
store,
userId,
listId,
statusId,
bookmarkFolderId,
tag,
credentials: useOAuthStore().token,
})
store.commit('addFetcher', { fetcherName: timeline, fetcher })
},
stopFetchingTimeline(store, timeline) {
const fetcher = store.state.fetchers[timeline]
if (!fetcher) return
store.commit('removeFetcher', { fetcherName: timeline, fetcher })
},
fetchTimeline(store, { timeline, ...rest }) {
fetchTimeline({
store,
timeline,
...rest,
credentials: useOAuthStore().token,
})
},
// Notifications
startFetchingNotifications(store) {
if (store.state.fetchers.notifications) return
const fetcher = notificationsFetcher.startFetching({
store,
credentials: useOAuthStore().token,
})
store.commit('addFetcher', { fetcherName: 'notifications', fetcher })
},
stopFetchingNotifications(store) {
const fetcher = store.state.fetchers.notifications
if (!fetcher) return
store.commit('removeFetcher', { fetcherName: 'notifications', fetcher })
},
// Follow requests
startFetchingFollowRequests(store) {
if (store.state.fetchers.followRequests) return
const fetcher = followRequestFetcher.startFetching({
store,
credentials: useOAuthStore().token,
})
store.commit('addFetcher', { fetcherName: 'followRequests', fetcher })
},
stopFetchingFollowRequests(store) {
const fetcher = store.state.fetchers.followRequests
if (!fetcher) return
store.commit('removeFetcher', { fetcherName: 'followRequests', fetcher })
},
// Pleroma websocket
setWsToken(store, token) {
store.commit('setWsToken', token)
},
initializeSocket({ commit, state, rootState }) {
// Set up websocket connection
const token = state.wsToken
if (
useInstanceCapabilitiesStore().shoutAvailable &&
- typeof token !== 'undefined' &&
+ token !== undefined &&
state.socket === null
) {
const socket = new Socket('/socket', { params: { token } })
socket.connect()
commit('setSocket', socket)
useShoutStore().initializeShout(socket)
}
},
disconnectFromSocket({ commit, state }) {
- state.socket && state.socket.disconnect()
+ state.socket?.disconnect()
commit('setSocket', null)
},
},
}
export default api
diff --git a/src/modules/statuses.js b/src/modules/statuses.js
index 03469a68c4..a41213d654 100644
--- a/src/modules/statuses.js
+++ b/src/modules/statuses.js
@@ -1,901 +1,901 @@
import {
each,
find,
findIndex,
first,
last,
maxBy,
merge,
minBy,
omitBy,
remove,
} from 'lodash'
import { useInstanceCapabilitiesStore } from 'src/stores/instance_capabilities.js'
import { useInterfaceStore } from 'src/stores/interface.js'
import { useOAuthStore } from 'src/stores/oauth.js'
import {
fetchEmojiReactions,
fetchFavoritedByUsers,
fetchPinnedStatuses,
fetchRebloggedByUsers,
fetchScrobbles,
fetchStatus,
fetchStatusHistory,
fetchStatusSource,
search2,
} from 'src/api/public.js'
import {
bookmarkStatus,
deleteStatus,
favorite,
muteConversation,
pinOwnStatus,
reactWithEmoji,
retweet,
unbookmarkStatus,
unfavorite,
unmuteConversation,
unpinOwnStatus,
unreactWithEmoji,
unretweet,
} from 'src/api/user.js'
const emptyTl = (userId = 0) => ({
statuses: [],
statusesObject: {},
faves: [],
visibleStatuses: [],
visibleStatusesObject: {},
newStatusCount: 0,
- maxId: 0,
- minId: 0,
+ maxId: '',
+ minId: '',
minVisibleId: 0,
loading: false,
followers: [],
friends: [],
userId,
flushMarker: 0,
})
export const defaultState = () => ({
allStatuses: [],
scrobblesNextFetch: {},
allStatusesObject: {},
conversationsObject: {},
- maxId: 0,
+ maxId: '',
favorites: new Set(),
timelines: {
mentions: emptyTl(),
public: emptyTl(),
user: emptyTl(),
favorites: emptyTl(),
media: emptyTl(),
publicAndExternal: emptyTl(),
friends: emptyTl(),
tag: emptyTl(),
dms: emptyTl(),
bookmarks: emptyTl(),
list: emptyTl(),
bubble: emptyTl(),
},
})
export const prepareStatus = (status) => {
// Set deleted flag
status.deleted = false
// To make the array reactive
status.attachments = status.attachments || []
return status
}
const mergeOrAdd = (arr, obj, item) => {
const oldItem = obj[item.id]
if (oldItem) {
// We already have this, so only merge the new info.
// We ignore null values to avoid overwriting existing properties with missing data
// we also skip 'user' because that is handled by users module
merge(
oldItem,
omitBy(item, (v, k) => v === null || k === 'user'),
)
// Reactivity fix.
oldItem.attachments.splice(oldItem.attachments.length)
return { item: oldItem, new: false }
} else {
// This is a new item, prepare it
prepareStatus(item)
arr.push(item)
obj[item.id] = item
return { item, new: true }
}
}
const sortById = (a, b) => {
const seqA = Number(a.id)
const seqB = Number(b.id)
const isSeqA = !Number.isNaN(seqA)
const isSeqB = !Number.isNaN(seqB)
if (isSeqA && isSeqB) {
return seqA > seqB ? -1 : 1
} else if (isSeqA && !isSeqB) {
return 1
} else if (!isSeqA && isSeqB) {
return -1
} else {
return a.id > b.id ? -1 : 1
}
}
const sortTimeline = (timeline) => {
timeline.visibleStatuses = timeline.visibleStatuses.sort(sortById)
timeline.statuses = timeline.statuses.sort(sortById)
- timeline.minVisibleId = (last(timeline.visibleStatuses) || {}).id
+ timeline.minVisibleId = last(timeline.visibleStatuses)?.id
return timeline
}
const getLatestScrobble = (state, user) => {
const scrobblesSupport =
useInstanceCapabilitiesStore().pleromaScrobblesAvailable
if (!scrobblesSupport || !user.name || user.id === 'undefined') {
return
}
if (
state.scrobblesNextFetch[user.id] &&
state.scrobblesNextFetch[user.id] > Date.now()
) {
return
}
state.scrobblesNextFetch[user.id] = Date.now() + 24 * 60 * 60 * 1000
if (!scrobblesSupport) return
fetchScrobbles({ accountId: user.id })
.then(({ data: scrobbles }) => {
if (scrobbles?.error) {
useInstanceCapabilitiesStore().set('pleromaScrobblesAvailable', false)
return
}
if (scrobbles.length > 0) {
user.latestScrobble = scrobbles[0]
state.scrobblesNextFetch[user.id] = Date.now() + 60 * 1000
}
})
.catch((e) => {
console.warn('cannot fetch scrobbles', e)
})
}
// Add status to the global storages (arrays and objects maintaining statuses) except timelines
const addStatusToGlobalStorage = (state, data) => {
getLatestScrobble(state, data.user)
const result = mergeOrAdd(state.allStatuses, state.allStatusesObject, data)
if (result.new) {
// Add to conversation
const status = result.item
const conversationsObject = state.conversationsObject
const conversationId = status.statusnet_conversation_id
if (conversationsObject[conversationId]) {
conversationsObject[conversationId].push(status)
} else {
conversationsObject[conversationId] = [status]
}
}
return result
}
const addNewStatuses = (
state,
{
statuses,
showImmediately = false,
timeline,
user = {},
noIdUpdate = false,
userId,
pagination = {},
},
) => {
// Sanity check
if (!Array.isArray(statuses)) {
return false
}
const allStatuses = state.allStatuses
const timelineObject = state.timelines[timeline]
// Mismatch between API pagination and our internal minId/maxId tracking systems:
// pagination.maxId is the oldest of the returned statuses when fetching older,
// and pagination.minId is the newest when fetching newer. The names come directly
// from the arguments they're supposed to be passed as for the next fetch.
const minNew =
pagination.maxId || (statuses.length > 0 ? minBy(statuses, 'id').id : 0)
const maxNew =
pagination.minId || (statuses.length > 0 ? maxBy(statuses, 'id').id : 0)
const newer =
timeline &&
- (maxNew > timelineObject.maxId || timelineObject.maxId === 0) &&
+ (maxNew > timelineObject.maxId || timelineObject.maxId === '') &&
statuses.length > 0
const older =
timeline &&
- (minNew < timelineObject.minId || timelineObject.minId === 0) &&
+ (minNew < timelineObject.minId || timelineObject.minId === '') &&
statuses.length > 0
if (!noIdUpdate && newer) {
timelineObject.maxId = maxNew
}
if (!noIdUpdate && older) {
timelineObject.minId = minNew
}
// This makes sure that user timeline won't get data meant for other
// user. I.e. opening different user profiles makes request which could
// return data late after user already viewing different user profile
if (
(timeline === 'user' || timeline === 'media') &&
timelineObject.userId !== userId
) {
return
}
const addStatus = (data, showImmediately, addToTimeline = true) => {
const result = addStatusToGlobalStorage(state, data)
const status = result.item
if (result.new) {
// We are mentioned in a post
if (
status.type === 'status' &&
find(status.attentions, { id: user.id })
) {
const mentions = state.timelines.mentions
// Add the mention to the mentions timeline
if (timelineObject !== mentions) {
mergeOrAdd(mentions.statuses, mentions.statusesObject, status)
mentions.newStatusCount += 1
sortTimeline(mentions)
}
}
if (status.visibility === 'direct') {
const dms = state.timelines.dms
mergeOrAdd(dms.statuses, dms.statusesObject, status)
dms.newStatusCount += 1
sortTimeline(dms)
}
}
// Decide if we should treat the status as new for this timeline.
let resultForCurrentTimeline
// Some statuses should only be added to the global status repository.
if (timeline && addToTimeline) {
resultForCurrentTimeline = mergeOrAdd(
timelineObject.statuses,
timelineObject.statusesObject,
status,
)
}
if (timeline && showImmediately) {
// Add it directly to the visibleStatuses, don't change
// newStatusCount
mergeOrAdd(
timelineObject.visibleStatuses,
timelineObject.visibleStatusesObject,
status,
)
} else if (timeline && addToTimeline && resultForCurrentTimeline.new) {
// Just change newStatuscount
timelineObject.newStatusCount += 1
}
if (status.quote) {
addStatus(
status.quote,
/* showImmediately = */ false,
/* addToTimeline = */ false,
)
}
return status
}
const favoriteStatus = (favorite) => {
const status = find(allStatuses, { id: favorite.in_reply_to_status_id })
if (status) {
// This is our favorite, so the relevant bit.
if (favorite.user.id === user.id) {
status.favorited = true
} else {
status.fave_num += 1
}
}
return status
}
const processors = {
status: (status) => {
addStatus(status, showImmediately)
},
edit: (status) => {
addStatus(status, showImmediately)
},
retweet: (status) => {
// RetweetedStatuses are never shown immediately
const retweetedStatus = addStatus(status.retweeted_status, false, false)
let retweet
// If the retweeted status is already there, don't add the retweet
// to the timeline.
if (
timeline &&
find(timelineObject.statuses, (s) => {
if (s.retweeted_status) {
return (
s.id === retweetedStatus.id ||
s.retweeted_status.id === retweetedStatus.id
)
} else {
return s.id === retweetedStatus.id
}
})
) {
// Already have it visible (either as the original or another RT), don't add to timeline, don't show.
retweet = addStatus(status, false, false)
} else {
retweet = addStatus(status, showImmediately)
}
retweet.retweeted_status = retweetedStatus
},
favorite: (favorite) => {
// Only update if this is a new favorite.
// Ignore our own favorites because we get info about likes as response to like request
if (!state.favorites.has(favorite.id)) {
state.favorites.add(favorite.id)
favoriteStatus(favorite)
}
},
follow: () => {
// NOOP, it is known status but we don't do anything about it for now
},
default: (unknown) => {
console.warn('unknown status type', unknown)
},
}
each(statuses, (status) => {
const type = status.type
const processor = processors[type] || processors.default
processor(status)
})
// Keep the visible statuses sorted
if (timeline && !(timeline === 'bookmarks')) {
sortTimeline(timelineObject)
}
}
const removeStatus = (state, { timeline, userId }) => {
const timelineObject = state.timelines[timeline]
if (userId) {
remove(timelineObject.statuses, { user: { id: userId } })
remove(timelineObject.visibleStatuses, { user: { id: userId } })
timelineObject.minVisibleId =
timelineObject.visibleStatuses.length > 0
? last(timelineObject.visibleStatuses).id
: 0
timelineObject.maxId =
timelineObject.statuses.length > 0 ? first(timelineObject.statuses).id : 0
}
}
export const mutations = {
addNewStatuses,
removeStatus,
showNewStatuses(state, { timeline }) {
const oldTimeline = state.timelines[timeline]
oldTimeline.newStatusCount = 0
oldTimeline.visibleStatuses = oldTimeline.statuses.slice(0, 50)
oldTimeline.minVisibleId = last(oldTimeline.visibleStatuses).id
oldTimeline.minId = oldTimeline.minVisibleId
oldTimeline.visibleStatusesObject = {}
each(oldTimeline.visibleStatuses, (status) => {
oldTimeline.visibleStatusesObject[status.id] = status
})
},
resetStatuses(state) {
const emptyState = defaultState()
Object.entries(emptyState).forEach(([key, value]) => {
state[key] = value
})
},
clearTimeline(state, { timeline, excludeUserId = false }) {
const userId = excludeUserId ? state.timelines[timeline].userId : undefined
state.timelines[timeline] = emptyTl(userId)
},
setFavorited(state, { status, value }) {
const newStatus = state.allStatusesObject[status.id]
if (newStatus.favorited !== value) {
if (value) {
newStatus.fave_num++
} else {
newStatus.fave_num--
}
}
newStatus.favorited = value
},
setFavoritedConfirm(state, { status, user }) {
const newStatus = state.allStatusesObject[status.id]
newStatus.favorited = status.favorited
newStatus.fave_num = status.fave_num
const index = findIndex(newStatus.favoritedBy, { id: user.id })
if (index !== -1 && !newStatus.favorited) {
newStatus.favoritedBy.splice(index, 1)
} else if (index === -1 && newStatus.favorited) {
newStatus.favoritedBy.push(user)
}
},
setMutedStatus(state, status) {
const newStatus = state.allStatusesObject[status.id]
newStatus.thread_muted = status.thread_muted
if (newStatus.thread_muted !== undefined) {
state.conversationsObject[newStatus.statusnet_conversation_id].forEach(
(status) => {
status.thread_muted = newStatus.thread_muted
},
)
}
},
setRetweeted(state, { status, value }) {
const newStatus = state.allStatusesObject[status.id]
if (newStatus.repeated !== value) {
if (value) {
newStatus.repeat_num++
} else {
newStatus.repeat_num--
}
}
newStatus.repeated = value
},
setRetweetedConfirm(state, { status, user }) {
const newStatus = state.allStatusesObject[status.id]
newStatus.repeated = status.repeated
newStatus.repeat_num = status.repeat_num
const index = findIndex(newStatus.rebloggedBy, { id: user.id })
if (index !== -1 && !newStatus.repeated) {
newStatus.rebloggedBy.splice(index, 1)
} else if (index === -1 && newStatus.repeated) {
newStatus.rebloggedBy.push(user)
}
},
setBookmarked(state, { status, value }) {
const newStatus = state.allStatusesObject[status.id]
newStatus.bookmarked = value
newStatus.bookmark_folder_id = status.bookmark_folder_id
},
setBookmarkedConfirm(state, { status }) {
const newStatus = state.allStatusesObject[status.id]
newStatus.bookmarked = status.bookmarked
if (status.pleroma)
newStatus.bookmark_folder_id = status.pleroma.bookmark_folder
},
setDeleted(state, { status }) {
const newStatus = state.allStatusesObject[status.id]
if (newStatus) newStatus.deleted = true
},
setManyDeleted(state, condition) {
Object.values(state.allStatusesObject).forEach((status) => {
if (condition(status)) {
status.deleted = true
}
})
},
setLoading(state, { timeline, value }) {
state.timelines[timeline].loading = value
},
setNsfw(state, { id, nsfw }) {
const newStatus = state.allStatusesObject[id]
newStatus.nsfw = nsfw
},
queueFlush(state, { timeline, id }) {
state.timelines[timeline].flushMarker = id
},
queueFlushAll(state) {
Object.keys(state.timelines).forEach((timeline) => {
state.timelines[timeline].flushMarker = state.timelines[timeline].maxId
})
},
addRepeats(state, { id, rebloggedByUsers, currentUser }) {
const newStatus = state.allStatusesObject[id]
- newStatus.rebloggedBy = rebloggedByUsers.filter((_) => _)
+ newStatus.rebloggedBy = rebloggedByUsers.filter(Boolean)
// repeats stats can be incorrect based on polling condition, let's update them using the most recent data
newStatus.repeat_num = newStatus.rebloggedBy.length
newStatus.repeated = !!newStatus.rebloggedBy.find(
({ id }) => currentUser.id === id,
)
},
addFavs(state, { id, favoritedByUsers, currentUser }) {
const newStatus = state.allStatusesObject[id]
- newStatus.favoritedBy = favoritedByUsers.filter((_) => _)
+ newStatus.favoritedBy = favoritedByUsers.filter(Boolean)
// favorites stats can be incorrect based on polling condition, let's update them using the most recent data
newStatus.fave_num = newStatus.favoritedBy.length
newStatus.favorited = !!newStatus.favoritedBy.find(
({ id }) => currentUser.id === id,
)
},
addEmojiReactionsBy(state, { id, emojiReactions }) {
const status = state.allStatusesObject[id]
status.emoji_reactions = emojiReactions
},
addOwnReaction(state, { id, emoji, currentUser }) {
const status = state.allStatusesObject[id]
const reactionIndex = findIndex(status.emoji_reactions, { name: emoji })
const reaction = status.emoji_reactions[reactionIndex] || {
name: emoji,
count: 0,
accounts: [],
}
const newReaction = {
...reaction,
count: reaction.count + 1,
me: true,
accounts: [...reaction.accounts, currentUser],
}
// Update count of existing reaction if it exists, otherwise append at the end
if (reactionIndex >= 0) {
status.emoji_reactions[reactionIndex] = newReaction
} else {
status.emoji_reactions = [...status.emoji_reactions, newReaction]
}
},
removeOwnReaction(state, { id, emoji, currentUser }) {
const status = state.allStatusesObject[id]
const reactionIndex = findIndex(status.emoji_reactions, { name: emoji })
if (reactionIndex < 0) return
const reaction = status.emoji_reactions[reactionIndex]
const accounts = reaction.accounts || []
const newReaction = {
...reaction,
count: reaction.count - 1,
me: false,
accounts: accounts.filter((acc) => acc.id !== currentUser.id),
}
if (newReaction.count > 0) {
status.emoji_reactions[reactionIndex] = newReaction
} else {
status.emoji_reactions = status.emoji_reactions.filter(
(r) => r.name !== emoji,
)
}
},
updateStatusWithPoll(state, { id, poll }) {
const status = state.allStatusesObject[id]
status.poll = poll
},
setVirtualHeight(state, { statusId, height }) {
state.allStatusesObject[statusId].virtualHeight = height
},
}
const statuses = {
state: defaultState(),
actions: {
addNewStatuses(
{ rootState, commit },
{
statuses,
showImmediately = false,
timeline = false,
noIdUpdate = false,
userId,
pagination,
},
) {
return commit('addNewStatuses', {
statuses,
showImmediately,
timeline,
noIdUpdate,
user: rootState.users.currentUser,
userId,
pagination,
})
},
fetchStatus({ rootState, dispatch }, id) {
return fetchStatus({ id }).then(({ data: status }) =>
dispatch('addNewStatuses', { statuses: [status] }),
)
},
fetchStatusSource({ rootState }, status) {
return fetchStatusSource({
id: status.id,
credentials: useOAuthStore().token,
}).then(({ data }) => data)
},
fetchStatusHistory(_, status) {
return fetchStatusHistory({ status }).then(({ data }) => data)
},
deleteStatus({ rootState, commit }, status) {
deleteStatus({
id: status.id,
credentials: useOAuthStore().token,
})
.then(() => {
commit('setDeleted', { status })
})
.catch((e) => {
useInterfaceStore().pushGlobalNotice({
level: 'error',
messageKey: 'status.delete_error',
messageArgs: [e.message],
timeout: 5000,
})
})
},
deleteStatusById({ rootState, commit }, id) {
const status = rootState.statuses.allStatusesObject[id]
commit('setDeleted', { status })
},
markStatusesAsDeleted({ commit }, condition) {
commit('setManyDeleted', condition)
},
favorite({ rootState, commit }, status) {
// Optimistic favoriting...
commit('setFavorited', { status, value: true })
favorite({
id: status.id,
credentials: useOAuthStore().token,
}).then(({ data: status }) =>
commit('setFavoritedConfirm', {
status,
user: rootState.users.currentUser,
}),
)
},
unfavorite({ rootState, commit }, status) {
// Optimistic unfavoriting...
commit('setFavorited', { status, value: false })
unfavorite({
id: status.id,
credentials: useOAuthStore().token,
}).then(({ data: status }) =>
commit('setFavoritedConfirm', {
status,
user: rootState.users.currentUser,
}),
)
},
fetchPinnedStatuses({ rootState, dispatch }, userId) {
fetchPinnedStatuses({
id: userId,
credentials: useOAuthStore().token,
}).then(({ data: statuses }) =>
dispatch('addNewStatuses', {
statuses,
timeline: 'user',
userId,
showImmediately: true,
noIdUpdate: true,
}),
)
},
pinStatus({ rootState, dispatch }, statusId) {
return pinOwnStatus({
id: statusId,
credentials: useOAuthStore().token,
}).then(({ data: status }) =>
dispatch('addNewStatuses', { statuses: [status] }),
)
},
unpinStatus({ rootState, dispatch }, statusId) {
return unpinOwnStatus({
id: statusId,
credentials: useOAuthStore().token,
}).then(({ data: status }) =>
dispatch('addNewStatuses', { statuses: [status] }),
)
},
muteConversation({ rootState, commit }, { id: statusId }) {
return muteConversation({
id: statusId,
credentials: useOAuthStore().token,
}).then(({ data: status }) => commit('setMutedStatus', status))
},
unmuteConversation({ rootState, commit }, { id: statusId }) {
return unmuteConversation({
id: statusId,
credentials: useOAuthStore().token,
}).then(({ data: status }) => commit('setMutedStatus', status))
},
retweet({ rootState, commit }, status) {
// Optimistic retweeting...
commit('setRetweeted', { status, value: true })
retweet({
id: status.id,
credentials: useOAuthStore().token,
}).then(({ data: status }) =>
commit('setRetweetedConfirm', {
status: status.retweeted_status,
user: rootState.users.currentUser,
}),
)
},
unretweet({ rootState, commit }, status) {
// Optimistic unretweeting...
commit('setRetweeted', { status, value: false })
unretweet({
id: status.id,
credentials: useOAuthStore().token,
}).then(({ data: status }) =>
commit('setRetweetedConfirm', {
status,
user: rootState.users.currentUser,
}),
)
},
bookmark({ rootState, commit }, status) {
commit('setBookmarked', { status, value: true })
bookmarkStatus({
id: status.id,
folder_id: status.bookmark_folder_id,
credentials: useOAuthStore().token,
}).then(({ data: status }) => {
commit('setBookmarkedConfirm', { status })
})
},
unbookmark({ rootState, commit }, status) {
commit('setBookmarked', { status, value: false })
unbookmarkStatus({
id: status.id,
credentials: useOAuthStore().token,
}).then(({ data: status }) => {
commit('setBookmarkedConfirm', { status })
})
},
queueFlush({ commit }, { timeline, id }) {
commit('queueFlush', { timeline, id })
},
queueFlushAll({ commit }) {
commit('queueFlushAll')
},
fetchFavsAndRepeats({ rootState, commit }, id) {
Promise.all([
fetchFavoritedByUsers({
id,
credentials: useOAuthStore().token,
}).then(({ data }) => data),
fetchRebloggedByUsers({
id,
credentials: useOAuthStore().token,
}).then(({ data }) => data),
]).then(([favoritedByUsers, rebloggedByUsers]) => {
commit('addFavs', {
id,
favoritedByUsers,
currentUser: rootState.users.currentUser,
})
commit('addRepeats', {
id,
rebloggedByUsers,
currentUser: rootState.users.currentUser,
})
})
},
reactWithEmoji({ rootState, dispatch, commit }, { id, emoji }) {
const currentUser = rootState.users.currentUser
if (!currentUser) return
commit('addOwnReaction', { id, emoji, currentUser })
reactWithEmoji({
id,
emoji,
credentials: useOAuthStore().token,
}).then(() => {
dispatch('fetchEmojiReactionsBy', id)
})
},
unreactWithEmoji({ rootState, dispatch, commit }, { id, emoji }) {
const currentUser = rootState.users.currentUser
if (!currentUser) return
commit('removeOwnReaction', { id, emoji, currentUser })
unreactWithEmoji({
id,
emoji,
currentUser: rootState.users.currentUser,
}).then(() => {
dispatch('fetchEmojiReactionsBy', id)
})
},
fetchEmojiReactionsBy({ rootState, commit }, id) {
return fetchEmojiReactions({
id,
credentials: useOAuthStore().token,
}).then(({ data: emojiReactions }) => {
commit('addEmojiReactionsBy', {
id,
emojiReactions,
currentUser: rootState.users.currentUser,
})
})
},
fetchFavs({ rootState, commit }, id) {
fetchFavoritedByUsers({
id,
credentials: useOAuthStore().token,
}).then(({ data: favoritedByUsers }) =>
commit('addFavs', {
id,
favoritedByUsers,
currentUser: rootState.users.currentUser,
}),
)
},
fetchRepeats({ rootState, commit }, id) {
fetchRebloggedByUsers({
id,
credentials: useOAuthStore().token,
}).then(({ data: rebloggedByUsers }) =>
commit('addRepeats', {
id,
rebloggedByUsers,
currentUser: rootState.users.currentUser,
}),
)
},
search(store, { q, resolve, limit, offset, following, type }) {
return search2({
q,
resolve,
limit,
offset,
following,
type,
credentials: useOAuthStore().token,
}).then(({ data }) => {
store.commit('addNewUsers', data.accounts)
store.commit(
'addNewUsers',
- data.statuses.map((s) => s.user).filter((u) => u),
+ data.statuses.map((s) => s.user).filter(Boolean),
)
store.dispatch('addNewStatuses', {
statuses: data.statuses,
})
data.statuses = data.statuses.map(
(s) => store.state.allStatusesObject[s.id],
)
return data
})
},
setVirtualHeight({ commit }, { statusId, height }) {
commit('setVirtualHeight', { statusId, height })
},
},
mutations,
}
export default statuses
diff --git a/src/modules/users.js b/src/modules/users.js
index a145a80e08..4aef4d5dd5 100644
--- a/src/modules/users.js
+++ b/src/modules/users.js
@@ -1,875 +1,875 @@
import Cookies from 'js-cookie'
import { compact, each, last, map, mergeWith } from 'lodash'
import {
registerPushNotifications,
unregisterPushNotifications,
} from '../services/sw/sw.js'
import {
windowHeight,
windowWidth,
} from '../services/window_utils/window_utils'
import { useAnnouncementsStore } from 'src/stores/announcements.js'
import { useBookmarkFoldersStore } from 'src/stores/bookmark_folders.js'
import { useChatsStore } from 'src/stores/chats.js'
import { useEmojiStore } from 'src/stores/emoji.js'
import { useInstanceStore } from 'src/stores/instance.js'
import { useInstanceCapabilitiesStore } from 'src/stores/instance_capabilities.js'
import { useInterfaceStore } from 'src/stores/interface.js'
import { useListsStore } from 'src/stores/lists.js'
import { useMergedConfigStore } from 'src/stores/merged_config.js'
import { useOAuthStore } from 'src/stores/oauth.js'
import { useSyncConfigStore } from 'src/stores/sync_config.js'
import { useUserHighlightStore } from 'src/stores/user_highlight.js'
import { revokeToken } from 'src/api/oauth.js'
import {
fetchFollowers,
fetchFriends,
fetchUser,
fetchUserByName,
getCaptcha,
register,
searchUsers,
verifyCredentials,
} from 'src/api/public.js'
import {
blockUser as apiBlockUser,
editUserNote as apiEditUserNote,
muteDomain as apiMuteDomain,
muteUser as apiMuteUser,
unblockUser as apiUnblockUser,
unmuteDomain as apiUnmuteDomain,
unmuteUser as apiUnmuteUser,
fetchBlocks,
fetchDomainMutes,
fetchMutes,
fetchUserInLists,
fetchUserRelationship,
followUser,
} from 'src/api/user.js'
// TODO: Unify with mergeOrAdd in statuses.js
export const mergeOrAdd = (arr, obj, item) => {
if (!item) {
return false
}
const oldItem = obj[item.id]
if (oldItem) {
// We already have this, so only merge the new info.
mergeWith(oldItem, item, mergeArrayLength)
return { item: oldItem, new: false }
} else {
// This is a new item, prepare it
arr.push(item)
obj[item.id] = item
return { item, new: true }
}
}
const mergeArrayLength = (oldValue, newValue) => {
if (Array.isArray(oldValue) && Array.isArray(newValue)) {
oldValue.length = newValue.length
return mergeWith(oldValue, newValue, mergeArrayLength)
}
}
-const getNotificationPermission = () => {
+const getNotificationPermission = async () => {
const Notification = window.Notification
- if (!Notification) return Promise.resolve(null)
+ if (!Notification) return null
if (Notification.permission === 'default')
return Notification.requestPermission()
- return Promise.resolve(Notification.permission)
+ return Notification.permission
}
const blockUser = (store, args) => {
const id = args.id
const expiresIn = typeof args === 'object' ? args.expiresIn : 0
const predictedRelationship = store.state.relationships[id] || { id }
store.commit('updateUserRelationship', [predictedRelationship])
store.commit('addBlockId', id)
return apiBlockUser({ id, expiresIn }).then(({ data: relationship }) => {
store.commit('updateUserRelationship', [relationship])
store.commit('addBlockId', id)
store.commit('removeStatus', { timeline: 'friends', userId: id })
store.commit('removeStatus', { timeline: 'public', userId: id })
store.commit('removeStatus', {
timeline: 'publicAndExternal',
userId: id,
})
})
}
const unblockUser = (store, id) => {
return apiUnblockUser({ id }).then(({ data: relationship }) =>
store.commit('updateUserRelationship', [relationship]),
)
}
const removeUserFromFollowers = (store, id) => {
return removeUserFromFollowers({ id }).then((relationship) =>
store.commit('updateUserRelationship', [relationship]),
)
}
const editUserNote = (store, { id, comment }) => {
return apiEditUserNote({ id, comment }).then((relationship) =>
store.commit('updateUserRelationship', [relationship]),
)
}
const muteUser = (store, args) => {
const id = typeof args === 'object' ? args.id : args
const expiresIn = typeof args === 'object' ? args.expiresIn : 0
const predictedRelationship = store.state.relationships[id] || { id }
store.commit('updateUserRelationship', [predictedRelationship])
store.commit('addMuteId', id)
return apiMuteUser({
id,
expiresIn,
credentials: useOAuthStore().token,
}).then(({ data: relationship }) => {
store.commit('updateUserRelationship', [relationship])
store.commit('addMuteId', id)
})
}
const unmuteUser = (store, id) => {
const predictedRelationship = store.state.relationships[id] || { id }
predictedRelationship.muting = false
store.commit('updateUserRelationship', [predictedRelationship])
return apiUnmuteUser({ id }).then(({ data: relationship }) =>
store.commit('updateUserRelationship', [relationship]),
)
}
const hideReblogs = (store, userId) => {
return followUser({
id: userId,
reblogs: false,
credentials: useOAuthStore().token,
}).then(({ data: relationship }) =>
store.commit('updateUserRelationship', [relationship]),
)
}
const showReblogs = (store, userId) => {
return followUser({
id: userId,
reblogs: true,
credentials: useOAuthStore().token,
}).then(({ data: relationship }) =>
store.commit('updateUserRelationship', [relationship]),
)
}
const muteDomain = (store, domain) => {
return apiMuteDomain({
domain,
credentials: useOAuthStore().token,
}).then(() => store.commit('addDomainMute', domain))
}
const unmuteDomain = (store, domain) => {
return apiUnmuteDomain({
domain,
credentials: useOAuthStore().token,
}).then(() => store.commit('removeDomainMute', domain))
}
export const mutations = {
tagUser(state, { user: { id }, tag }) {
const user = state.usersObject[id]
user.tags.add(tag)
},
untagUser(state, { user: { id }, tag }) {
const user = state.usersObject[id]
user.tags.delete(tag)
},
updateRight(state, { user: { id }, right, value }) {
const user = state.usersObject[id]
const newRights = user.rights
newRights[right] = value
user.rights = newRights
},
updateUserAdminData(state, { user }) {
const { id } = user
const localUser = state.usersObject[id]
localUser.adminData = user
localUser.deactivated = !user.is_active
localUser.tags = new Set(user.tags)
},
setCurrentUser(state, user) {
state.lastLoginName = user.screen_name
state.currentUser = mergeWith(
state.currentUser || {},
user,
mergeArrayLength,
)
},
clearCurrentUser(state) {
state.currentUser = false
state.lastLoginName = false
},
beginLogin(state) {
state.loggingIn = true
},
endLogin(state) {
state.loggingIn = false
},
saveFriendIds(state, { id, friendIds }) {
const user = state.usersObject[id]
user.friendIds = [...new Set([...(user.friendIds || []), ...friendIds])]
},
saveFollowerIds(state, { id, followerIds }) {
const user = state.usersObject[id]
user.followerIds = [...new Set([user.followerIds || [], ...followerIds])]
},
// Because frontend doesn't have a reason to keep these stuff in memory
// outside of viewing someones user profile.
clearFriends(state, userId) {
const user = state.usersObject[userId]
if (user) {
user.friendIds = []
}
},
clearFollowers(state, userId) {
const user = state.usersObject[userId]
if (user) {
user.followerIds = []
}
},
addNewUsers(state, users) {
each(users, (user) => {
if (user.relationship) {
state.relationships[user.relationship.id] = user.relationship
}
const res = mergeOrAdd(state.users, state.usersObject, user)
const item = res.item
if (res.new && item.screen_name && !item.screen_name.includes('@')) {
state.usersByNameObject[item.screen_name.toLowerCase()] = item
}
})
},
updateUserRelationship(state, relationships) {
relationships.forEach((relationship) => {
state.relationships[relationship.id] = relationship
})
},
updateUserInLists(state, { id, inLists }) {
state.usersObject[id].inLists = inLists
},
saveBlockIds(state, blockIds) {
state.currentUser.blockIds = blockIds
},
addBlockId(state, blockId) {
- if (state.currentUser.blockIds.indexOf(blockId) === -1) {
+ if (state.currentUser.blockIds.includes(blockId)) {
state.currentUser.blockIds.push(blockId)
}
},
setBlockIdsMaxId(state, blockIdsMaxId) {
state.currentUser.blockIdsMaxId = blockIdsMaxId
},
saveMuteIds(state, muteIds) {
state.currentUser.muteIds = muteIds
},
setMuteIdsMaxId(state, muteIdsMaxId) {
state.currentUser.muteIdsMaxId = muteIdsMaxId
},
addMuteId(state, muteId) {
- if (state.currentUser.muteIds.indexOf(muteId) === -1) {
+ if (state.currentUser.muteIds.includes(muteId)) {
state.currentUser.muteIds.push(muteId)
}
},
saveDomainMutes(state, domainMutes) {
state.currentUser.domainMutes = domainMutes
},
addDomainMute(state, domain) {
- if (state.currentUser.domainMutes.indexOf(domain) === -1) {
+ if (state.currentUser.domainMutes.includes(domain)) {
state.currentUser.domainMutes.push(domain)
}
},
removeDomainMute(state, domain) {
const index = state.currentUser.domainMutes.indexOf(domain)
if (index !== -1) {
state.currentUser.domainMutes.splice(index, 1)
}
},
setPinnedToUser(state, status) {
const user = state.usersObject[status.user.id]
user.pinnedStatusIds = user.pinnedStatusIds || []
const index = user.pinnedStatusIds.indexOf(status.id)
if (status.pinned && index === -1) {
user.pinnedStatusIds.push(status.id)
} else if (!status.pinned && index !== -1) {
user.pinnedStatusIds.splice(index, 1)
}
},
setUserForStatus(state, status) {
status.user = state.usersObject[status.user.id]
},
setUserForNotification(state, notification) {
if (notification.type !== 'follow') {
notification.action.user = state.usersObject[notification.action.user.id]
}
notification.from_profile = state.usersObject[notification.from_profile.id]
},
setColor(state, { user: { id }, highlighted }) {
const user = state.usersObject[id]
user.highlight = highlighted
},
signUpPending(state) {
state.signUpPending = true
state.signUpErrors = []
state.signUpNotice = {}
},
signUpSuccess(state) {
state.signUpPending = false
},
signUpFailure(state, errors) {
state.signUpPending = false
state.signUpErrors = errors
state.signUpNotice = {}
},
signUpNotice(state, notice) {
state.signUpPending = false
state.signUpErrors = []
state.signUpNotice = notice
},
}
export const getters = {
findUser: (state) => (query) => {
return state.usersObject[query]
},
findUserByName: (state) => (query) => {
return state.usersByNameObject[query.toLowerCase()]
},
findUserByUrl: (state) => (query) => {
return state.users.find(
(u) =>
u.statusnet_profile_url &&
u.statusnet_profile_url.toLowerCase() === query.toLowerCase(),
)
},
relationship: (state) => (id) => {
const rel = id && state.relationships[id]
return rel || { id, loading: true }
},
}
export const defaultState = {
loggingIn: false,
lastLoginName: false,
currentUser: false,
users: [],
usersObject: {},
usersByNameObject: {},
signUpPending: false,
signUpErrors: [],
signUpNotice: {},
relationships: {},
}
const users = {
state: defaultState,
mutations,
getters,
actions: {
- fetchUserIfMissing(store, id) {
+ async fetchUserIfMissing(store, id) {
const user = store.getters.findUser(id)
if (!user) {
return store.dispatch('fetchUser', id)
} else {
- return Promise.resolve(user)
+ return user
}
},
updateUserAdminData(store, { userAdminData }) {
return store
.dispatch('fetchUserIfMissing', userAdminData.id)
.then((user) => {
user.adminData = userAdminData
store.commit('addNewUsers', [user])
return user
})
},
fetchUser(store, id) {
return fetchUser({
id,
credentials: useOAuthStore().token,
})
.then(({ data: user }) => {
store.commit('addNewUsers', [user])
return user
})
.catch((error) => {
if (error.statusCode === 404) {
console.warn(`User ${id} not found`)
} else {
throw error
}
})
},
fetchUserByName(store, name) {
return fetchUserByName({
name,
credentials: useOAuthStore().token,
}).then(({ data: user }) => {
store.commit('addNewUsers', [user])
return user
})
},
fetchUserRelationship(store, id) {
if (store.state.currentUser) {
fetchUserRelationship({
id,
credentials: useOAuthStore().token,
}).then(({ data: relationships }) =>
store.commit('updateUserRelationship', relationships),
)
}
},
fetchUserInLists(store, id) {
if (store.state.currentUser) {
fetchUserInLists({
id,
credentials: useOAuthStore().token,
}).then(({ data: inLists }) =>
store.commit('updateUserInLists', { id, inLists }),
)
}
},
fetchBlocks(store, args) {
const { reset } = args || {}
const maxId = store.state.currentUser.blockIdsMaxId
return fetchBlocks({
maxId,
credentials: useOAuthStore().token,
}).then(({ data: blocks }) => {
if (reset) {
store.commit('saveBlockIds', map(blocks, 'id'))
} else {
map(blocks, 'id').map((id) => store.commit('addBlockId', id))
}
if (blocks.length) {
store.commit('setBlockIdsMaxId', last(blocks).id)
}
store.commit('addNewUsers', blocks)
return blocks
})
},
blockUser(store, data) {
return blockUser(store, data)
},
unblockUser(store, data) {
return unblockUser(store, data)
},
removeUserFromFollowers(store, id) {
return removeUserFromFollowers(store, id)
},
blockUsers(store, data = []) {
return Promise.all(data.map((d) => blockUser(store, d)))
},
unblockUsers(store, data = []) {
return Promise.all(data.map((d) => unblockUser(store, d)))
},
editUserNote(store, args) {
return editUserNote(store, args)
},
fetchMutes(store, args) {
const { reset } = args || {}
const maxId = store.state.currentUser.muteIdsMaxId
return fetchMutes({
maxId,
credentials: useOAuthStore().token,
}).then(({ data: mutes }) => {
if (reset) {
store.commit('saveMuteIds', map(mutes, 'id'))
} else {
map(mutes, 'id').map((id) => store.commit('addMuteId', id))
}
if (mutes.length) {
store.commit('setMuteIdsMaxId', last(mutes).id)
}
store.commit('addNewUsers', mutes)
return mutes
})
},
muteUser(store, data) {
return muteUser(store, data)
},
unmuteUser(store, id) {
return unmuteUser(store, id)
},
hideReblogs(store, id) {
return hideReblogs(store, id)
},
showReblogs(store, id) {
return showReblogs(store, id)
},
muteUsers(store, data = []) {
return Promise.all(data.map((d) => muteUser(store, d)))
},
unmuteUsers(store, ids = []) {
return Promise.all(ids.map((d) => unmuteUser(store, d)))
},
fetchDomainMutes(store) {
return fetchDomainMutes({
credentials: useOAuthStore().token,
}).then(({ data: domainMutes }) => {
store.commit('saveDomainMutes', domainMutes)
return domainMutes
})
},
muteDomain(store, domain) {
return muteDomain(store, domain)
},
unmuteDomain(store, domain) {
return unmuteDomain(store, domain)
},
muteDomains(store, domains = []) {
return Promise.all(domains.map((domain) => muteDomain(store, domain)))
},
unmuteDomains(store, domain = []) {
return Promise.all(domain.map((domain) => unmuteDomain(store, domain)))
},
fetchFriends({ rootState, commit }, id) {
const user = rootState.users.usersObject[id]
const maxId = last(user.friendIds)
return fetchFriends({
id,
maxId,
credentials: useOAuthStore().token,
}).then(({ data: friends }) => {
commit('addNewUsers', friends)
commit('saveFriendIds', { id, friendIds: map(friends, 'id') })
return friends
})
},
fetchFollowers({ rootState, commit }, id) {
const user = rootState.users.usersObject[id]
const maxId = last(user.followerIds)
return fetchFollowers({
id,
maxId,
credentials: useOAuthStore().token,
}).then(({ data: followers }) => {
commit('addNewUsers', followers)
commit('saveFollowerIds', { id, followerIds: map(followers, 'id') })
return followers
})
},
clearFriends({ commit }, userId) {
commit('clearFriends', userId)
},
clearFollowers({ commit }, userId) {
commit('clearFollowers', userId)
},
subscribeUser({ rootState, commit }, id) {
return followUser({
id,
notify: true,
credentials: useOAuthStore().token,
}).then(({ data: relationship }) =>
commit('updateUserRelationship', [relationship]),
)
},
unsubscribeUser({ rootState, commit }, id) {
return followUser({
id,
notify: false,
credentials: useOAuthStore().token,
}).then(({ data: relationship }) =>
commit('updateUserRelationship', [relationship]),
)
},
registerPushNotifications(store) {
const token = store.state.currentUser.credentials
const vapidPublicKey = useInstanceStore().vapidPublicKey
const isEnabled = useMergedConfigStore().mergedConfig.webPushNotifications
const notificationVisibility =
useMergedConfigStore().mergedConfig.notificationVisibility
registerPushNotifications(
isEnabled,
vapidPublicKey,
token,
notificationVisibility,
)
},
unregisterPushNotifications(store) {
const token = store.state.currentUser.credentials
unregisterPushNotifications(token)
},
addNewUsers({ commit }, users) {
commit('addNewUsers', users)
},
addNewStatuses(store, { statuses }) {
const users = map(statuses, 'user')
const retweetedUsers = compact(map(statuses, 'retweeted_status.user'))
const quotedUsers = compact(map(statuses, 'quote.user'))
store.commit('addNewUsers', users)
store.commit('addNewUsers', retweetedUsers)
store.commit('addNewUsers', quotedUsers)
each(statuses, (status) => {
// Reconnect users to statuses
store.commit('setUserForStatus', status)
// Set pinned statuses to user
store.commit('setPinnedToUser', status)
})
each(compact(map(statuses, 'retweeted_status')), (status) => {
// Reconnect users to retweets
store.commit('setUserForStatus', status)
// Set pinned retweets to user
store.commit('setPinnedToUser', status)
})
},
addNewNotifications(store, { notifications }) {
const users = map(notifications, 'from_profile')
- const targetUsers = map(notifications, 'target').filter((_) => _)
+ const targetUsers = map(notifications, 'target').filter(Boolean)
const notificationIds = notifications.map((_) => _.id)
store.commit('addNewUsers', users)
store.commit('addNewUsers', targetUsers)
const notificationsObject = store.rootState.notifications.idStore
const relevantNotifications = Object.entries(notificationsObject)
.filter(([k]) => notificationIds.includes(k))
.map(([, val]) => val)
// Reconnect users to notifications
each(relevantNotifications, (notification) => {
store.commit('setUserForNotification', notification)
})
},
searchUsers({ rootState, commit }, { query }) {
return searchUsers({
query,
credentials: useOAuthStore().token,
}).then(({ data: users }) => {
commit('addNewUsers', users)
return users
})
},
async signUp(store, userInfo) {
const oauthStore = useOAuthStore()
store.commit('signUpPending')
try {
const token = await oauthStore.ensureAppToken()
const { data } = await register({
credentials: token,
params: { ...userInfo },
})
if (data.access_token) {
store.commit('signUpSuccess')
oauthStore.setToken(data.access_token)
await store.dispatch('loginUser', data.access_token)
return 'ok'
} else {
// Request succeeded, but user cannot login yet.
store.commit('signUpNotice', data)
return 'request_sent'
}
} catch (e) {
const errors = e.message
store.commit('signUpFailure', errors)
throw e
}
},
getCaptcha(store) {
return getCaptcha({
credentials: useOAuthStore().token,
}).then(({ data }) => data)
},
logout(store) {
const oauth = useOAuthStore()
// NOTE: No need to verify the app still exists, because if it doesn't,
// the token will be invalid too
return oauth
.ensureApp()
.then((app) => {
const params = {
app,
instance: useInstanceStore().server,
token: oauth.userToken,
}
return revokeToken(params)
})
.then(() => {
store.commit('clearCurrentUser')
store.dispatch('disconnectFromSocket')
store.dispatch('stopFetchingTimeline', 'friends')
store.dispatch('stopFetchingNotifications')
useListsStore().stopFetching()
useBookmarkFoldersStore().stopFetching()
store.dispatch('stopFetchingFollowRequests')
store.commit('clearNotifications')
store.commit('resetStatuses')
useChatsStore().resetChats()
oauth.clearToken()
Cookies.remove('__Host-pleroma_key', { path: '/' })
useInterfaceStore().setLastTimeline('public-timeline')
useInterfaceStore().setLayoutWidth(windowWidth())
useInterfaceStore().setLayoutHeight(windowHeight())
})
},
loginUser(store, accessToken) {
return new Promise((resolve, reject) => {
const commit = store.commit
const dispatch = store.dispatch
commit('beginLogin')
verifyCredentials({
credentials: useOAuthStore().token,
})
.then(({ data: user }) => {
// user.credentials = userCredentials
user.credentials = accessToken
user.blockIds = []
user.muteIds = []
user.domainMutes = []
commit('setCurrentUser', user)
useSyncConfigStore()
.initSyncConfig(user)
.then(() => {
useInterfaceStore()
.applyTheme()
.catch((e) => {
console.error('Error setting theme', e)
})
})
useUserHighlightStore().initUserHighlight(user)
commit('addNewUsers', [user])
useEmojiStore().fetchEmoji()
getNotificationPermission().then((permission) =>
useInterfaceStore().setNotificationPermission(permission),
)
// Do server-side storage migrations
// Debug snippet to clean up storage and reset migrations
/*
// Reset wordfilter
Object.keys(
useSyncConfigStore().prefsStorage.simple.muteFilters
).forEach(key => {
useSyncConfigStore().unsetSimplePrefAndSave({ path: 'muteFilters.' + key, value: null })
})
// Reset flag to 0 to re-run migrations
useSyncConfigStore().setFlag({ flag: 'configMigration', value: 0 })
/**/
if (user.token) {
dispatch('setWsToken', user.token)
// Initialize the shout socket.
dispatch('initializeSocket')
}
const startPolling = () => {
// Start getting fresh posts.
dispatch('startFetchingTimeline', { timeline: 'friends' })
// Start fetching notifications
dispatch('startFetchingNotifications')
if (useInstanceCapabilitiesStore().pleromaChatMessagesAvailable) {
// Start fetching chats
useChatsStore().startFetchingChats()
}
}
useListsStore().startFetching()
useBookmarkFoldersStore().startFetching()
if (user.locked) {
dispatch('startFetchingFollowRequests')
}
if (useMergedConfigStore().mergedConfig.useStreamingApi) {
dispatch('fetchTimeline', {
timeline: 'friends',
sinceId: null,
})
dispatch('fetchNotifications', { sinceId: null })
dispatch('enableMastoSockets', true)
.catch((error) => {
console.error(
'Failed initializing MastoAPI Streaming socket',
error,
)
})
.then(() => {
dispatch('fetchChats', { latest: true })
setTimeout(
() => dispatch('setNotificationsSilence', false),
10000,
)
})
} else {
startPolling()
}
// Start fetching things that don't need to block the UI
useAnnouncementsStore().startFetchingAnnouncements()
dispatch('fetchMutes')
dispatch('loadDrafts')
useInterfaceStore().setLayoutWidth(windowWidth())
useInterfaceStore().setLayoutHeight(windowHeight())
// Fetch our friends
fetchFriends({ id: user.id }).then(({ data: friends }) =>
commit('addNewUsers', friends),
)
commit('endLogin')
resolve()
})
.catch((error) => {
console.error(error)
// Authentication failed
commit('endLogin')
// remove authentication token on client/authentication errors
if ([400, 401, 403, 422].includes(error.statusCode)) {
useOAuthStore().clearToken()
}
commit('endLogin')
if (error.tatusCode === 401) {
throw new Error('Wrong username or password', error)
} else {
throw new Error('An error occurred, please try again', error)
}
})
})
},
},
}
export default users
diff --git a/src/panel.scss b/src/panel.scss
index 8d9c2896bd..6af2c9e764 100644
--- a/src/panel.scss
+++ b/src/panel.scss
@@ -1,209 +1,217 @@
/* stylelint-disable no-descending-specificity */
.panel {
--__panel-background: var(--background);
--__panel-backdrop-filter: var(--backdrop-filter);
--_shadow: var(--shadow);
.tab-switcher .tabs {
background: var(--__panel-background);
backdrop-filter: var(--__panel-backdrop-filter);
}
position: relative;
display: flex;
flex-direction: column;
.panel-heading {
background-color: inherit;
}
&::after,
& {
border-radius: var(--roundness);
}
&::after {
content: "";
position: absolute;
inset: 0;
z-index: 5;
box-shadow: var(--_shadow);
pointer-events: none;
}
}
.panel-body {
padding: var(--panel-body-padding, 0);
background: var(--background);
backdrop-filter: var(--__panel-backdrop-filter);
.tab-switcher .tabs {
background: none;
backdrop-filter: none;
}
&:empty::before {
content: "¯\\_(ツ)_/¯"; // Could use words but it'd require translations
display: block;
padding: 1em;
text-align: center;
}
> p {
line-height: 1.3;
padding: 1em;
margin: 0;
}
}
.panel-heading,
.panel-footer {
--panel-heading-height-padding: calc(var(--panel-header-height) * 0.2);
--__panel-heading-gap: calc(var(--panel-header-height) * 0.1565);
--__panel-heading-height: var(--panel-header-height);
--__panel-heading-height-inner: calc(var(--__panel-heading-height) - 2 * var(--panel-heading-height-padding, 0));
font-size: calc(var(--panelHeaderSize) / 3.2);
backdrop-filter: var(--__panel-backdrop-filter);
position: relative;
box-sizing: border-box;
display: grid;
grid-auto-flow: column;
grid-template-columns: minmax(50%, 1fr);
grid-auto-columns: auto;
grid-column-gap: var(--__panel-heading-gap);
flex: none;
background-size: cover;
padding: var(--panel-heading-height-padding);
height: var(--__panel-heading-height);
line-height: var(--__panel-heading-height-inner);
z-index: 4;
&.-flexible-height {
--__panel-heading-height: auto;
&::after,
&::before {
display: none;
}
}
&.-stub {
&,
&::after {
border-radius: var(--roundness);
}
}
&.-sticky {
position: sticky;
top: var(--navbar-height);
}
&::after,
&::before {
content: "";
position: absolute;
inset: 0;
pointer-events: none;
}
.title {
font-size: 1.3em;
margin: 0;
font-weight: normal;
}
.alert {
white-space: nowrap;
text-overflow: ellipsis;
overflow-x: hidden;
}
&:not(.-flexible-height) {
> .button-default,
> .alert {
height: var(--__panel-heading-height-inner);
min-height: 0;
box-sizing: border-box;
margin: 0;
min-width: 1px;
padding-top: 0;
padding-bottom: 0;
align-self: stretch;
}
> .alert {
line-height: calc(var(--__panel-heading-height-inner) - 2px);
}
}
}
// TODO Should refactor panels into separate component and utilize slots
.panel-heading {
border-radius: var(--roundness) var(--roundness) 0 0;
border-width: 0 0 1px;
align-items: start;
background-image:
linear-gradient(to bottom, var(--background), var(--background)),
linear-gradient(to bottom, var(--__panel-background), var(--__panel-background));
+ overflow: hidden;
+ text-overflow: ellipsis;
+
+ .title {
+ overflow: hidden;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+ }
--_shadow: var(--shadow);
&::after {
background-color: var(--background);
z-index: -2;
border-radius: var(--roundness) var(--roundness) 0 0;
box-shadow: var(--_shadow);
}
&:not(.-flexible-height) {
> .button-default {
flex-shrink: 0;
}
}
.rightside-button {
align-self: stretch;
text-align: center;
width: var(--__panel-heading-height);
height: var(--__panel-heading-height);
margin: calc(-1 * var(--panel-heading-height-padding)) 0;
margin-right: calc(-1 * var(--__panel-heading-gap));
> button {
box-sizing: border-box;
padding: calc(1 * var(--panel-heading-height-padding)) 0;
height: 100%;
width: 100%;
text-align: center;
svg {
font-size: 1.2em;
}
}
}
.rightside-icon {
align-self: stretch;
text-align: center;
width: var(--__panel-heading-height);
margin-right: calc(-1 * var(--__panel-heading-gap));
svg {
font-size: 1.2em;
}
}
}
.panel-footer {
border-top-left-radius: 0;
border-top-right-radius: 0;
align-items: center;
border-width: 1px 0 0;
border-style: solid;
border-color: var(--border);
background-color: var(--__panel-background);
}
/* stylelint-enable no-descending-specificity */
diff --git a/src/services/chat_utils/chat_utils.js b/src/services/chat_utils/chat_utils.js
index ccf91e85c5..bc02981cd8 100644
--- a/src/services/chat_utils/chat_utils.js
+++ b/src/services/chat_utils/chat_utils.js
@@ -1,50 +1,47 @@
import { showDesktopNotification } from '../desktop_notification_utils/desktop_notification_utils.js'
export const maybeShowChatNotification = (chat) => {
if (!chat.lastMessage) return
if (window.vuex.state.users.currentUser.id === chat.lastMessage.account_id)
return
const opts = {
tag: chat.lastMessage.id,
title: chat.account.name,
icon: chat.account.profile_image_url,
body: chat.lastMessage.content,
}
- if (
- chat.lastMessage.attachment &&
- chat.lastMessage.attachment.type === 'image'
- ) {
+ if (chat.lastMessage.attachment?.type === 'image') {
opts.image = chat.lastMessage.attachment.preview_url
}
showDesktopNotification(window.vuex.state, opts)
}
export const buildFakeMessage = ({
content,
chatId,
attachments,
userId,
idempotencyKey,
}) => {
const fakeMessage = {
content,
chat_id: chatId,
created_at: new Date(),
id: `${new Date().getTime()}`,
attachments,
account_id: userId,
idempotency_key: idempotencyKey,
emojis: [],
pending: true,
isNormalized: true,
}
if (attachments[0]) {
fakeMessage.attachment = attachments[0]
}
return fakeMessage
}
diff --git a/src/services/color_convert/color_convert.js b/src/services/color_convert/color_convert.js
index dded73da3e..925969531b 100644
--- a/src/services/color_convert/color_convert.js
+++ b/src/services/color_convert/color_convert.js
@@ -1,297 +1,297 @@
import { contrastRatio, convert, invertLightness } from 'chromatism'
// useful for visualizing color when debugging
// const consoleColor = (color) => console.debug('%c##########', 'background: ' + color + '; color: ' + color)
/**
* Convert r, g, b values into hex notation. All components are [0-255]
*
* @param {Number|String|Object} r - Either red component, {r,g,b} object, or hex string
* @param {Number} [g] - Green component
* @param {Number} [b] - Blue component
*/
export const rgb2hex = (r, g, b) => {
- if (r === null || typeof r === 'undefined') {
+ if (r === null || r === undefined) {
return undefined
}
// TODO: clean up this mess
if (r[0] === '#' || r === 'transparent') {
return r
}
if (typeof r === 'object') {
;({ r, g, b } = r)
}
;[r, g, b] = [r, g, b].map((val) => {
val = Math.ceil(val)
val = val < 0 ? 0 : val
val = val > 255 ? 255 : val
return val
})
return `#${((1 << 24) + (r << 16) + (g << 8) + b).toString(16).slice(1)}`
}
/**
* Converts 8-bit RGB component into linear component
* https://www.w3.org/TR/2008/REC-WCAG20-20081211/#relativeluminancedef
* https://www.w3.org/TR/2008/REC-WCAG20-20081211/relative-luminance.xml
* https://en.wikipedia.org/wiki/SRGB#The_reverse_transformation
*
* @param {Number} bit - color component [0..255]
* @returns {Number} linear component [0..1]
*/
const c2linear = (bit) => {
// W3C gives 0.03928 while wikipedia states 0.04045
// what those magical numbers mean - I don't know.
// something about gamma-correction, i suppose.
// Sticking with W3C example.
const c = bit / 255
if (c < 0.03928) {
return c / 12.92
} else {
return Math.pow((c + 0.055) / 1.055, 2.4)
}
}
/**
* Calculates relative luminance for given color
* https://www.w3.org/TR/2008/REC-WCAG20-20081211/#relativeluminancedef
* https://www.w3.org/TR/2008/REC-WCAG20-20081211/relative-luminance.xml
*
* @param {Object} srgb - sRGB color
* @returns {Number} relative luminance
*/
export const relativeLuminance = (srgb) => {
const r = c2linear(srgb.r)
const g = c2linear(srgb.g)
const b = c2linear(srgb.b)
return 0.2126 * r + 0.7152 * g + 0.0722 * b
}
/**
* Generates color ratio between two colors. Order is unimporant
* https://www.w3.org/TR/2008/REC-WCAG20-20081211/#contrast-ratiodef
*
* @param {Object} a - sRGB color
* @param {Object} b - sRGB color
* @returns {Number} color ratio
*/
export const getContrastRatio = (a, b) => {
const la = relativeLuminance(a)
const lb = relativeLuminance(b)
const [l1, l2] = la > lb ? [la, lb] : [lb, la]
return (l1 + 0.05) / (l2 + 0.05)
}
/**
* Same as `getContrastRatio` but for multiple layers in-between
*
* @param {Object} text - text color (topmost layer)
* @param {[Object, Number]} layers[] - layers between text and bedrock
* @param {Object} bedrock - layer at the very bottom
*/
export const getContrastRatioLayers = (text, layers, bedrock) => {
return getContrastRatio(alphaBlendLayers(bedrock, layers), text)
}
/**
* Blending of two solid colors with a user-defined operator: origin +- value
*
* @param {Object} origin - base color
* @param {Object} value - modification argument
* @param {string} operator - math operator to use
*/
export const arithmeticBlend = (origin, value, operator) => {
const func = (a, b) => {
switch (operator) {
case '+':
return Math.min(a + b, 255)
case '-':
return Math.max(a - b, 0)
default:
return a
}
}
return {
r: func(origin.r, value.r),
g: func(origin.g, value.g),
b: func(origin.b, value.b),
}
}
/**
* This performs alpha blending between solid background and semi-transparent foreground
*
* @param {Object} fg - top layer color
* @param {Number} fga - top layer's alpha
* @param {Object} bg - bottom layer color
* @returns {Object} sRGB of resulting color
*/
export const alphaBlend = (fg, fga, bg) => {
- if (fga === 1 || typeof fga === 'undefined') {
+ if (fga === 1 || fga === undefined) {
return fg
}
// Simplified https://en.wikipedia.org/wiki/Alpha_compositing#Alpha_blending
// for opaque bg and transparent fg
return {
r: fg.r * fga + bg.r * (1 - fga),
g: fg.g * fga + bg.g * (1 - fga),
b: fg.b * fga + bg.b * (1 - fga),
}
}
/**
* Same as `alphaBlend` but for multiple layers in-between
*
* @param {Object} bedrock - layer at the very bottom
* @param {[Object, Number]} layers[] - layers between text and bedrock
*/
export const alphaBlendLayers = (bedrock, layers) =>
layers.reduce((acc, [color, opacity]) => {
return alphaBlend(color, opacity, acc)
}, bedrock)
export const invert = (rgb) => {
return {
r: 255 - rgb.r,
g: 255 - rgb.g,
b: 255 - rgb.b,
}
}
/**
* Converts #rrggbb hex notation into an {r, g, b} object
*
* @param {String} hex - #rrggbb string
* @returns {Object} rgb representation of the color, values are 0-255
*/
export const hex2rgb = (hex) => {
const result = /^#?([a-f\d]{2})([a-f\d]{2})([a-f\d]{2})$/i.exec(hex)
return result
? {
r: Number.parseInt(result[1], 16),
g: Number.parseInt(result[2], 16),
b: Number.parseInt(result[3], 16),
}
: null
}
/**
* Old somewhat weird function for mixing two colors together
*
* @param {Object} a - one color (rgb)
* @param {Object} b - other color (rgb)
* @returns {Object} result
*/
export const mixrgb = (a, b) => {
return {
r: (a.r + b.r) / 2,
g: (a.g + b.g) / 2,
b: (a.b + b.b) / 2,
}
}
/**
* Converts rgb object into a CSS rgba() color
*
* @param {Object} color - rgb
* @returns {String} CSS rgba() color
*/
export const rgba2css = function (rgba) {
const base = {
r: 0,
g: 0,
b: 0,
a: 1,
}
if (rgba !== null) {
- if (rgba.r !== undefined && !isNaN(rgba.r)) {
+ if (rgba.r !== undefined && !Number.isNaN(rgba.r)) {
base.r = rgba.r
}
- if (rgba.g !== undefined && !isNaN(rgba.g)) {
+ if (rgba.g !== undefined && !Number.isNaN(rgba.g)) {
base.g = rgba.g
}
- if (rgba.b !== undefined && !isNaN(rgba.b)) {
+ if (rgba.b !== undefined && !Number.isNaN(rgba.b)) {
base.b = rgba.b
}
- if (rgba.a !== undefined && !isNaN(rgba.a)) {
+ if (rgba.a !== undefined && !Number.isNaN(rgba.a)) {
base.a = rgba.a
}
} else {
base.r = 255
base.g = 255
base.b = 255
}
return `rgba(${Math.floor(base.r)}, ${Math.floor(base.g)}, ${Math.floor(base.b)}, ${base.a})`
}
/**
* Get text color for given background color and intended text color
* This checks if text and background don't have enough color and inverts
* text color's lightness if needed. If text color is still not enough it
* will fall back to black or white
*
* @param {Object} bg - background color
* @param {Object} text - intended text color
* @param {Boolean} preserve - try to preserve intended text color's hue/saturation (i.e. no BW)
*/
export const getTextColor = function (bg, text, preserve) {
const originalContrast = getContrastRatio(bg, text)
if (!preserve) {
if (originalContrast < 4.5) {
// B&W
return contrastRatio(bg, text).rgb
}
}
const originalColor = convert(text).hex
const invertedColor = invertLightness(originalColor).hex
const invertedContrast = getContrastRatio(bg, convert(invertedColor).rgb)
let workColor
if (invertedContrast > originalContrast) {
workColor = invertedColor
} else {
workColor = originalColor
}
let contrast = getContrastRatio(bg, text)
const result = convert(rgb2hex(workColor)).hsl
const delta = result.l >= 50 ? 1 : -1
const multiplier = 1
while (contrast < 4.5 && result.l > 20 && result.l < 80) {
result.l += delta * multiplier
result.l = Math.min(100, Math.max(0, result.l))
contrast = getContrastRatio(bg, convert(result).rgb)
}
const base = text.a !== undefined ? { a: text.a } : {}
return Object.assign(convert(result).rgb, base)
}
/**
* Converts color to CSS Color value
*
* @param {Object|String} input - color
* @param {Number} [a] - alpha value
* @returns {String} a CSS Color value
*/
export const getCssColor = (input, a) => {
let rgb = {}
if (typeof input === 'object') {
rgb = input
} else if (typeof input === 'string') {
if (input.startsWith('#')) {
rgb = hex2rgb(input)
} else {
return input
}
}
return rgba2css({ ...rgb, a })
}
diff --git a/src/services/entity_normalizer/entity_normalizer.service.js b/src/services/entity_normalizer/entity_normalizer.service.js
index d2df5e8699..5b8fa5b57a 100644
--- a/src/services/entity_normalizer/entity_normalizer.service.js
+++ b/src/services/entity_normalizer/entity_normalizer.service.js
@@ -1,426 +1,426 @@
import { parseLinkHeader } from '@web3-storage/parse-link-header'
import escapeHtml from 'escape-html'
import { unescape as lodashUnescape } from 'lodash'
import punycode from 'punycode.js'
import { fileType } from '../file_type/file_type.service.js'
import { isStatusNotification } from '../notification_utils/notification_utils.js'
/** NOTICE! **
* Do not initialize UI-generated data here.
* It will override existing data.
*
* i.e. user.pinnedStatusIds was set to [] here
* UI code would update it with data but upon next user fetch
* it would be reverted back to []
*/
export const parseUser = (data) => {
const output = {}
output._original = data // used for server-side settings
// case for users in "mentions" property for statuses in MastoAPI
const mastoShort = !Object.hasOwn(data, 'avatar')
output.inLists = null
output.id = String(data.id)
output.screen_name = data.acct
output.fqn = data.fqn
output.statusnet_profile_url = data.url
if (Object.hasOwn(data, 'mute_expires_at')) {
output.mute_expires_at =
data.mute_expires_at == null ? false : data.mute_expires_at
}
if (Object.hasOwn(data, 'block_expires_at')) {
output.block_expires_at =
data.block_expires_at == null ? false : data.block_expires_at
}
// There's nothing else to get
if (mastoShort) {
return output
}
output.emoji = data.emojis
output.name = escapeHtml(data.display_name)
output.name_html = output.name
output.name_unescaped = data.display_name
output.description = data.note
// TODO cleanup this shit, output.description is overriden with source data
output.description_html = data.note
output.fields = data.fields
output.fields_html = data.fields.map((field) => {
return {
name: escapeHtml(field.name),
value: field.value,
}
})
output.fields_text = data.fields.map((field) => {
return {
- name: unescape(field.name.replace(/<[^>]*>/g, '')),
- value: unescape(field.value.replace(/<[^>]*>/g, '')),
+ name: unescape(field.name.replaceAll(/<[^>]*>/g, '')),
+ value: unescape(field.value.replaceAll(/<[^>]*>/g, '')),
}
})
// Utilize avatar_static for gif avatars?
output.profile_image_url = data.avatar
output.profile_image_url_original = data.avatar
// Same, utilize header_static?
output.cover_photo = data.header
output.friends_count = data.following_count
output.bot = data.bot
output.privileges = []
if (data.pleroma) {
if (data.pleroma.settings_store) {
output.storage = data.pleroma.settings_store['pleroma-fe']
output.user_highlight = data.pleroma.settings_store['user_highlight']
}
const relationship = data.pleroma.relationship
output.background_image = data.pleroma.background_image
output.favicon = data.pleroma.favicon
output.token = data.pleroma.chat_token
if (relationship) {
output.relationship = relationship
}
output.allow_following_move = data.pleroma.allow_following_move
output.hide_favorites = data.pleroma.hide_favorites
output.hide_follows = data.pleroma.hide_follows
output.hide_followers = data.pleroma.hide_followers
output.hide_follows_count = data.pleroma.hide_follows_count
output.hide_followers_count = data.pleroma.hide_followers_count
output.rights = {
moderator: data.pleroma.is_moderator,
admin: data.pleroma.is_admin,
}
// TODO: Clean up in UI? This is duplication from what BE does for qvitterapi
if (output.rights.admin) {
output.role = 'admin'
} else if (output.rights.moderator) {
output.role = 'moderator'
} else {
output.role = 'member'
}
output.birthday = data.pleroma.birthday
if (data.pleroma.privileges) {
output.privileges = new Set(data.pleroma.privileges)
} else if (data.pleroma.is_admin) {
output.privileges = new Set([
'users_read',
'users_manage_invites',
'users_manage_activation_state',
'users_manage_tags',
'users_manage_credentials',
'users_delete',
'messages_read',
'messages_delete',
'instances_delete',
'reports_manage_reports',
'moderation_log_read',
'announcements_manage_announcements',
'emoji_manage_emoji',
'statistics_read',
])
} else if (data.pleroma.is_moderator) {
output.privileges = new Set(['messages_delete', 'reports_manage_reports'])
} else {
output.privileges = new Set()
}
}
if (data.source) {
output.description = data.source.note
output.default_scope = data.source.privacy
output.fields = data.source.fields
if (data.source.pleroma) {
output.no_rich_text = data.source.pleroma.no_rich_text
output.show_role =
typeof data.source.pleroma.show_role === 'boolean'
? data.source.pleroma.show_role
: true
output.discoverable = data.source.pleroma.discoverable
output.show_birthday = data.pleroma.show_birthday
output.actor_type = data.source.pleroma.actor_type
}
}
// TODO: handle is_local
output.is_local = !output.screen_name.includes('@')
output.created_at = new Date(data.created_at)
output.locked = data.locked
output.last_status_at = new Date(data.last_status_at)
output.followers_count = data.followers_count
output.statuses_count = data.statuses_count
if (data.pleroma) {
output.follow_request_count = data.pleroma.follow_request_count
output.tags = new Set(data.pleroma.tags)
// deactivated was changed to is_active in Pleroma 2.3.0
// so check if is_active is present
output.deactivated =
data.pleroma.is_active !== undefined
? !data.pleroma.is_active // new backend
: data.pleroma.deactivated // old backend
output.notification_settings = data.pleroma.notification_settings
output.unread_chat_count = data.pleroma.unread_chat_count
}
output.tags = output.tags || new Set()
output.rights = output.rights || {}
output.notification_settings = output.notification_settings || {}
// Convert punycode to unicode for UI
output.screen_name_ui = output.screen_name
- if (output.screen_name && output.screen_name.includes('@')) {
+ if (output.screen_name?.includes('@')) {
const parts = output.screen_name.split('@')
const unicodeDomain = punycode.toUnicode(parts[1])
if (unicodeDomain !== parts[1]) {
// Add some identifier so users can potentially spot spoofing attempts:
// lain.com and xn--lin-6cd.com would appear identical otherwise.
output.screen_name_ui_contains_non_ascii = true
output.screen_name_ui = [parts[0], unicodeDomain].join('@')
} else {
output.screen_name_ui_contains_non_ascii = false
}
}
return output
}
export const parseAttachment = (data) => {
const output = {}
// Not exactly same...
output.mimetype = data.pleroma ? data.pleroma.mime_type : data.type
output.meta = data.meta // not present in BE yet
output.id = data.id
if (data.type !== 'unknown') {
// treat gifv like it is "video"
output.type = data.type === 'gifv' ? 'video' : data.type
} else {
output.type = fileType(output.mimetype)
}
output.url = data.url
output.large_thumb_url = data.preview_url
output.description = lodashUnescape(data.description)
return output
}
export const parseSource = (data) => {
const output = {}
output.text = data.text
output.spoiler_text = data.spoiler_text
output.content_type = data.content_type
return output
}
export const parseStatus = (data) => {
const output = {}
output.favorited = data.favourited
output.fave_num = data.favourites_count
output.repeated = data.reblogged
output.repeat_num = data.reblogs_count
output.bookmarked = data.bookmarked
output.type = data.reblog ? 'retweet' : 'status'
output.nsfw = data.sensitive
output.raw_html = data.content
output.emojis = data.emojis
output.tags = data.tags
output.edited_at = data.edited_at
const { pleroma } = data
if (data.pleroma) {
output.text = pleroma.content
? data.pleroma.content['text/plain']
: data.content
output.summary = pleroma.spoiler_text
? data.pleroma.spoiler_text['text/plain']
: data.spoiler_text
output.statusnet_conversation_id = data.pleroma.conversation_id
output.is_local = pleroma.local
output.in_reply_to_screen_name = pleroma.in_reply_to_account_acct
output.thread_muted = pleroma.thread_muted
output.emoji_reactions = pleroma.emoji_reactions
output.parent_visible =
pleroma.parent_visible === undefined ? true : pleroma.parent_visible
output.quote_visible = pleroma.quote_visible || true
output.quotes_count = pleroma.quotes_count
output.bookmark_folder_id = pleroma.bookmark_folder
} else {
output.text = data.content
output.summary = data.spoiler_text
}
const quoteRaw = pleroma?.quote || data.quote
const quoteData = quoteRaw ? parseStatus(quoteRaw) : undefined
output.quote = quoteData
output.quote_id =
data.quote?.id ?? data.quote_id ?? quoteData?.id ?? pleroma?.quote_id
output.quote_url = data.quote?.url ?? quoteData?.url ?? pleroma?.quote_url
output.in_reply_to_status_id = data.in_reply_to_id
output.in_reply_to_user_id = data.in_reply_to_account_id
output.replies_count = data.replies_count
if (output.type === 'retweet') {
output.retweeted_status = parseStatus(data.reblog)
}
output.summary_raw_html = escapeHtml(data.spoiler_text)
output.external_url = data.uri || data.url
output.poll = data.poll
if (output.poll) {
output.poll.options = (output.poll.options || []).map((field) => ({
...field,
title_html: escapeHtml(field.title),
}))
}
output.pinned = data.pinned
output.muted = data.muted
output.id = String(data.id)
output.visibility = data.visibility
output.card = data.card
output.created_at = new Date(data.created_at)
// Converting to string, the right way.
output.in_reply_to_status_id = output.in_reply_to_status_id
? String(output.in_reply_to_status_id)
: null
output.in_reply_to_user_id = output.in_reply_to_user_id
? String(output.in_reply_to_user_id)
: null
output.user = parseUser(data.account)
output.attentions = (data.mentions || []).map(parseUser)
output.attachments = (data.media_attachments || []).map(parseAttachment)
const retweetedStatus = data.reblog
if (retweetedStatus) {
output.retweeted_status = parseStatus(retweetedStatus)
}
output.favoritedBy = []
output.rebloggedBy = []
if (Object.hasOwn(data, 'originalStatus')) {
Object.assign(output, data.originalStatus)
}
return output
}
export const parseNotification = (data) => {
const mastoDict = {
favourite: 'like',
reblog: 'repeat',
}
const output = {}
output.type = mastoDict[data.type] || data.type
output.seen = data.pleroma.is_seen
// TODO: null check should be a temporary fix, I guess.
// Investigate why backend does this.
output.status =
isStatusNotification(output.type) && data.status !== null
? parseStatus(data.status)
: null
output.target = output.type !== 'move' ? null : parseUser(data.target)
output.from_profile = parseUser(data.account)
output.emoji = data.emoji
output.emoji_url = data.emoji_url
if (data.report) {
output.report = data.report
output.report.content = data.report.content
output.report.acct = parseUser(data.report.account)
output.report.actor = parseUser(data.report.actor)
output.report.statuses = data.report.statuses.map(parseStatus)
}
output.created_at = new Date(data.created_at)
output.id = Number.parseInt(data.id)
return output
}
export const parseLinkHeaderPagination = (linkHeader, opts = {}) => {
const flakeId = opts.flakeId
const parsedLinkHeader = parseLinkHeader(linkHeader)
if (!parsedLinkHeader) return
const maxId = parsedLinkHeader.next?.max_id
const minId = parsedLinkHeader.prev?.min_id
return {
maxId: flakeId ? maxId : Number.parseInt(maxId, 10),
minId: flakeId ? minId : Number.parseInt(minId, 10),
}
}
export const parseChat = (chat) => {
const output = {}
output.id = chat.id
output.account = parseUser(chat.account)
output.unread = chat.unread
output.lastMessage = parseChatMessage(chat.last_message)
output.updated_at = new Date(chat.updated_at)
return output
}
export const parseChatMessage = (message) => {
if (!message) {
return
}
if (message.isNormalized) {
return message
}
const output = message
output.id = message.id
output.created_at = new Date(message.created_at)
output.chat_id = message.chat_id
output.emojis = message.emojis
output.content = message.content
if (message.attachment) {
output.attachments = [parseAttachment(message.attachment)]
} else {
output.attachments = []
}
output.pending = !!message.pending
output.error = false
output.idempotency_key = message.idempotency_key
output.isNormalized = true
return output
}
diff --git a/src/services/errors/errors.js b/src/services/errors/errors.js
index 5fbb8da110..0742de3f49 100644
--- a/src/services/errors/errors.js
+++ b/src/services/errors/errors.js
@@ -1,70 +1,70 @@
import { capitalize } from 'lodash'
function humanizeErrors(errors) {
return Object.entries(errors).reduce((errs, [k, val]) => {
const message = val.reduce((acc, message) => {
- const key = capitalize(k.replace(/_/g, ' '))
+ const key = capitalize(k.replaceAll('_', ' '))
return acc + [key, message].join(' ') + '. '
}, '')
return [...errs, message]
}, [])
}
export function StatusCodeError(statusCode, body, options, response) {
this.name = 'StatusCodeError'
this.statusCode = statusCode
this.statusText = body.error || body.errors || body
this.details = JSON.stringify(body)
this.errorData = body.error || body.errors
this.message = this.statusCode + ' - ' + this.statusText
this.error = body // legacy attribute
this.options = options
this.response = response
if (Error.captureStackTrace) {
// required for non-V8 environments
Error.captureStackTrace(this)
}
}
StatusCodeError.prototype = Object.create(Error.prototype)
StatusCodeError.prototype.constructor = StatusCodeError
export class RegistrationError extends Error {
constructor(error) {
super()
if (Error.captureStackTrace) {
Error.captureStackTrace(this)
}
try {
// the error is probably a JSON object with a single key, "errors", whose value is another JSON object containing the real errors
if (typeof error === 'string') {
error = JSON.parse(error)
// eslint-disable-next-line
if (Object.hasOwn(error, 'error')) {
error = JSON.parse(error.error)
}
}
if (typeof error === 'object') {
const errorContents = JSON.parse(error.error)
// keys will have the property that has the error, for example 'ap_id',
// 'email' or 'captcha', the value will be an array of its error
// like "ap_id": ["has been taken"] or "captcha": ["Invalid CAPTCHA"]
// replace ap_id with username
if (errorContents.ap_id) {
errorContents.username = errorContents.ap_id
delete errorContents.ap_id
}
this.message = humanizeErrors(errorContents)
} else {
this.message = error
}
} catch {
// can't parse it, so just treat it like a string
this.message = error
}
}
}
diff --git a/src/services/file_type/file_type.service.js b/src/services/file_type/file_type.service.js
index 02f659eec1..b6179cd81c 100644
--- a/src/services/file_type/file_type.service.js
+++ b/src/services/file_type/file_type.service.js
@@ -1,54 +1,54 @@
// TODO this func might as well take the entire file and use its mimetype
// or the entire service could be just mimetype service that only operates
// on mimetypes and not files. Currently the naming is confusing.
export const fileType = (mimetype) => {
if (mimetype.match(/flash/)) {
return 'flash'
}
if (mimetype.match(/text\/html/)) {
return 'html'
}
if (mimetype.match(/image/)) {
return 'image'
}
if (mimetype.match(/video/)) {
return 'video'
}
if (mimetype.match(/audio/)) {
return 'audio'
}
return 'unknown'
}
export const fileTypeExt = (url) => {
if (url.match(/\.(a?png|jpe?g|gif|webp|avif)$/)) {
return 'image'
}
if (url.match(/\.(ogv|mp4|webm|mov)$/)) {
return 'video'
}
if (
url.match(
/\.(it|s3m|mod|umx|mp3|aac|m4a|flac|alac|ogg|oga|opus|wav|ape|midi?)$/,
)
) {
return 'audio'
}
return 'unknown'
}
export const fileMatchesSomeType = (types, file) =>
- types.some((type) => fileType(file.mimetype) === type)
+ types.includes(fileType(file.mimetype))
const fileTypeService = {
fileType,
fileTypeExt,
fileMatchesSomeType,
}
export default fileTypeService
diff --git a/src/services/notification_utils/notification_utils.js b/src/services/notification_utils/notification_utils.js
index 6cb3dbc191..9e443bf8bd 100644
--- a/src/services/notification_utils/notification_utils.js
+++ b/src/services/notification_utils/notification_utils.js
@@ -1,212 +1,204 @@
import { showDesktopNotification } from '../desktop_notification_utils/desktop_notification_utils.js'
import { muteFilterHits } from '../status_parser/status_parser.js'
import FaviconService from 'src/services/favicon_service/favicon_service.js'
export const ACTIONABLE_NOTIFICATION_TYPES = new Set([
'mention',
'pleroma:report',
'follow_request',
])
let cachedBadgeUrl = null
export const notificationsFromStore = (store) => store.state.notifications.data
const visibleTypes = (notificationVisibility) => {
return [
notificationVisibility.likes && 'like',
notificationVisibility.mentions && 'mention',
notificationVisibility.statuses && 'status',
notificationVisibility.repeats && 'repeat',
notificationVisibility.follows && 'follow',
notificationVisibility.followRequest && 'follow_request',
notificationVisibility.moves && 'move',
notificationVisibility.emojiReactions && 'pleroma:emoji_reaction',
notificationVisibility.reports && 'pleroma:report',
notificationVisibility.polls && 'poll',
- ].filter((_) => _)
+ ].filter(Boolean)
}
const statusNotifications = new Set([
'like',
'mention',
'status',
'repeat',
'pleroma:emoji_reaction',
'poll',
])
export const isStatusNotification = (type) => statusNotifications.has(type)
export const isValidNotification = (notification) => {
if (isStatusNotification(notification.type) && !notification.status) {
return false
}
return true
}
const sortById = (a, b) => {
const seqA = Number(a.id)
const seqB = Number(b.id)
const isSeqA = !Number.isNaN(seqA)
const isSeqB = !Number.isNaN(seqB)
if (isSeqA && isSeqB) {
return seqA > seqB ? -1 : 1
} else if (isSeqA && !isSeqB) {
return 1
} else if (!isSeqA && isSeqB) {
return -1
} else {
return a.id > b.id ? -1 : 1
}
}
const isMutedNotification = (muteFilters, notification) => {
if (!notification.status) return false
if (notification.status.muted) return true
return muteFilterHits(muteFilters, notification.status).length > 0
}
export const maybeShowNotification = (
store,
notificationVisibility,
muteFilters,
notification,
i18n,
) => {
const rootState = store.rootState || store.state
if (notification.seen) return
if (!visibleTypes(notificationVisibility).includes(notification.type)) return
if (
notification.type === 'mention' &&
isMutedNotification(muteFilters, notification)
)
return
const notificationObject = prepareNotificationObject(notification, i18n)
showDesktopNotification(rootState, notificationObject)
}
export const filteredNotificationsFromStore = (
store,
notificationVisibility,
types,
) => {
// map is just to clone the array since sort mutates it and it causes some issues
- const sortedNotifications = notificationsFromStore(store)
- .map((_) => _)
- .sort(sortById)
+ const sortedNotifications = notificationsFromStore(store).sort(sortById)
// TODO implement sorting elsewhere and make it optional
return sortedNotifications.filter((notification) =>
(types || visibleTypes(notificationVisibility)).includes(notification.type),
)
}
export const unseenNotificationsFromStore = (
store,
notificationVisibility,
ignoreInactionableSeen,
) => {
return filteredNotificationsFromStore(store, notificationVisibility).filter(
({ seen, type }) => {
if (!ignoreInactionableSeen) return !seen
if (seen) return false
return ACTIONABLE_NOTIFICATION_TYPES.has(type)
},
)
}
export const prepareNotificationObject = (notification, i18n) => {
if (cachedBadgeUrl === null) {
const favicons = FaviconService.getOriginalFavicons()
const favicon = favicons[favicons.length - 1]
if (!favicon) {
cachedBadgeUrl = 'about:blank'
} else {
cachedBadgeUrl = favicon.favimg.src
}
}
const notifObj = {
tag: notification.id,
type: notification.type,
badge: cachedBadgeUrl,
}
const status = notification.status
const title = notification.from_profile.name
notifObj.title = title
notifObj.icon = notification.from_profile.profile_image_url
let i18nString
switch (notification.type) {
case 'like':
i18nString = 'favorited_you'
break
case 'status':
i18nString = 'subscribed_status'
break
case 'repeat':
i18nString = 'repeated_you'
break
case 'follow':
i18nString = 'followed_you'
break
case 'move':
i18nString = 'migrated_to'
break
case 'follow_request':
i18nString = 'follow_request'
break
case 'pleroma:report':
i18nString = 'submitted_report'
break
case 'poll':
i18nString = 'poll_ended'
break
}
if (notification.type === 'pleroma:emoji_reaction') {
notifObj.body = i18n.t('notifications.reacted_with', [notification.emoji])
} else if (i18nString) {
notifObj.body = i18n.t('notifications.' + i18nString)
} else if (isStatusNotification(notification.type)) {
notifObj.body = notification.status.text
}
// Shows first attached non-nsfw image, if any. Should add configuration for this somehow...
- if (
- status &&
- status.attachments &&
- status.attachments.length > 0 &&
- !status.nsfw &&
- status.attachments[0].mimetype.startsWith('image/')
- ) {
+ if (!status.nsfw && status?.attachments?.[0]?.mimetype.startsWith('image/')) {
notifObj.image = status.attachments[0].url
}
return notifObj
}
export const countExtraNotifications = (
store,
mergedConfig,
unreadChatsCount,
unreadAnnouncementCount,
) => {
const rootGetters = store.rootGetters || store.getters
if (!mergedConfig.showExtraNotifications) {
return 0
}
return [
mergedConfig.showChatsInExtraNotifications ? unreadChatsCount : 0,
mergedConfig.showAnnouncementsInExtraNotifications
? unreadAnnouncementCount
: 0,
mergedConfig.showFollowRequestsInExtraNotifications
? rootGetters.followRequestCount
: 0,
].reduce((a, c) => a + c, 0)
}
diff --git a/src/services/ruffle_service/ruffle_service.js b/src/services/ruffle_service/ruffle_service.js
index c029330aaa..298f32df3f 100644
--- a/src/services/ruffle_service/ruffle_service.js
+++ b/src/services/ruffle_service/ruffle_service.js
@@ -1,48 +1,48 @@
const createRuffleService = () => {
let ruffleInstance = null
const getRuffle = async () =>
new Promise((resolve, reject) => {
if (ruffleInstance) {
resolve(ruffleInstance)
return
}
// Ruffle needs these to be set before it's loaded
// https://github.com/ruffle-rs/ruffle/issues/3952
window.RufflePlayer = {}
window.RufflePlayer.config = {
polyfills: false,
publicPath: '/static/ruffle',
}
// Currently it's seems like a better way of loading ruffle
// because it needs the wasm publically accessible, but it needs path to it
// and filename of wasm seems to be pseudo-randomly generated (is it a hash?)
const script = document.createElement('script')
// see webpack config, using CopyPlugin to copy it from node_modules
// provided via ruffle-mirror
script.src = '/static/ruffle/ruffle.js'
script.type = 'text/javascript'
script.onerror = (e) => {
- reject(e)
+ reject(new Error('Ruffle script errorred', e))
}
script.onabort = (e) => {
- reject(e)
+ reject(new Error('Ruffle script aborted', e))
}
script.oncancel = (e) => {
- reject(e)
+ reject(new Error('Ruffle script cancelled', e))
}
script.onload = () => {
ruffleInstance = window.RufflePlayer
resolve(ruffleInstance)
}
document.body.appendChild(script)
})
return { getRuffle }
}
const RuffleService = createRuffleService()
export default RuffleService
diff --git a/src/services/status_parser/status_parser.js b/src/services/status_parser/status_parser.js
index a011fe2654..fc498f540e 100644
--- a/src/services/status_parser/status_parser.js
+++ b/src/services/status_parser/status_parser.js
@@ -1,92 +1,92 @@
export const muteFilterHits = (muteFilters, status) => {
const statusText = status.text.toLowerCase()
const statusSummary = status.summary.toLowerCase()
const replyToUser = status.in_reply_to_screen_name?.toLowerCase()
const poster = status.user.screen_name?.toLowerCase()
const mentions = (status.attentions || []).map((att) =>
att.screen_name.toLowerCase(),
)
return muteFilters
.toSorted((a, b) => b.order - a.order)
.map((filter) => {
const {
hide,
expires,
name,
value,
type,
enabled,
caseSensitive = false,
} = filter
if (!enabled) return false
if (value === '') return false
if (expires !== null && expires < Date.now()) return false
switch (type) {
case 'word': {
let match = false
if (caseSensitive) {
match = statusText.includes(value) || statusSummary.includes(value)
} else {
const lowercaseValue = value.toLowerCase()
match =
statusText.toLowerCase().includes(lowercaseValue) ||
statusSummary.toLowerCase().includes(lowercaseValue)
}
if (match) {
return { hide, name }
}
break
}
case 'regexp': {
try {
const re = new RegExp(value, caseSensitive ? '' : 'i')
if (re.test(statusText) || re.test(statusSummary)) {
return { hide, name }
}
return false
} catch {
return false
}
}
case 'user': {
let match = false
if (caseSensitive) {
match =
poster.includes(value) ||
replyToUser.includes(value) ||
mentions.some((mention) => mention.includes(value))
} else {
const lowercaseValue = value.toLowerCase()
match =
poster.toLowerCase().includes(lowercaseValue) ||
replyToUser.toLowerCase().includes(lowercaseValue) ||
mentions.some((mention) =>
mention.toLowerCase().includes(lowercaseValue),
)
}
if (match) {
return { hide, name }
}
break
}
case 'user_regexp': {
try {
const re = new RegExp(value, caseSensitive ? '' : 'i')
if (
re.test(poster) ||
re.test(replyToUser) ||
mentions.some((mention) => re.test(mention))
) {
return { hide, name }
}
return false
} catch {
return false
}
}
}
})
- .filter((_) => _)
+ .filter(Boolean)
}
diff --git a/src/services/style_setter/style_setter.js b/src/services/style_setter/style_setter.js
index 43eaeb3e5c..6c289590e2 100644
--- a/src/services/style_setter/style_setter.js
+++ b/src/services/style_setter/style_setter.js
@@ -1,372 +1,376 @@
import sum from 'hash-sum'
import localforage from 'localforage'
import { chunk, throttle } from 'lodash'
import { getCssRules } from '../theme_data/css_utils.js'
import { getEngineChecksum, init } from '../theme_data/theme_data_3.service.js'
import { useMergedConfigStore } from 'src/stores/merged_config.js'
import { useSyncConfigStore } from 'src/stores/sync_config.js'
import { promisedRequest } from 'src/api/helpers.js'
import { ROOT_CONFIG } from 'src/modules/default_config_state.js'
// On platforms where this is not supported, it will return undefined
// Otherwise it will return an array
const supportsAdoptedStyleSheets = !!document.adoptedStyleSheets
const stylesheets = {}
export const createStyleSheet = (id, priority = 1000) => {
if (stylesheets[id]) return stylesheets[id]
const newStyleSheet = {
rules: [],
ready: false,
priority,
clear() {
this.rules = []
},
addRule(rule) {
let newRule = rule
if (!CSS.supports?.('backdrop-filter', 'blur()')) {
- newRule = newRule.replace(/backdrop-filter:[^;]+;/g, '') // Remove backdrop-filter
+ newRule = newRule.replaceAll(/backdrop-filter:[^;]+;/g, '') // Remove backdrop-filter
}
if (newRule.startsWith('::-webkit')) {
if (typeof CSS.supports !== 'function') return
// firefox doesn't like invalid selectors
const fullWebkitScrollbarSupport =
CSS.supports('selector(::-webkit-scrollbar)') &&
CSS.supports('selector(::-webkit-scrollbar-button)') &&
CSS.supports('selector(::-webkit-resizer)') &&
CSS.supports('selector(::-webkit-scrollbar-thumb)')
if (!fullWebkitScrollbarSupport) return
}
this.rules.push(
- newRule.replace(/var\(--shadowFilter\)[^;]*;/g, ''), // Remove shadowFilter references
+ newRule.replaceAll(/var\(--shadowFilter\)[^;]*;/g, ''), // Remove shadowFilter references
)
},
}
stylesheets[id] = newStyleSheet
return newStyleSheet
}
export const adoptStyleSheets = throttle(() => {
if (supportsAdoptedStyleSheets) {
document.adoptedStyleSheets = Object.values(stylesheets)
.filter((x) => x.ready)
.sort((a, b) => a.priority - b.priority)
.map((sheet) => {
const css = new CSSStyleSheet()
sheet.rules.forEach((r) => {
try {
css.insertRule(r)
} catch (e) {
console.warn('Error inserting rule:', e, r)
}
})
return css
})
} else {
const holder = document.getElementById('custom-styles-holder')
for (let i = holder.sheet.cssRules.length - 1; i >= 0; --i) {
holder.sheet.deleteRule(i)
}
Object.values(stylesheets)
.filter((x) => x.ready)
.sort((a, b) => a.priority - b.priority)
.forEach((sheet) => {
sheet.rules.forEach((r) => holder.sheet.insertRule(r))
})
}
// Some older browsers do not support document.adoptedStyleSheets.
// In this case, we use the <style> elements.
// Since the <style> elements we need are already in the DOM, there
// is nothing to do here.
}, 500)
const EAGER_STYLE_ID = 'pleroma-eager-styles'
const LAZY_STYLE_ID = 'pleroma-lazy-styles'
+export const hasInvalidCachedThemeRules = (data) =>
+ data
+ .flat()
+ .some((rule) => /--(?:mono)?font:\s*\[object Object\](?:;|$)/i.test(rule))
+
const generateTheme = (inputRuleset, callbacks, debug) => {
const {
onNewRule = () => {
/* no-op */
},
onLazyFinished = () => {
/* no-op */
},
onEagerFinished = () => {
/* no-op */
},
} = callbacks
const themes3 = init({
inputRuleset,
debug,
})
getCssRules(themes3.eager, debug).forEach((rule) => {
// Hacks to support multiple selectors on same component
onNewRule(rule, false)
})
onEagerFinished()
// Optimization - instead of processing all lazy rules in one go, process them in small chunks
// so that UI can do other things and be somewhat responsive while less important rules are being
// processed
let counter = 0
const chunks = chunk(themes3.lazy, 200)
// let t0 = performance.now()
const processChunk = () => {
const chunk = chunks[counter]
Promise.all(chunk.map((x) => x())).then((result) => {
getCssRules(result.filter(Boolean), debug).forEach((rule) => {
onNewRule(rule, true)
})
// const t1 = performance.now()
// console.debug('Chunk ' + counter + ' took ' + (t1 - t0) + 'ms')
// t0 = t1
counter += 1
if (counter < chunks.length) {
setTimeout(processChunk, 0)
} else {
onLazyFinished()
}
})
}
return { lazyProcessFunc: processChunk }
}
export const tryLoadCache = async () => {
console.info('Trying to load compiled theme data from cache')
const cache = await localforage.getItem('pleromafe-theme-cache')
if (!cache) return null
try {
if (
cache.engineChecksum === getEngineChecksum() &&
cache.checksum !== undefined &&
- cache.checksum === useMergedConfigStore().mergedConfig.themeChecksum
+ cache.checksum === useMergedConfigStore().mergedConfig.themeChecksum &&
+ !hasInvalidCachedThemeRules(cache.data)
) {
const eagerStyles = createStyleSheet(EAGER_STYLE_ID, 10)
const lazyStyles = createStyleSheet(LAZY_STYLE_ID, 20)
cache.data[0].forEach((rule) => eagerStyles.addRule(rule))
cache.data[1].forEach((rule) => lazyStyles.addRule(rule))
eagerStyles.ready = true
lazyStyles.ready = true
console.info(`Loaded theme from cache`)
return true
} else {
console.warn("Checksums don't match, cache not usable, clearing")
localStorage.removeItem('pleroma-fe-theme-cache')
}
} catch (e) {
console.error('Failed to load theme cache:', e)
return false
}
}
export const applyTheme = (
input,
onEagerFinish = () => {
/* no-op */
},
onFinish = () => {
/* no-op */
},
debug,
) => {
const eagerStyles = createStyleSheet(EAGER_STYLE_ID, 10)
const lazyStyles = createStyleSheet(LAZY_STYLE_ID, 20)
eagerStyles.clear()
lazyStyles.clear()
const { lazyProcessFunc } = generateTheme(
input,
{
onNewRule(rule, isLazy) {
if (isLazy) {
lazyStyles.addRule(rule)
} else {
eagerStyles.addRule(rule)
}
},
onEagerFinished() {
eagerStyles.ready = true
adoptStyleSheets()
onEagerFinish()
console.info(
'Eager part of theme finished, waiting for lazy part to finish to store cache',
)
},
onLazyFinished() {
lazyStyles.ready = true
adoptStyleSheets()
const data = [eagerStyles.rules, lazyStyles.rules]
const checksum = sum(data)
const cache = {
checksum,
engineChecksum: getEngineChecksum(),
data,
}
useSyncConfigStore().setSimplePrefAndSave({
path: 'themeChecksum',
value: checksum,
})
onFinish(cache)
localforage.setItem('pleromafe-theme-cache', cache)
console.info('Theme cache stored')
},
},
debug,
)
setTimeout(lazyProcessFunc, 0)
}
const extractStyleConfig = ({
sidebarColumnWidth,
contentColumnWidth,
notifsColumnWidth,
themeEditorMinWidth,
emojiReactionsScale,
emojiSize,
navbarSize,
panelHeaderSize,
textSize,
forcedRoundness,
}) => {
const result = {
sidebarColumnWidth,
contentColumnWidth,
notifsColumnWidth,
themeEditorMinWidth:
Number.parseInt(themeEditorMinWidth) === 0
? 'fit-content'
: themeEditorMinWidth,
emojiReactionsScale,
emojiSize,
navbarSize,
panelHeaderSize,
textSize,
}
switch (forcedRoundness) {
case 'disable':
break
case '0':
result.forcedRoundness = '0'
break
case '1':
result.forcedRoundness = '1px'
break
case '2':
result.forcedRoundness = '0.4rem'
break
default:
}
return result
}
const defaultStyleConfig = extractStyleConfig(ROOT_CONFIG)
export const applyStyleConfig = (input) => {
const config = extractStyleConfig(input)
if (config === defaultStyleConfig) {
return
}
const rules = Object.entries(config)
.filter(([, v]) => v)
.map(([k, v]) => `--${k}: ${v}`)
.join(';')
const styleSheet = createStyleSheet('theme-holder', 30)
styleSheet.clear()
styleSheet.addRule(`:root { ${rules} }`)
// TODO find a way to make this not apply to theme previews
if (Object.hasOwn(config, 'forcedRoundness')) {
styleSheet.addRule(` *:not(.preview-block) {
--roundness: var(--forcedRoundness) !important;
}`)
}
styleSheet.ready = true
adoptStyleSheets()
}
const noop = (x) => x
export const getResourcesIndex = async (url, parser = noop) => {
const cache = 'no-store'
const customUrl = url.replace(/\.(\w+)$/, '.custom.$1')
let builtin
let custom
const resourceTransform = (resources) => {
return Object.entries(resources).map(([k, v]) => {
if (typeof v === 'object') {
- return [k, () => Promise.resolve(v)]
+ return [k, () => v]
} else if (typeof v === 'string') {
return [
k,
() =>
promisedRequest({
url: v,
cache,
forceContentType: parser === noop ? null : 'text/plain',
})
.then(({ data: text }) => parser(text))
.catch((e) => {
console.error(e)
return null
}),
]
} else {
console.error(`Unknown resource format - ${k} is a ${typeof v}`)
return [k, null]
}
})
}
try {
const { data: builtinData } = await promisedRequest({ url, cache })
builtin = resourceTransform(builtinData)
} catch {
builtin = []
console.warn(`Builtin resources at ${url} unavailable`)
}
try {
const { data: customData } = await promisedRequest({
url: customUrl,
cache,
})
custom = resourceTransform(customData)
} catch {
custom = []
console.warn(`Custom resources at ${customUrl} unavailable`)
}
const total = [...custom, ...builtin]
if (total.length === 0) {
- return Promise.reject(
- new Error(
- `Resource at ${url} and ${customUrl} completely unavailable. Panicking`,
- ),
+ throw new Error(
+ `Resource at ${url} and ${customUrl} completely unavailable. Panicking`,
)
}
- return Promise.resolve(Object.fromEntries(total))
+ return Object.fromEntries(total)
}
diff --git a/src/services/sw/sw.js b/src/services/sw/sw.js
index b45409b283..ef8200a4d4 100644
--- a/src/services/sw/sw.js
+++ b/src/services/sw/sw.js
@@ -1,187 +1,187 @@
/* global process */
function urlBase64ToUint8Array(base64String) {
const padding = '='.repeat((4 - (base64String.length % 4)) % 4)
- const base64 = (base64String + padding).replace(/-/g, '+').replace(/_/g, '/')
+ const base64 = (base64String + padding)
+ .replaceAll('-', '+')
+ .replaceAll('_', '/')
const rawData = window.atob(base64)
return Uint8Array.from([...rawData].map((char) => char.codePointAt(0)))
}
export function isSWSupported() {
return 'serviceWorker' in navigator
}
function isPushSupported() {
return 'PushManager' in window
}
function getOrCreateServiceWorker() {
if (!isSWSupported()) return
const swType = process.env.HAS_MODULE_SERVICE_WORKER ? 'module' : 'classic'
return navigator.serviceWorker
.register('/sw-pleroma.js', { type: swType })
.catch((err) =>
console.error('Unable to get or create a service worker.', err),
)
}
function subscribePush(registration, isEnabled, vapidPublicKey) {
- if (!isEnabled)
- return Promise.reject(new Error('Web Push is disabled in config'))
- if (!vapidPublicKey)
- return Promise.reject(new Error('VAPID public key is not found'))
+ if (!isEnabled) throw new Error('Web Push is disabled in config')
+ if (!vapidPublicKey) throw new Error('VAPID public key is not found')
const subscribeOptions = {
userVisibleOnly: false,
applicationServerKey: urlBase64ToUint8Array(vapidPublicKey),
}
return registration.pushManager.subscribe(subscribeOptions)
}
-function unsubscribePush(registration) {
+async function unsubscribePush(registration) {
return registration.pushManager.getSubscription().then((subscription) => {
if (subscription === null) {
- return Promise.resolve('No subscription')
+ return 'No subscription'
}
return subscription.unsubscribe()
})
}
function deleteSubscriptionFromBackEnd(token) {
return fetch('/api/v1/push/subscription/', {
method: 'DELETE',
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${token}`,
},
}).then((response) => {
if (!response.ok) throw new Error('Bad status code from server.')
return response
})
}
function sendSubscriptionToBackEnd(
subscription,
token,
notificationVisibility,
) {
return window
.fetch('/api/v1/push/subscription/', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${token}`,
},
body: JSON.stringify({
subscription,
data: {
alerts: {
follow: notificationVisibility.follows,
favourite: notificationVisibility.likes,
mention: notificationVisibility.mentions,
reblog: notificationVisibility.repeats,
move: notificationVisibility.moves,
},
},
}),
})
.then((response) => {
if (!response.ok) throw new Error('Bad status code from server.')
return response.json()
})
.then((responseData) => {
if (!responseData.id) throw new Error('Bad response from server.')
return responseData
})
}
export async function initServiceWorker(store) {
if (!isSWSupported()) return
await getOrCreateServiceWorker()
navigator.serviceWorker.addEventListener('message', (event) => {
const { dispatch } = store
const { type, ...rest } = event.data
switch (type) {
case 'notificationClicked':
dispatch('notificationClicked', { id: rest.id })
}
})
}
export async function showDesktopNotification(content) {
if (!isSWSupported) return
const { active: sw } =
(await window.navigator.serviceWorker.getRegistration()) || {}
if (!sw) return console.error('No serviceworker found!')
sw.postMessage({ type: 'desktopNotification', content })
}
export async function closeDesktopNotification({ id }) {
if (!isSWSupported) return
const { active: sw } =
(await window.navigator.serviceWorker.getRegistration()) || {}
if (!sw) return console.error('No serviceworker found!')
if (id >= 0) {
sw.postMessage({ type: 'desktopNotificationClose', content: { id } })
} else {
sw.postMessage({ type: 'desktopNotificationClose', content: { all: true } })
}
}
export async function updateFocus() {
if (!isSWSupported) return
const { active: sw } =
(await window.navigator.serviceWorker.getRegistration()) || {}
if (!sw) return console.error('No serviceworker found!')
sw.postMessage({ type: 'updateFocus' })
}
export function registerPushNotifications(
isEnabled,
vapidPublicKey,
token,
notificationVisibility,
) {
if (isPushSupported()) {
getOrCreateServiceWorker()
.then((registration) =>
subscribePush(registration, isEnabled, vapidPublicKey),
)
.then((subscription) =>
sendSubscriptionToBackEnd(subscription, token, notificationVisibility),
)
.catch((e) =>
console.warn(`Failed to setup Web Push Notifications: ${e.message}`),
)
}
}
export function unregisterPushNotifications(token) {
if (isPushSupported()) {
getOrCreateServiceWorker()
.then((registration) => {
return unsubscribePush(registration).then((result) => [
registration,
result,
])
})
.then(([, unsubResult]) => {
if (unsubResult === 'No subscription') return
if (!unsubResult) {
console.warn("Push subscription cancellation wasn't successful")
}
return deleteSubscriptionFromBackEnd(token)
})
.catch((e) => {
console.warn(`Failed to disable Web Push Notifications: ${e.message}`)
})
}
}
export const shouldCache = process.env.NODE_ENV === 'production'
export const cacheKey = 'pleroma-fe'
export const emojiCacheKey = 'pleroma-fe-emoji'
export const clearCache = (key) => caches.delete(key)
export { getOrCreateServiceWorker }
diff --git a/src/services/theme_data/css_utils.js b/src/services/theme_data/css_utils.js
index 990db652c5..b728f68d2b 100644
--- a/src/services/theme_data/css_utils.js
+++ b/src/services/theme_data/css_utils.js
@@ -1,190 +1,190 @@
import { convert } from 'chromatism'
import { hex2rgb, rgba2css } from '../color_convert/color_convert.js'
export const getCssColorString = (color, alpha = 1) =>
rgba2css({ ...convert(color).rgb, a: alpha })
export const getCssShadow = (input, usesDropShadow) => {
if (input.length === 0) {
return 'none'
}
return input
.filter((_) => (usesDropShadow ? _.inset : _))
.map((shad) =>
[shad.x, shad.y, shad.blur, shad.spread]
.map((_) => _ + 'px ')
.concat([
getCssColorString(shad.color, shad.alpha),
shad.inset ? 'inset' : '',
])
.join(' '),
)
.join(', ')
}
export const getCssShadowFilter = (input) => {
if (input.length === 0) {
return 'none'
}
return (
input
// drop-shadow doesn't support inset or spread
.filter((shad) => !shad.inset && Number(shad.spread) === 0)
.map((shad) =>
[
shad.x,
shad.y,
// drop-shadow's blur is twice as strong compared to box-shadow
shad.blur / 2,
]
.map((_) => _ + 'px')
.concat([getCssColorString(shad.color, shad.alpha)])
.join(' '),
)
.map((_) => `drop-shadow(${_})`)
.join(' ')
)
}
// `debug` changes what backgrounds are used to "stacked" solid colors so you can see
// what theme engine "thinks" is actual background color is for purposes of text color
// generation and for when --stacked variable is used
export const getCssRules = (rules, debug) =>
rules
.map((rule) => {
let selector = rule.selector
if (!selector) {
selector = 'html'
}
const header = selector + ' {'
const footer = '}'
const virtualDirectives = Object.entries(rule.virtualDirectives || {})
.map(([k, v]) => {
return ' ' + k + ': ' + v
})
.join(';\n')
const directives = Object.entries(rule.directives)
.map(([k, v]) => {
switch (k) {
case 'roundness': {
return ' ' + ['--roundness: ' + v + 'px'].join(';\n ')
}
case 'shadow': {
if (!rule.dynamicVars.shadow) {
return ''
}
return (
' ' +
[
'--shadow: ' + getCssShadow(rule.dynamicVars.shadow),
'--shadowFilter: ' +
getCssShadowFilter(rule.dynamicVars.shadow),
'--shadowInset: ' +
getCssShadow(rule.dynamicVars.shadow, true),
].join(';\n ')
)
}
case 'background': {
if (debug) {
return `
--background: ${getCssColorString(rule.dynamicVars.stacked)};
background-color: ${getCssColorString(rule.dynamicVars.stacked)};
`
}
if (v === 'transparent') {
if (rule.component === 'Root') return null
return [
rule.directives.backgroundNoCssColor !== 'yes'
? 'background-color: ' + v
: '',
' --background: ' + v,
]
.filter(Boolean)
.join(';\n')
}
const color = getCssColorString(
rule.dynamicVars.background,
rule.directives.opacity,
)
const cssDirectives = ['--background: ' + color]
if (rule.directives.backgroundNoCssColor !== 'yes') {
cssDirectives.push('background-color: ' + color)
}
return cssDirectives.filter(Boolean).join(';\n')
}
case 'blur': {
const cssDirectives = []
if (rule.directives.opacity < 1) {
cssDirectives.push(`--backdrop-filter: blur(${v}) `)
if (rule.directives.backgroundNoCssColor !== 'yes') {
cssDirectives.push(`backdrop-filter: blur(${v}) `)
}
}
return cssDirectives.join(';\n')
}
case 'font': {
return 'font-family: ' + v
}
case 'textColor': {
if (rule.directives.textNoCssColor === 'yes') {
return ''
}
return 'color: ' + v
}
default:
if (k.startsWith('--')) {
const [type, value] = v.split('|').map((x) => x.trim())
switch (type) {
case 'color': {
const color = rule.dynamicVars[k]
if (typeof color === 'string') {
return k + ': ' + rgba2css(hex2rgb(color))
} else {
return k + ': ' + rgba2css(color)
}
}
case 'generic':
return k + ': ' + value
default:
return null
}
}
return null
}
})
.filter(Boolean)
.map((x) => ' ' + x + ';')
.join('\n')
return [
header,
directives,
rule.component === 'Text' &&
- rule.state.indexOf('faint') < 0 &&
+ !rule.state.includes('faint') &&
rule.directives.textNoCssColor !== 'yes'
? ' color: var(--text);'
: '',
virtualDirectives,
footer,
]
.filter(Boolean)
.join('\n')
})
.filter(Boolean)
export const getScopedVersion = (rules, newScope) => {
return rules.map((x) => {
if (x.startsWith('html')) {
return x.replace('html', newScope)
} else if (x.startsWith('#content')) {
return x.replace('#content', newScope)
} else {
return newScope + ' > ' + x
}
})
}
diff --git a/src/services/theme_data/iss_utils.js b/src/services/theme_data/iss_utils.js
index e5aa5cd15c..d42da37800 100644
--- a/src/services/theme_data/iss_utils.js
+++ b/src/services/theme_data/iss_utils.js
@@ -1,223 +1,216 @@
import { sortBy } from 'lodash'
// "Unrolls" a tree structure of item: { parent: { ...item2, parent: { ...item3, parent: {...} } }}
// into an array [item2, item3] for iterating
export const unroll = (item) => {
const out = []
let currentParent = item
while (currentParent) {
out.push(currentParent)
currentParent = currentParent.parent
}
return out
}
// This gives you an array of arrays of all possible unique (i.e. order-insensitive) combinations
// Can only accept primitives. Duplicates are not supported and can cause unexpected behavior
export const getAllPossibleCombinations = (array) => {
const combos = [array.map((x) => [x])]
for (let comboSize = 2; comboSize <= array.length; comboSize++) {
const previous = combos[combos.length - 1]
const newCombos = previous.map((self) => {
const selfSet = new Set()
self.forEach((x) => selfSet.add(x))
const nonSelf = array.filter((x) => !selfSet.has(x))
return nonSelf.map((x) => [...self, x])
})
- const flatCombos = newCombos.reduce((acc, x) => [...acc, ...x], [])
+ const flatCombos = newCombos.flat()
const uniqueComboStrings = new Set()
const uniqueCombos = flatCombos.map(sortBy).filter((x) => {
if (uniqueComboStrings.has(x.join())) {
return false
} else {
uniqueComboStrings.add(x.join())
return true
}
})
combos.push(uniqueCombos)
}
- return combos.reduce((acc, x) => [...acc, ...x], [])
+ return combos.flat()
}
/**
* Converts rule, parents and their criteria into a CSS (or path if ignoreOutOfTreeSelector == true)
* selector.
*
* "path" here refers to "fake" selector that cannot be actually used in UI but is used for internal
* purposes
*
* @param {Object} components - object containing all components definitions
*
* @returns {Function}
* @param {Object} rule - rule in question to convert to CSS selector
* @param {boolean} ignoreOutOfTreeSelector - wthether to ignore aformentioned field in
* component definition and use selector
* @param {boolean} isParent - (mostly) internal argument used when recursing
*
* @returns {String} CSS selector (or path)
*/
export const genericRuleToSelector =
(components) => (rule, ignoreOutOfTreeSelector, liteMode, children) => {
const isParent = !!children
if (!rule && !isParent) return null
const component = components[rule.component]
const { states = {}, variants = {}, outOfTreeSelector } = component
const expand = (array = [], subArray = []) => {
if (array.length === 0) return subArray.map((x) => [x])
if (subArray.length === 0) return array.map((x) => [x])
return array
.map((a) => {
return subArray.map((b) => [a, b])
})
.flat()
}
let componentSelectors = Array.isArray(component.selector)
? component.selector
: [component.selector]
if (ignoreOutOfTreeSelector || liteMode)
componentSelectors = [componentSelectors[0]]
componentSelectors = componentSelectors.map((selector) => {
if (selector === ':root') {
return ''
} else if (isParent) {
return selector
} else {
if (outOfTreeSelector && !ignoreOutOfTreeSelector)
return outOfTreeSelector
return selector
}
})
const applicableVariantName = rule.variant || 'normal'
let variantSelectors = null
if (applicableVariantName !== 'normal') {
variantSelectors = variants[applicableVariantName]
} else {
variantSelectors = variants?.normal ?? ''
}
variantSelectors = Array.isArray(variantSelectors)
? variantSelectors
: [variantSelectors]
if (ignoreOutOfTreeSelector || liteMode)
variantSelectors = [variantSelectors[0]]
const applicableStates = (rule.state || []).filter((x) => x !== 'normal')
// const applicableStates = (rule.state || [])
const statesSelectors = applicableStates.map((state) => {
const selector = states[state] || ''
let arraySelector = Array.isArray(selector) ? selector : [selector]
if (ignoreOutOfTreeSelector || liteMode)
arraySelector = [arraySelector[0]]
- arraySelector
- .sort((a) => {
- if (a.startsWith(':')) return 1
- if (/^[a-z]/.exec(a)) return -1
- else return 0
- })
- .join('')
return arraySelector
})
const statesSelectorsFlat = statesSelectors.reduce((acc, s) => {
return expand(acc, s).map((st) => st.join(''))
}, [])
const componentVariant = expand(componentSelectors, variantSelectors).map(
(cv) => cv.join(''),
)
const componentVariantStates = expand(
componentVariant,
statesSelectorsFlat,
).map((cvs) => cvs.join(''))
const selectors = expand(componentVariantStates, children).map((cvsc) =>
cvsc.join(' '),
)
/*
*/
if (rule.parent) {
return genericRuleToSelector(components)(
rule.parent,
ignoreOutOfTreeSelector,
liteMode,
selectors,
)
}
return selectors.join(', ').trim()
}
/**
* Check if combination matches
*
* @param {Object} criteria - criteria to match against
* @param {Object} subject - rule/combination to check match
* @param {boolean} strict - strict checking:
* By default every variant and state inherits from "normal" state/variant
* so when checking if combination matches, it WILL match against "normal"
* state/variant. In strict mode inheritance is ignored an "normal" does
* not match
*/
export const combinationsMatch = (criteria, subject, strict) => {
if (criteria.component !== subject.component) return false
// All variants inherit from normal
if (subject.variant !== 'normal' || strict) {
if (criteria.variant !== subject.variant) return false
}
// Subject states > 1 essentially means state is "normal" and therefore matches
if (subject.state.length > 1 || strict) {
const subjectStatesSet = new Set(subject.state)
const criteriaStatesSet = new Set(criteria.state)
const setsAreEqual =
[...criteriaStatesSet].every((state) => subjectStatesSet.has(state)) &&
[...subjectStatesSet].every((state) => criteriaStatesSet.has(state))
if (!setsAreEqual) return false
}
return true
}
/**
* Search for rule that matches `criteria` in set of rules
* meant to be used in a ruleset.filter() function
*
* @param {Object} criteria - criteria to search for
* @param {boolean} strict - whether search strictly or not (see combinationsMatch)
*
* @return function that returns true/false if subject matches
*/
export const findRules = (criteria, strict) => (subject) => {
// If we searching for "general" rules - ignore "specific" ones
if (criteria.parent === null && !!subject.parent) return false
if (!combinationsMatch(criteria, subject, strict)) return false
if (criteria.parent !== undefined && criteria.parent !== null) {
if (!subject.parent && !strict) return true
const pathCriteria = unroll(criteria)
const pathSubject = unroll(subject)
if (pathCriteria.length < pathSubject.length) return false
// Search: .a .b .c
// Matches: .a .b .c; .b .c; .c; .z .a .b .c
// Does not match .a .b .c .d, .a .b .e
for (let i = 0; i < pathCriteria.length; i++) {
const criteriaParent = pathCriteria[i]
const subjectParent = pathSubject[i]
if (!subjectParent) return true
if (!combinationsMatch(criteriaParent, subjectParent, strict))
return false
}
}
return true
}
// Pre-fills 'normal' state/variant if missing
export const normalizeCombination = (rule) => {
rule.variant = rule.variant ?? 'normal'
rule.state = [...new Set(['normal', ...(rule.state || [])])]
}
diff --git a/src/services/theme_data/theme2_to_theme3.js b/src/services/theme_data/theme2_to_theme3.js
index 51dc3e2c4b..48afe4ad92 100644
--- a/src/services/theme_data/theme2_to_theme3.js
+++ b/src/services/theme_data/theme2_to_theme3.js
@@ -1,574 +1,584 @@
import { convert } from 'chromatism'
import allKeys from './theme2_keys'
// keys that are meant to be used globally, i.e. what's the rest of the theme is based upon.
export const basePaletteKeys = new Set([
'bg',
'fg',
'text',
'link',
'accent',
'cBlue',
'cRed',
'cGreen',
'cOrange',
'wallpaper',
])
export const fontsKeys = new Set(['interface', 'input', 'post', 'postCode'])
export const opacityKeys = new Set([
'alert',
'alertPopup',
'bg',
'border',
'btn',
'faint',
'input',
'panel',
'popover',
'profileTint',
'underlay',
])
export const shadowsKeys = new Set([
'panel',
'topBar',
'popup',
'avatar',
'avatarStatus',
'panelHeader',
'button',
'buttonHover',
'buttonPressed',
'input',
])
export const radiiKeys = new Set([
'btn',
'input',
'checkbox',
'panel',
'avatar',
'avatarAlt',
'tooltip',
'attachment',
'chatMessage',
])
// Keys that are not available in editor and never meant to be edited
export const hiddenKeys = new Set(['profileBg', 'profileTint'])
export const extendedBasePrefixes = [
'border',
'icon',
'highlight',
'lightText',
'popover',
'panel',
'topBar',
'tab',
'btn',
'input',
'selectedMenu',
'alert',
'alertPopup',
'badge',
'post',
'selectedPost', // wrong nomenclature
'poll',
'chatBg',
'chatMessage',
]
export const nonComponentPrefixes = new Set([
'border',
'icon',
'highlight',
'lightText',
'chatBg',
])
export const extendedBaseKeys = Object.fromEntries(
extendedBasePrefixes.map((prefix) => [
prefix,
allKeys.filter((k) => {
if (prefix === 'alert') {
return k.startsWith(prefix) && !k.startsWith('alertPopup')
}
return k.startsWith(prefix)
}),
]),
)
// Keysets that are only really used intermideately, i.e. to generate other colors
export const temporary = new Set(['', 'highlight'])
export const temporaryColors = {}
export const convertTheme2To3 = (data) => {
data.colors.accent = data.colors.accent || data.colors.link
data.colors.link = data.colors.link || data.colors.accent
const generateRoot = () => {
const directives = {}
basePaletteKeys.forEach((key) => {
directives['--' + key] = 'color | ' + convert(data.colors[key]).hex
})
return {
component: 'Root',
directives,
}
}
const convertOpacity = () => {
const newRules = []
Object.keys(data.opacity || {}).forEach((key) => {
if (!opacityKeys.has(key) || data.opacity[key] === undefined) return null
const originalOpacity = data.opacity[key]
const rule = { source: '2to3' }
switch (key) {
case 'alert':
rule.component = 'Alert'
break
case 'alertPopup':
rule.component = 'Alert'
rule.parent = { component: 'Popover' }
break
case 'bg':
rule.component = 'Panel'
break
case 'border':
rule.component = 'Border'
break
case 'btn':
rule.component = 'Button'
break
case 'faint':
rule.component = 'Text'
rule.state = ['faint']
break
case 'input':
rule.component = 'Input'
break
case 'panel':
rule.component = 'PanelHeader'
break
case 'popover':
rule.component = 'Popover'
break
case 'profileTint':
return null
case 'underlay':
rule.component = 'Underlay'
break
}
switch (key) {
case 'alert':
case 'alertPopup':
case 'bg':
case 'btn':
case 'input':
case 'panel':
case 'popover':
case 'underlay':
rule.directives = { opacity: originalOpacity }
break
case 'faint':
case 'border':
rule.directives = { textOpacity: originalOpacity }
break
}
newRules.push(rule)
if (rule.component === 'Button') {
- newRules.push({ ...rule, component: 'ScrollbarElement' })
- newRules.push({ ...rule, component: 'Tab' })
- newRules.push({
- ...rule,
- component: 'Tab',
- state: ['active'],
- directives: { opacity: 0 },
- })
+ newRules.push(
+ { ...rule, component: 'ScrollbarElement' },
+ { ...rule, component: 'Tab' },
+ {
+ ...rule,
+ component: 'Tab',
+ state: ['active'],
+ directives: { opacity: 0 },
+ },
+ )
}
if (rule.component === 'Panel') {
newRules.push({ ...rule, component: 'Post' })
}
})
return newRules
}
const convertRadii = () => {
const newRules = []
Object.keys(data.radii || {}).forEach((key) => {
if (!radiiKeys.has(key) || data.radii[key] === undefined) return null
const originalRadius = data.radii[key]
const rule = { source: '2to3' }
switch (key) {
case 'btn':
rule.component = 'Button'
break
case 'tab':
rule.component = 'Tab'
break
case 'input':
rule.component = 'Input'
break
case 'checkbox':
rule.component = 'Input'
rule.variant = 'checkbox'
break
case 'panel':
rule.component = 'Panel'
break
case 'avatar':
rule.component = 'Avatar'
break
case 'avatarAlt':
rule.component = 'Avatar'
rule.variant = 'compact'
break
case 'tooltip':
rule.component = 'Popover'
break
case 'ChatMessage':
rule.component = 'Button'
break
}
rule.directives = {
roundness: originalRadius,
}
newRules.push(rule)
if (rule.component === 'Button') {
- newRules.push({ ...rule, component: 'ScrollbarElement' })
- newRules.push({ ...rule, component: 'Tab' })
+ newRules.push(
+ { ...rule, component: 'ScrollbarElement' },
+ { ...rule, component: 'Tab' },
+ )
}
})
return newRules
}
const convertFonts = () => {
const newRules = []
Object.keys(data.fonts || {}).forEach((key) => {
if (!fontsKeys.has(key)) return
if (!data.fonts[key]) return
const originalFont = data.fonts[key].family
const rule = { source: '2to3' }
switch (key) {
case 'interface':
case 'postCode':
rule.component = 'Root'
break
case 'input':
rule.component = 'Input'
break
case 'post':
rule.component = 'RichContent'
break
}
switch (key) {
case 'interface':
case 'input':
case 'post':
rule.directives = { '--font': 'generic | ' + originalFont }
break
case 'postCode':
rule.directives = { '--monoFont': 'generic | ' + originalFont }
newRules.push({ ...rule, component: 'RichContent' })
break
}
newRules.push(rule)
})
return newRules
}
const convertShadows = () => {
const newRules = []
Object.keys(data.shadows || {}).forEach((key) => {
if (!shadowsKeys.has(key)) return
const originalShadow = data.shadows[key]
const rule = { source: '2to3' }
switch (key) {
case 'panel':
rule.component = 'Panel'
break
case 'topBar':
rule.component = 'TopBar'
break
case 'popup':
rule.component = 'Popover'
break
case 'avatar':
rule.component = 'Avatar'
break
case 'avatarStatus':
rule.component = 'Avatar'
rule.parent = { component: 'Post' }
break
case 'panelHeader':
rule.component = 'PanelHeader'
break
case 'button':
rule.component = 'Button'
break
case 'buttonHover':
rule.component = 'Button'
rule.state = ['hover']
break
case 'buttonPressed':
rule.component = 'Button'
rule.state = ['pressed']
break
case 'input':
rule.component = 'Input'
break
}
rule.directives = {
shadow: originalShadow,
}
newRules.push(rule)
if (key === 'topBar') {
newRules.push({
...rule,
component: 'PanelHeader',
parent: { component: 'MobileDrawer' },
})
}
if (key === 'avatarStatus') {
newRules.push({ ...rule, parent: { component: 'Notification' } })
}
if (key === 'buttonPressed') {
- newRules.push({ ...rule, state: ['toggled'] })
- newRules.push({ ...rule, state: ['toggled', 'focus'] })
- newRules.push({ ...rule, state: ['pressed', 'focus'] })
- newRules.push({ ...rule, state: ['toggled', 'focus', 'hover'] })
- newRules.push({ ...rule, state: ['pressed', 'focus', 'hover'] })
+ newRules.push(
+ { ...rule, state: ['toggled'] },
+ { ...rule, state: ['toggled', 'focus'] },
+ { ...rule, state: ['pressed', 'focus'] },
+ { ...rule, state: ['toggled', 'focus', 'hover'] },
+ { ...rule, state: ['pressed', 'focus', 'hover'] },
+ )
}
if (rule.component === 'Button') {
- newRules.push({ ...rule, component: 'ScrollbarElement' })
- newRules.push({ ...rule, component: 'Tab' })
+ newRules.push(
+ { ...rule, component: 'ScrollbarElement' },
+ { ...rule, component: 'Tab' },
+ )
}
})
return newRules
}
const extendedRules = Object.entries(extendedBaseKeys).map(
([prefix, keys]) => {
if (nonComponentPrefixes.has(prefix)) return null
const rule = { source: '2to3' }
if (prefix === 'alertPopup') {
rule.component = 'Alert'
rule.parent = { component: 'Popover' }
} else if (prefix === 'selectedPost') {
rule.component = 'Post'
rule.state = ['selected']
} else if (prefix === 'selectedMenu') {
rule.component = 'MenuItem'
rule.state = ['hover']
} else if (prefix === 'chatMessageIncoming') {
rule.component = 'ChatMessage'
} else if (prefix === 'chatMessageOutgoing') {
rule.component = 'ChatMessage'
rule.variant = 'outgoing'
} else if (prefix === 'panel') {
rule.component = 'PanelHeader'
} else if (prefix === 'topBar') {
rule.component = 'TopBar'
} else if (prefix === 'chatMessage') {
rule.component = 'ChatMessage'
} else if (prefix === 'poll') {
rule.component = 'PollGraph'
} else if (prefix === 'btn') {
rule.component = 'Button'
} else {
rule.component = prefix[0].toUpperCase() + prefix.slice(1).toLowerCase()
}
return keys.map((key) => {
if (!data.colors[key]) return null
const leftoverKey = key.replace(prefix, '')
const parts = (leftoverKey || 'Bg').match(/[A-Z][a-z]*/g)
const last = parts.slice(-1)[0]
let newRule = { source: '2to3', directives: {} }
let variantArray = []
switch (last) {
case 'Text':
case 'Faint': // typo
case 'Link':
case 'Icon':
case 'Greentext':
case 'Cyantext':
case 'Border':
newRule.parent = rule
newRule.directives.textColor = data.colors[key]
variantArray = parts.slice(0, -1)
break
default:
newRule = { ...rule, directives: {} }
newRule.directives.background = data.colors[key]
variantArray = parts
break
}
if (last === 'Text' || last === 'Link') {
const secondLast = parts.slice(-2)[0]
if (secondLast === 'Light') {
return null // unsupported
} else if (secondLast === 'Faint') {
newRule.state = ['faint']
variantArray = parts.slice(0, -2)
}
}
switch (last) {
case 'Text':
case 'Link':
case 'Icon':
case 'Border':
newRule.component = last
break
case 'Greentext':
case 'Cyantext':
newRule.component = 'FunText'
newRule.variant = last.toLowerCase()
break
case 'Faint':
newRule.component = 'Text'
newRule.state = ['faint']
break
}
variantArray = variantArray.filter((x) => x !== 'Bg')
if (last === 'Link' && prefix === 'selectedPost') {
// selectedPost has typo - duplicate 'Post'
variantArray = variantArray.filter((x) => x !== 'Post')
}
if (prefix === 'popover' && variantArray[0] === 'Post') {
newRule.component = 'Post'
newRule.parent = { source: '2to3hack', component: 'Popover' }
variantArray = variantArray.filter((x) => x !== 'Post')
}
if (prefix === 'selectedMenu' && variantArray[0] === 'Popover') {
newRule.parent = { source: '2to3hack', component: 'Popover' }
variantArray = variantArray.filter((x) => x !== 'Popover')
}
switch (prefix) {
case 'btn':
case 'input':
case 'alert': {
const hasPanel = variantArray.find((x) => x === 'Panel')
if (hasPanel) {
newRule.parent = {
source: '2to3hack',
component: 'PanelHeader',
parent: newRule.parent,
}
variantArray = variantArray.filter((x) => x !== 'Panel')
}
const hasTop = variantArray.find((x) => x === 'Top') // TopBar
if (hasTop) {
newRule.parent = {
source: '2to3hack',
component: 'TopBar',
parent: newRule.parent,
}
variantArray = variantArray.filter(
(x) => x !== 'Top' && x !== 'Bar',
)
}
break
}
}
if (variantArray.length > 0) {
if (prefix === 'btn') {
newRule.state = variantArray.map((x) => x.toLowerCase())
} else {
newRule.variant = variantArray[0].toLowerCase()
}
}
if (newRule.component === 'Panel') {
return [newRule, { ...newRule, component: 'MobileDrawer' }]
} else if (newRule.component === 'Button') {
const rules = [
newRule,
{ ...newRule, component: 'Tab' },
{ ...newRule, component: 'ScrollbarElement' },
]
- if (newRule.state?.indexOf('toggled') >= 0) {
- rules.push({ ...newRule, state: [...newRule.state, 'focused'] })
- rules.push({ ...newRule, state: [...newRule.state, 'hover'] })
- rules.push({
- ...newRule,
- state: [...newRule.state, 'hover', 'focused'],
- })
+ if (newRule.state?.includes('toggled')) {
+ rules.push(
+ { ...newRule, state: [...newRule.state, 'focused'] },
+ { ...newRule, state: [...newRule.state, 'hover'] },
+ {
+ ...newRule,
+ state: [...newRule.state, 'hover', 'focused'],
+ },
+ )
}
- if (newRule.state?.indexOf('hover') >= 0) {
+ if (newRule.state?.includes('hover')) {
rules.push({ ...newRule, state: [...newRule.state, 'focused'] })
}
return rules
} else if (newRule.component === 'Badge') {
if (newRule.variant === 'notification') {
return [
newRule,
{
component: 'Root',
directives: {
'--badgeNotification':
'color | ' + newRule.directives.background,
},
},
]
} else if (newRule.variant === 'neutral') {
return [{ ...newRule, variant: 'normal' }]
} else {
return [newRule]
}
} else if (newRule.component === 'TopBar') {
return [
newRule,
{
...newRule,
parent: { component: 'MobileDrawer' },
component: 'PanelHeader',
},
]
} else {
return [newRule]
}
})
},
)
const flatExtRules = extendedRules
.filter(Boolean)
- .reduce((acc, x) => [...acc, ...x], [])
+ .flat()
.filter(Boolean)
- .reduce((acc, x) => [...acc, ...x], [])
+ .flat()
return [
generateRoot(),
...convertShadows(),
...convertRadii(),
...convertOpacity(),
...convertFonts(),
...flatExtRules,
]
}
diff --git a/src/services/theme_data/theme_data.service.js b/src/services/theme_data/theme_data.service.js
index cdce2cf574..cc5553936f 100644
--- a/src/services/theme_data/theme_data.service.js
+++ b/src/services/theme_data/theme_data.service.js
@@ -1,837 +1,834 @@
import { brightness, contrastRatio, convert } from 'chromatism'
import {
alphaBlendLayers,
getCssColor,
getTextColor,
relativeLuminance,
rgb2hex,
rgba2css,
} from '../color_convert/color_convert.js'
import { DEFAULT_OPACITY, LAYERS, SLOT_INHERITANCE } from './pleromafe.js'
/*
* # What's all this?
* Here be theme engine for pleromafe. All of this supposed to ease look
* and feel customization, making widget styles and make developer's life
* easier when it comes to supporting themes. Like many other theme systems
* it operates on color definitions, or "slots" - for example you define
* "button" color slot and then in UI component Button's CSS you refer to
* it as a CSS3 Variable.
*
* Some applications allow you to customize colors for certain things.
* Some UI toolkits allow you to define colors for each type of widget.
* Most of them are pretty barebones and have no assistance for common
* problems and cases, and in general themes themselves are very hard to
* maintain in all aspects. This theme engine tries to solve all of the
* common problems with themes.
*
* You don't have redefine several similar colors if you just want to
* change one color - all color slots are derived from other ones, so you
* can have at least one or two "basic" colors defined and have all other
* components inherit and modify basic ones.
*
* You don't have to test contrast ratio for colors or pick text color for
* each element even if you have light-on-dark elements in dark-on-light
* theme.
*
* You don't have to maintain order of code for inheriting slots from othet
* slots - dependency graph resolving does it for you.
*/
/* This indicates that this version of code outputs similar theme data and
* should be incremented if output changes - for instance if getTextColor
* function changes and older themes no longer render text colors as
* author intended previously.
*/
export const CURRENT_VERSION = 3
export const getLayersArray = (layer, data = LAYERS) => {
const array = [layer]
let parent = data[layer]
while (parent) {
array.unshift(parent)
parent = data[parent]
}
return array
}
export const getLayers = (
layer,
variant = layer,
opacitySlot,
colors,
opacity,
) => {
return getLayersArray(layer).map((currentLayer) => [
currentLayer === layer ? colors[variant] : colors[currentLayer],
currentLayer === layer ? opacity[opacitySlot] || 1 : opacity[currentLayer],
])
}
const getDependencies = (key, inheritance) => {
const data = inheritance[key]
if (typeof data === 'string' && data.startsWith('--')) {
return [data.substring(2)]
} else {
if (data === null) return []
const { depends, layer, variant } = data
const layerDeps = layer
? getLayersArray(layer).map((currentLayer) => {
return currentLayer === layer ? variant || layer : currentLayer
})
: []
if (Array.isArray(depends)) {
return [...depends, ...layerDeps]
} else {
return [...layerDeps]
}
}
}
/**
* Sorts inheritance object topologically - dependant slots come after
* dependencies
*
* @property {Object} inheritance - object defining the nodes
* @property {Function} getDeps - function that returns dependencies for
* given value and inheritance object.
* @returns {String[]} keys of inheritance object, sorted in topological
* order. Additionally, dependency-less nodes will always be first in line
*/
export const topoSort = (
inheritance = SLOT_INHERITANCE,
getDeps = getDependencies,
) => {
// This is an implementation of https://en.wikipedia.org/wiki/Tarjan%27s_strongly_connected_components_algorithm
const allKeys = Object.keys(inheritance)
const whites = new Set(allKeys)
const grays = new Set()
const blacks = new Set()
const unprocessed = [...allKeys]
const output = []
const step = (node) => {
if (whites.has(node)) {
// Make node "gray"
whites.delete(node)
grays.add(node)
// Do step for each node connected to it (one way)
getDeps(node, inheritance).forEach(step)
// Make node "black"
grays.delete(node)
blacks.add(node)
// Put it into the output list
output.push(node)
} else if (grays.has(node)) {
output.push(node)
} else if (blacks.has(node)) {
// do nothing
} else {
throw new Error('Unintended condition in topoSort!')
}
}
while (unprocessed.length > 0) {
step(unprocessed.pop())
}
// The index thing is to make sorting stable on browsers
// where Array.sort() isn't stable
return output
.map((data, index) => ({ data, index }))
.sort(({ data: a, index: ai }, { data: b, index: bi }) => {
const depsA = getDeps(a, inheritance).length
const depsB = getDeps(b, inheritance).length
if (depsA === depsB || (depsB !== 0 && depsA !== 0)) return ai - bi
if (depsA === 0 && depsB !== 0) return -1
if (depsB === 0 && depsA !== 0) return 1
return 0 // failsafe, shouldn't happen?
})
.map(({ data }) => data)
}
const expandSlotValue = (value) => {
if (typeof value === 'object') return value
return {
depends: value.startsWith('--') ? [value.substring(2)] : [],
default: value.startsWith('#') ? value : undefined,
}
}
/**
* retrieves opacity slot for given slot. This goes up the depenency graph
* to find which parent has opacity slot defined for it.
* TODO refactor this
*/
export const getOpacitySlot = (
k,
inheritance = SLOT_INHERITANCE,
getDeps = getDependencies,
) => {
const value = expandSlotValue(inheritance[k])
if (value.opacity === null) return
if (value.opacity) return value.opacity
const findInheritedOpacity = (key, visited = [k]) => {
const depSlot = getDeps(key, inheritance)[0]
if (depSlot === undefined) return
const dependency = inheritance[depSlot]
if (dependency === undefined) return
if (dependency.opacity || dependency === null) {
return dependency.opacity
} else if (dependency.depends && visited.includes(depSlot)) {
return findInheritedOpacity(depSlot, [...visited, depSlot])
} else {
return null
}
}
if (value.depends) {
return findInheritedOpacity(k)
}
}
/**
* retrieves layer slot for given slot. This goes up the depenency graph
* to find which parent has opacity slot defined for it.
* this is basically copypaste of getOpacitySlot except it checks if key is
* in LAYERS
* TODO refactor this
*/
export const getLayerSlot = (
k,
inheritance = SLOT_INHERITANCE,
getDeps = getDependencies,
) => {
const value = expandSlotValue(inheritance[k])
if (LAYERS[k]) return k
if (value.layer === null) return
if (value.layer) return value.layer
const findInheritedLayer = (key, visited = [k]) => {
const depSlot = getDeps(key, inheritance)[0]
if (depSlot === undefined) return
const dependency = inheritance[depSlot]
if (dependency === undefined) return
if (dependency.layer || dependency === null) {
return dependency.layer
} else if (dependency.depends) {
return findInheritedLayer(dependency, [...visited, depSlot])
} else {
return null
}
}
if (value.depends) {
return findInheritedLayer(k)
}
}
/**
* topologically sorted SLOT_INHERITANCE
*/
export const SLOT_ORDERED = topoSort(
Object.entries(SLOT_INHERITANCE)
.sort(([, aV], [, bV]) => (aV?.priority || 0) - (bV?.priority || 0))
.reduce((acc, [k, v]) => ({ ...acc, [k]: v }), {}),
)
/**
* All opacity slots used in color slots, their default values and affected
* color slots.
*/
export const OPACITIES = Object.entries(SLOT_INHERITANCE).reduce((acc, [k]) => {
const opacity = getOpacitySlot(k, SLOT_INHERITANCE, getDependencies)
if (opacity) {
return {
...acc,
[opacity]: {
defaultValue: DEFAULT_OPACITY[opacity] || 1,
- affectedSlots: [
- ...((acc[opacity] && acc[opacity].affectedSlots) || []),
- k,
- ],
+ affectedSlots: [...(acc[opacity]?.affectedSlots || []), k],
},
}
} else {
return acc
}
}, {})
/**
* Handle dynamic color
*/
export const computeDynamicColor = (sourceColor, getColor, mod) => {
if (typeof sourceColor !== 'string' || !sourceColor.startsWith('--'))
return sourceColor
let targetColor = null
// Color references other color
const [variable, modifier] = sourceColor.split(/,/g).map((str) => str.trim())
const variableSlot = variable.substring(2)
targetColor = getColor(variableSlot)
if (modifier) {
targetColor = brightness(Number.parseFloat(modifier) * mod, targetColor).rgb
}
return targetColor
}
/**
* THE function you want to use. Takes provided colors and opacities
* value and uses inheritance data to figure out color needed for the slot.
*/
export const getColors = (sourceColors, sourceOpacity) =>
SLOT_ORDERED.reduce(
({ colors, opacity }, key) => {
const sourceColor = sourceColors[key]
const value = expandSlotValue(SLOT_INHERITANCE[key])
const deps = getDependencies(key, SLOT_INHERITANCE)
const isTextColor = !!value.textColor
const variant = value.variant || value.layer
let backgroundColor = null
if (isTextColor) {
backgroundColor = alphaBlendLayers(
{
...(colors[deps[0]] || convert(sourceColors[key] || '#FF00FF').rgb),
},
getLayers(
getLayerSlot(key) || 'bg',
variant || 'bg',
getOpacitySlot(variant),
colors,
opacity,
),
)
} else if (variant && variant !== key) {
backgroundColor = colors[variant] || convert(sourceColors[variant]).rgb
} else {
backgroundColor = colors.bg || convert(sourceColors.bg)
}
const isLightOnDark = relativeLuminance(backgroundColor) < 0.5
const mod = isLightOnDark ? 1 : -1
let outputColor = null
if (sourceColor) {
// Color is defined in source color
let targetColor = sourceColor
if (targetColor === 'transparent') {
// We take only layers below current one
const layers = getLayers(
getLayerSlot(key),
key,
getOpacitySlot(key) || key,
colors,
opacity,
).slice(0, -1)
targetColor = {
...alphaBlendLayers(convert('#FF00FF').rgb, layers),
a: 0,
}
} else if (
typeof sourceColor === 'string' &&
sourceColor.startsWith('--')
) {
targetColor = computeDynamicColor(
sourceColor,
(variableSlot) =>
colors[variableSlot] || sourceColors[variableSlot],
mod,
)
} else if (
typeof sourceColor === 'string' &&
sourceColor.startsWith('#')
) {
targetColor = convert(targetColor).rgb
}
outputColor = { ...targetColor }
} else if (value.default) {
// same as above except in object form
outputColor = convert(value.default).rgb
} else {
// calculate color
const defaultColorFunc = (mod, dep) => ({ ...dep })
const colorFunc = value.color || defaultColorFunc
if (value.textColor) {
if (value.textColor === 'bw') {
outputColor = contrastRatio(backgroundColor).rgb
} else {
let color = { ...colors[deps[0]] }
if (value.color) {
color = colorFunc(mod, ...deps.map((dep) => ({ ...colors[dep] })))
}
outputColor = getTextColor(
backgroundColor,
{ ...color },
value.textColor === 'preserve',
)
}
} else {
// background color case
outputColor = colorFunc(
mod,
...deps.map((dep) => ({ ...colors[dep] })),
)
}
}
if (!outputColor) {
throw new Error("Couldn't generate color for " + key)
}
const opacitySlot = value.opacity || getOpacitySlot(key)
const ownOpacitySlot = value.opacity
if (ownOpacitySlot === null) {
outputColor.a = 1
} else if (sourceColor === 'transparent') {
outputColor.a = 0
} else {
const opacityOverriden =
ownOpacitySlot && sourceOpacity[opacitySlot] !== undefined
const dependencySlot = deps[0]
const dependencyColor = dependencySlot && colors[dependencySlot]
if (
!ownOpacitySlot &&
dependencyColor &&
!value.textColor &&
ownOpacitySlot !== null
) {
// Inheriting color from dependency (weird, i know)
// except if it's a text color or opacity slot is set to 'null'
outputColor.a = dependencyColor.a
} else if (!dependencyColor && !opacitySlot) {
// Remove any alpha channel if no dependency and no opacitySlot found
delete outputColor.a
} else {
// Otherwise try to assign opacity
if (dependencyColor?.a === 0) {
// transparent dependency shall make dependents transparent too
outputColor.a = 0
} else {
// Otherwise check if opacity is overriden and use that or default value instead
outputColor.a = Number(
opacityOverriden
? sourceOpacity[opacitySlot]
- : (OPACITIES[opacitySlot] || {}).defaultValue,
+ : OPACITIES[opacitySlot]?.defaultValue,
)
}
}
}
if (Number.isNaN(outputColor.a) || outputColor.a === undefined) {
outputColor.a = 1
}
if (opacitySlot) {
return {
colors: { ...colors, [key]: outputColor },
opacity: { ...opacity, [opacitySlot]: outputColor.a },
}
} else {
return {
colors: { ...colors, [key]: outputColor },
opacity,
}
}
},
{ colors: {}, opacity: {} },
)
export const composePreset = (colors, radii, shadows, fonts) => {
return {
rules: {
...shadows.rules,
...colors.rules,
...radii.rules,
...fonts.rules,
},
theme: {
...shadows.theme,
...colors.theme,
...radii.theme,
...fonts.theme,
},
}
}
export const generatePreset = (input) => {
const colors = generateColors(input)
return composePreset(
colors,
generateRadii(input),
- generateShadows(input, colors.theme.colors, colors.mod),
+ generateShadows(input, colors.theme.colors),
generateFonts(input),
)
}
export const getCssShadow = (input, usesDropShadow) => {
if (input.length === 0) {
return 'none'
}
return input
.filter((_) => (usesDropShadow ? _.inset : _))
.map((shad) =>
[shad.x, shad.y, shad.blur, shad.spread]
.map((_) => _ + 'px')
.concat([
getCssColor(shad.color, shad.alpha),
shad.inset ? 'inset' : '',
])
.join(' '),
)
.join(', ')
}
export const getCssShadowFilter = (input) => {
if (input.length === 0) {
return 'none'
}
return (
input
// drop-shadow doesn't support inset or spread
.filter((shad) => !shad.inset && Number(shad.spread) === 0)
.map((shad) =>
[
shad.x,
shad.y,
// drop-shadow's blur is twice as strong compared to box-shadow
shad.blur / 2,
]
.map((_) => _ + 'px')
.concat([getCssColor(shad.color, shad.alpha)])
.join(' '),
)
.map((_) => `drop-shadow(${_})`)
.join(' ')
)
}
export const generateColors = (themeData) => {
const sourceColors = !themeData.themeEngineVersion
? colors2to3(themeData.colors || themeData)
: themeData.colors || themeData
const { colors, opacity } = getColors(sourceColors, themeData.opacity || {})
const htmlColors = Object.entries(colors).reduce(
(acc, [k, v]) => {
if (!v) return acc
acc.solid[k] = rgb2hex(v)
acc.complete[k] = v.a === undefined ? rgb2hex(v) : rgba2css(v)
return acc
},
{ complete: {}, solid: {} },
)
return {
rules: {
colors: Object.entries(htmlColors.complete)
.filter(([, v]) => v)
.map(([k, v]) => `--${k}: ${v}`)
.join(';'),
},
theme: {
colors: htmlColors.solid,
opacity,
},
}
}
export const generateRadii = (input) => {
let inputRadii = input.radii || {}
// v1 -> v2
if (input.btnRadius !== undefined) {
inputRadii = Object.entries(input)
.filter(([k]) => k.endsWith('Radius'))
.reduce((acc, e) => {
acc[e[0].split('Radius')[0]] = e[1]
return acc
}, {})
}
const radii = Object.entries(inputRadii)
.filter(([, v]) => v)
.reduce(
(acc, [k, v]) => {
acc[k] = v
return acc
},
{
btn: 4,
input: 4,
checkbox: 2,
panel: 10,
avatar: 5,
avatarAlt: 50,
tooltip: 2,
attachment: 5,
chatMessage: inputRadii.panel,
},
)
return {
rules: {
radii: Object.entries(radii)
.filter(([, v]) => v)
.map(([k, v]) => `--${k}Radius: ${v}px`)
.join(';'),
},
theme: {
radii,
},
}
}
export const generateFonts = (input) => {
const fonts = Object.entries(input.fonts || {})
.filter(([, v]) => v)
.reduce(
(acc, [k, v]) => {
acc[k] = Object.entries(v)
.filter(([, v]) => v)
.reduce((acc, [k, v]) => {
acc[k] = v
return acc
}, acc[k])
return acc
},
{
interface: {
family: 'sans-serif',
},
input: {
family: 'inherit',
},
post: {
family: 'inherit',
},
postCode: {
family: 'monospace',
},
},
)
return {
rules: {
fonts: Object.entries(fonts)
.filter(([, v]) => v)
.map(([k, v]) => `--${k}Font: ${v}`)
.join(';'),
},
theme: {
fonts,
},
}
}
const border = (top, shadow) => ({
x: 0,
y: top ? 1 : -1,
blur: 0,
spread: 0,
color: shadow ? '#000000' : '#FFFFFF',
alpha: 0.2,
inset: true,
})
const buttonInsetFakeBorders = [border(true, false), border(false, true)]
const inputInsetFakeBorders = [border(true, true), border(false, false)]
const hoverGlow = {
x: 0,
y: 0,
blur: 4,
spread: 0,
color: '--faint',
alpha: 1,
}
export const DEFAULT_SHADOWS = {
panel: [
{
x: 1,
y: 1,
blur: 4,
spread: 0,
color: '#000000',
alpha: 0.6,
},
],
topBar: [
{
x: 0,
y: 0,
blur: 4,
spread: 0,
color: '#000000',
alpha: 0.6,
},
],
popup: [
{
x: 2,
y: 2,
blur: 3,
spread: 0,
color: '#000000',
alpha: 0.5,
},
],
avatar: [
{
x: 0,
y: 1,
blur: 8,
spread: 0,
color: '#000000',
alpha: 0.7,
},
],
avatarStatus: [],
panelHeader: [],
button: [
{
x: 0,
y: 0,
blur: 2,
spread: 0,
color: '#000000',
alpha: 1,
},
...buttonInsetFakeBorders,
],
buttonHover: [hoverGlow, ...buttonInsetFakeBorders],
buttonPressed: [hoverGlow, ...inputInsetFakeBorders],
input: [
...inputInsetFakeBorders,
{
x: 0,
y: 0,
blur: 2,
inset: true,
spread: 0,
color: '#000000',
alpha: 1,
},
],
}
export const generateShadows = (input, colors) => {
// TODO this is a small hack for `mod` to work with shadows
// this is used to get the "context" of shadow, i.e. for `mod` properly depend on background color of element
const hackContextDict = {
button: 'btn',
panel: 'bg',
top: 'topBar',
popup: 'popover',
avatar: 'bg',
panelHeader: 'panel',
input: 'input',
}
const cleanInputShadows = Object.fromEntries(
Object.entries(input.shadows || {}).map(([name, shadowSlot]) => [
name,
// defaulting color to black to avoid potential problems
shadowSlot.map((shadowDef) => ({ color: '#000000', ...shadowDef })),
]),
)
const inputShadows =
cleanInputShadows && !input.themeEngineVersion
? shadows2to3(cleanInputShadows, input.opacity)
: cleanInputShadows || {}
const shadows = Object.entries({
...DEFAULT_SHADOWS,
...inputShadows,
}).reduce((shadowsAcc, [slotName, shadowDefs]) => {
const slotFirstWord = slotName.replace(/[A-Z].*$/, '')
const colorSlotName = hackContextDict[slotFirstWord]
const isLightOnDark =
relativeLuminance(convert(colors[colorSlotName]).rgb) < 0.5
const mod = isLightOnDark ? 1 : -1
const newShadow = shadowDefs.reduce(
(shadowAcc, def) => [
...shadowAcc,
{
...def,
color: rgb2hex(
computeDynamicColor(
def.color,
(variableSlot) => convert(colors[variableSlot]).rgb,
mod,
),
),
},
],
[],
)
return { ...shadowsAcc, [slotName]: newShadow }
}, {})
return {
rules: {
shadows: Object.entries(shadows)
// TODO for v2.2: if shadow doesn't have non-inset shadows with spread > 0 - optionally
// convert all non-inset shadows into filter: drop-shadow() to boost performance
.map(([k, v]) =>
[
`--${k}Shadow: ${getCssShadow(v)}`,
`--${k}ShadowFilter: ${getCssShadowFilter(v)}`,
`--${k}ShadowInset: ${getCssShadow(v, true)}`,
].join(';'),
)
.join(';'),
},
theme: {
shadows,
},
}
}
/**
* This handles compatibility issues when importing v2 theme's shadows to current format
*
* Back in v2 shadows allowed you to use dynamic colors however those used pure CSS3 variables
*/
export const shadows2to3 = (shadows, opacity) => {
return Object.entries(shadows).reduce(
(shadowsAcc, [slotName, shadowDefs]) => {
const isDynamic = ({ color = '#000000' }) => color.startsWith('--')
const getOpacity = ({ color }) =>
opacity[getOpacitySlot(color.substring(2).split(',')[0])]
const newShadow = shadowDefs.reduce(
(shadowAcc, def) => [
...shadowAcc,
{
...def,
alpha: isDynamic(def) ? getOpacity(def) || 1 : def.alpha,
},
],
[],
)
return { ...shadowsAcc, [slotName]: newShadow }
},
{},
)
}
export const colors2to3 = (colors) => {
return Object.entries(colors).reduce((acc, [slotName, color]) => {
const btnPositions = ['', 'Panel', 'TopBar']
switch (slotName) {
case 'lightBg':
return { ...acc, highlight: color }
case 'btnText':
return {
...acc,
...btnPositions.reduce(
(statePositionAcc, position) => ({
...statePositionAcc,
['btn' + position + 'Text']: color,
}),
{},
),
}
default:
return { ...acc, [slotName]: color }
}
}, {})
}
diff --git a/src/services/theme_data/theme_data_3.service.js b/src/services/theme_data/theme_data_3.service.js
index 626d7daeb6..694e41e12f 100644
--- a/src/services/theme_data/theme_data_3.service.js
+++ b/src/services/theme_data/theme_data_3.service.js
@@ -1,780 +1,780 @@
import { brightness, convert } from 'chromatism'
import sum from 'hash-sum'
import { flattenDeep, sortBy } from 'lodash'
import {
alphaBlend,
getTextColor,
mixrgb,
relativeLuminance,
rgba2css,
} from '../color_convert/color_convert.js'
import { deserializeShadow } from './iss_deserializer.js'
import {
findRules,
genericRuleToSelector,
getAllPossibleCombinations,
normalizeCombination,
unroll,
} from './iss_utils.js'
import {
colorFunctions,
process,
shadowFunctions,
} from './theme3_slot_functions.js'
// Ensuring the order of components
const components = {
Root: null,
Text: null,
FunText: null,
Link: null,
Icon: null,
Border: null,
PanelHeader: null,
Panel: null,
Chat: null,
ChatMessage: null,
Button: null,
}
export const findShadow = (shadows, { dynamicVars, staticVars }) => {
return (shadows || []).map((shadow) => {
let targetShadow
if (typeof shadow === 'string') {
if (shadow.startsWith('$')) {
targetShadow = process(
shadow,
shadowFunctions,
{ findColor, findShadow },
{ dynamicVars, staticVars },
)
} else if (shadow.startsWith('--')) {
// modifiers are completely unsupported here
const variableSlot = shadow.substring(2)
return findShadow(staticVars[variableSlot], { dynamicVars, staticVars })
} else {
targetShadow = deserializeShadow(shadow)
}
} else {
targetShadow = shadow
}
const shadowArray = Array.isArray(targetShadow)
? targetShadow
: [targetShadow]
return shadowArray.map((s) => ({
...s,
color: findColor(s.color, { dynamicVars, staticVars }),
}))
})
}
export const findColor = (color, { dynamicVars, staticVars }) => {
try {
if (
typeof color !== 'string' ||
(!color.startsWith('--') && !color.startsWith('$'))
)
return color
let targetColor = null
if (color.startsWith('--')) {
// Modifier support is pretty much for v2 themes only
const [variable, modifier] = color.split(/,/g).map((str) => str.trim())
const variableSlot = variable.substring(2)
if (variableSlot === 'stack') {
const { r, g, b } = dynamicVars.stacked
targetColor = { r, g, b }
} else if (variableSlot.startsWith('parent')) {
if (variableSlot === 'parent') {
const { r, g, b } = dynamicVars.lowerLevelBackground ?? {}
targetColor = { r, g, b }
} else {
const virtualSlot = variableSlot.replace(/^parent/, '')
targetColor = convert(
dynamicVars.lowerLevelVirtualDirectivesRaw[virtualSlot],
).rgb
}
} else {
const staticVar = staticVars[variableSlot]
const dynamicVar = dynamicVars[variableSlot]
if (!staticVar && !dynamicVar) {
console.warn(
`Couldn't find variable "${variableSlot}", falling back to magenta. Variables are:
Static:
${JSON.stringify(staticVars, null, 2)}
Dynamic:
${JSON.stringify(dynamicVars, null, 2)}`,
dynamicVars,
variableSlot,
dynamicVars[variableSlot],
)
}
targetColor = convert(staticVar ?? dynamicVar ?? '#FF00FF').rgb
}
if (modifier) {
const effectiveBackground =
dynamicVars.lowerLevelBackground ?? targetColor
const isLightOnDark =
relativeLuminance(convert(effectiveBackground).rgb) < 0.5
const mod = isLightOnDark ? 1 : -1
targetColor = brightness(
Number.parseFloat(modifier) * mod,
targetColor,
).rgb
}
}
if (color.startsWith('$')) {
try {
targetColor = process(
color,
colorFunctions,
{ findColor },
{ dynamicVars, staticVars },
)
} catch (e) {
console.error(
'Failure executing color function',
e,
'\n Function: ' + color,
)
targetColor = '#FF00FF'
}
}
// Color references other color
return targetColor
} catch (e) {
throw new Error(`Couldn't find color "${color}", variables are:
Static:
${JSON.stringify(staticVars, null, 2)}
Dynamic:
${JSON.stringify(dynamicVars, null, 2)}\nError: ${e}`)
}
}
const getTextColorAlpha = (
directives,
intendedTextColor,
dynamicVars,
staticVars,
) => {
const opacity = directives.textOpacity
const backgroundColor = convert(dynamicVars.lowerLevelBackground).rgb
const textColor = convert(
findColor(intendedTextColor, { dynamicVars, staticVars }),
).rgb
if (opacity === null || opacity === undefined || opacity >= 1) {
return convert(textColor).hex
}
if (opacity === 0) {
return convert(backgroundColor).hex
}
const opacityMode = directives.textOpacityMode
switch (opacityMode) {
case 'fake':
return convert(alphaBlend(textColor, opacity, backgroundColor)).hex
case 'mixrgb':
return convert(mixrgb(backgroundColor, textColor)).hex
default:
return rgba2css({ a: opacity, ...textColor })
}
}
// Loading all style.js[on] files dynamically
const componentsContext = import.meta.glob(
['/src/**/*.style.js', '/src/**/*.style.json'],
{ eager: true },
)
Object.keys(componentsContext).forEach((key) => {
const component = componentsContext[key].default
if (components[component.name] != null) {
console.warn(
`Component in file ${key} is trying to override existing component ${component.name}! You have collisions/duplicates!`,
)
}
components[component.name] = component
})
Object.keys(components).forEach((key) => {
if (key === 'Root') return
components.Root.validInnerComponents =
components.Root.validInnerComponents || []
components.Root.validInnerComponents.push(key)
})
Object.keys(components).forEach((key) => {
const component = components[key]
const { validInnerComponents = [] } = component
validInnerComponents.forEach((inner) => {
const child = components[inner]
component.possibleChildren = component.possibleChildren || []
component.possibleChildren.push(child)
child.possibleParents = child.possibleParents || []
child.possibleParents.push(component)
})
})
const engineChecksum = sum(components)
const ruleToSelector = genericRuleToSelector(components)
export const getEngineChecksum = () => engineChecksum
/**
* Initializes and compiles the theme according to the ruleset
*
* @param {Object[]} inputRuleset - set of rules to compile theme against. Acts as an override to
* component default rulesets
* @param {string} ultimateBackgroundColor - Color that will be the "final" background for
* calculating contrast ratios and making text automatically accessible. Really used for cases when
* stuff is transparent.
* @param {boolean} debug - print out debug information in console, mostly just performance stuff
* @param {boolean} liteMode - use validInnerComponentsLite instead of validInnerComponents, meant to
* generatate theme previews and such that need to be compiled faster and don't require a lot of other
* components present in "normal" mode
* @param {boolean} onlyNormalState - only use components 'normal' states, meant for generating theme
* previews since states are the biggest factor for compilation time and are completely unnecessary
* when previewing multiple themes at same time
*/
export const init = ({
inputRuleset,
ultimateBackgroundColor,
debug = false,
liteMode = false,
editMode = false,
onlyNormalState = false,
initialStaticVars = {},
}) => {
const rootComponentName = 'Root'
if (!inputRuleset) throw new Error('Ruleset is null or undefined!')
const staticVars = { ...initialStaticVars }
const stacked = {}
const computed = {}
const rulesetUnsorted = [
...Object.values(components)
.map((c) =>
(c.defaultRules || []).map((r) => ({
source: 'Built-in',
component: c.name,
...r,
})),
)
- .reduce((acc, arr) => [...acc, ...arr], []),
+ .flat(),
...inputRuleset,
].map((rule) => {
normalizeCombination(rule)
let currentParent = rule.parent
while (currentParent) {
normalizeCombination(currentParent)
currentParent = currentParent.parent
}
return rule
})
const ruleset = rulesetUnsorted
.map((data, index) => ({ data, index }))
.toSorted(({ data: a, index: ai }, { data: b, index: bi }) => {
const parentsA = unroll(a).length
const parentsB = unroll(b).length
let aScore = 0
let bScore = 0
aScore += parentsA * 1000
bScore += parentsB * 1000
aScore += a.variant !== 'normal' ? 100 : 0
bScore += b.variant !== 'normal' ? 100 : 0
aScore += a.state.filter((x) => x !== 'normal').length * 1000
bScore += b.state.filter((x) => x !== 'normal').length * 1000
aScore += a.component === 'Text' ? 1 : 0
bScore += b.component === 'Text' ? 1 : 0
// Debug
a._specificityScore = aScore
b._specificityScore = bScore
if (aScore === bScore) {
return ai - bi
}
return aScore - bScore
})
.map(({ data }) => data)
if (!ultimateBackgroundColor) {
console.warn(
'No ultimate background color provided, falling back to panel color',
)
const rootRule = ruleset.findLast(
(x) => x.component === 'Root' && x.directives?.['--bg'],
)
ultimateBackgroundColor = rootRule.directives['--bg'].split('|')[1].trim()
}
const virtualComponents = new Set(
Object.values(components)
.filter((c) => c.virtual)
.map((c) => c.name),
)
const transparentComponents = new Set(
Object.values(components)
.filter((c) => c.transparent)
.map((c) => c.name),
)
const nonEditableComponents = new Set(
Object.values(components)
.filter((c) => c.notEditable)
.map((c) => c.name),
)
const extraCompileComponents = new Set([])
Object.values(components).forEach((component) => {
const relevantRules = ruleset.filter((r) => r.component === component.name)
const backgrounds = relevantRules
.map((r) => r.directives.background)
.filter(Boolean)
const opacities = relevantRules
.map((r) => r.directives.opacity)
.filter(Boolean)
if (
backgrounds.some((x) => x.match(/--parent/)) ||
opacities.some((x) => x != null && x < 1)
) {
extraCompileComponents.add(component.name)
}
})
const processCombination = (combination) => {
try {
const selector = ruleToSelector(combination, true)
const cssSelector = ruleToSelector(combination)
const parentSelector = selector.split(/ /g).slice(0, -1).join(' ')
const soloSelector = selector.split(/ /g).slice(-1)[0]
const lowerLevelSelector = parentSelector
let lowerLevelBackground = computed[lowerLevelSelector]?.background
if (editMode && !lowerLevelBackground) {
// FIXME hack for editor until it supports handling component backgrounds
lowerLevelBackground = '#00FFFF'
}
const lowerLevelVirtualDirectives =
computed[lowerLevelSelector]?.virtualDirectives
const lowerLevelVirtualDirectivesRaw =
computed[lowerLevelSelector]?.virtualDirectivesRaw
const dynamicVars = computed[selector] || {
lowerLevelSelector,
lowerLevelBackground,
lowerLevelVirtualDirectives,
lowerLevelVirtualDirectivesRaw,
}
// Inheriting all of the applicable rules
const existingRules = ruleset.filter(findRules(combination))
const computedDirectives = existingRules
.map((r) => r.directives)
.reduce((acc, directives) => ({ ...acc, ...directives }), {})
const computedRule = {
...combination,
directives: computedDirectives,
}
computed[selector] = computed[selector] || {}
computed[selector].computedRule = computedRule
computed[selector].dynamicVars = dynamicVars
// avoid putting more stuff into actual CSS
computed[selector].virtualDirectives = {}
// but still be able to access it i.e. from --parent
computed[selector].virtualDirectivesRaw =
computed[lowerLevelSelector]?.virtualDirectivesRaw || {}
if (virtualComponents.has(combination.component)) {
const virtualName = [
'--',
combination.component.toLowerCase(),
combination.variant === 'normal'
? ''
: combination.variant[0].toUpperCase() +
combination.variant.slice(1).toLowerCase(),
...sortBy(combination.state.filter((x) => x !== 'normal')).map(
(state) => state[0].toUpperCase() + state.slice(1).toLowerCase(),
),
].join('')
let inheritedTextColor = computedDirectives.textColor
let inheritedTextAuto = computedDirectives.textAuto
let inheritedTextOpacity = computedDirectives.textOpacity
let inheritedTextOpacityMode = computedDirectives.textOpacityMode
const lowerLevelTextSelector = [
...selector.split(/ /g).slice(0, -1),
soloSelector,
].join(' ')
const lowerLevelTextRule = computed[lowerLevelTextSelector]
if (
inheritedTextColor == null ||
inheritedTextOpacity == null ||
inheritedTextOpacityMode == null
) {
inheritedTextColor =
computedDirectives.textColor ?? lowerLevelTextRule.textColor
inheritedTextAuto =
computedDirectives.textAuto ?? lowerLevelTextRule.textAuto
inheritedTextOpacity =
computedDirectives.textOpacity ?? lowerLevelTextRule.textOpacity
inheritedTextOpacityMode =
computedDirectives.textOpacityMode ??
lowerLevelTextRule.textOpacityMode
}
const newTextRule = {
...computedRule,
directives: {
...computedRule.directives,
textColor: inheritedTextColor,
textAuto: inheritedTextAuto ?? 'preserve',
textOpacity: inheritedTextOpacity,
textOpacityMode: inheritedTextOpacityMode,
},
}
dynamicVars.inheritedBackground = lowerLevelBackground
dynamicVars.stacked = convert(stacked[lowerLevelSelector]).rgb
const intendedTextColor = convert(
findColor(inheritedTextColor, { dynamicVars, staticVars }),
).rgb
const textColor =
newTextRule.directives.textAuto === 'no-auto'
? intendedTextColor
: getTextColor(
convert(stacked[lowerLevelSelector]).rgb,
intendedTextColor,
newTextRule.directives.textAuto === 'preserve',
)
const virtualDirectives = {
...(computed[lowerLevelSelector].virtualDirectives || {}),
}
const virtualDirectivesRaw = {
...(computed[lowerLevelSelector].virtualDirectivesRaw || {}),
}
// Storing color data in lower layer to use as custom css properties
virtualDirectives[virtualName] = getTextColorAlpha(
newTextRule.directives,
textColor,
dynamicVars,
)
virtualDirectivesRaw[virtualName] = textColor
computed[lowerLevelSelector].virtualDirectives = virtualDirectives
computed[lowerLevelSelector].virtualDirectivesRaw = virtualDirectivesRaw
return {
dynamicVars,
selector: cssSelector.split(/ /g).slice(0, -1).join(' '),
...combination,
directives: {},
virtualDirectives,
virtualDirectivesRaw,
}
} else {
computed[selector] = computed[selector] || {}
// TODO: DEFAULT TEXT COLOR
const lowerLevelStackedBackground =
stacked[lowerLevelSelector] || convert(ultimateBackgroundColor).rgb
if (computedDirectives.background) {
let inheritRule = null
const variantRules = ruleset.filter(
findRules({
component: combination.component,
variant: combination.variant,
parent: combination.parent,
}),
)
const lastVariantRule = variantRules[variantRules.length - 1]
const lastVariantSelector = ruleToSelector(lastVariantRule, true)
if (lastVariantRule && lastVariantSelector !== selector) {
inheritRule = lastVariantRule
} else {
const normalRules = ruleset.filter(
findRules({
component: combination.component,
parent: combination.parent,
}),
)
const lastNormalRule = normalRules[normalRules.length - 1]
inheritRule = lastNormalRule
}
const inheritSelector = ruleToSelector(
{ ...inheritRule, parent: combination.parent },
true,
)
const inheritedBackground = computed[inheritSelector].background
dynamicVars.inheritedBackground = inheritedBackground
const rgb = convert(
findColor(computedDirectives.background, {
dynamicVars,
staticVars,
}),
).rgb
if (!stacked[selector]) {
let blend
const alpha = computedDirectives.opacity ?? 1
if (alpha >= 1) {
blend = rgb
} else if (alpha <= 0) {
blend = lowerLevelStackedBackground
} else {
blend = alphaBlend(
rgb,
computedDirectives.opacity,
lowerLevelStackedBackground,
)
}
stacked[selector] = blend
computed[selector].background = {
...rgb,
a: computedDirectives.opacity ?? 1,
}
}
}
if (computedDirectives.shadow) {
dynamicVars.shadow = flattenDeep(
findShadow(flattenDeep(computedDirectives.shadow), {
dynamicVars,
staticVars,
}),
)
}
if (!stacked[selector]) {
computedDirectives.background = 'transparent'
computedDirectives.opacity = 0
stacked[selector] = lowerLevelStackedBackground
computed[selector].background = {
...lowerLevelStackedBackground,
a: 0,
}
}
dynamicVars.stacked = stacked[selector]
dynamicVars.background = computed[selector].background
const dynamicSlots = Object.entries(computedDirectives).filter(([k]) =>
k.startsWith('--'),
)
dynamicSlots.forEach(([k, v]) => {
const [type, value] = v.split('|').map((x) => x.trim()) // woah, Extreme!
switch (type) {
case 'color': {
const color = findColor(value, { dynamicVars, staticVars })
dynamicVars[k] = color
if (combination.component === rootComponentName) {
staticVars[k.substring(2)] = color
}
break
}
case 'shadow': {
const shadow = value
.split(/,/g)
.map((s) => s.trim())
.filter(Boolean)
dynamicVars[k] = shadow
if (combination.component === rootComponentName) {
staticVars[k.substring(2)] = shadow
}
break
}
case 'generic': {
dynamicVars[k] = value
if (combination.component === rootComponentName) {
staticVars[k.substring(2)] = value
}
break
}
}
})
const rule = {
dynamicVars,
selector: cssSelector,
...combination,
directives: computedDirectives,
}
return rule
}
} catch (e) {
const { component, variant, state } = combination
throw new Error(
`Error processing combination ${component}.${variant}:${state.join(':')}: ${e}`,
)
}
}
const processInnerComponent = (component, parent) => {
const combinations = []
const { states: originalStates = {}, variants: originalVariants = {} } =
component
let validInnerComponents
if (editMode) {
const temp =
component.validInnerComponentsLite ||
component.validInnerComponents ||
[]
validInnerComponents = temp.filter(
(c) => virtualComponents.has(c) && !nonEditableComponents.has(c),
)
} else if (liteMode) {
validInnerComponents =
component.validInnerComponentsLite ||
component.validInnerComponents ||
[]
} else if (
component.name === 'Root' ||
component.states != null ||
component.background?.includes('--parent')
) {
validInnerComponents = component.validInnerComponents || []
} else {
validInnerComponents =
component.validInnerComponents?.filter(
(c) =>
virtualComponents.has(c) ||
transparentComponents.has(c) ||
extraCompileComponents.has(c),
) || []
}
// Normalizing states and variants to always include "normal"
const states = { normal: '', ...originalStates }
const variants = { normal: '', ...originalVariants }
const innerComponents = validInnerComponents.map((name) => {
const result = components[name]
if (result === undefined)
console.error(
`Component ${component.name} references a component ${name} which does not exist!`,
)
return result
})
// Optimization: we only really need combinations without "normal" because all states implicitly have it
const permutationStateKeys = Object.keys(states).filter(
(s) => s !== 'normal',
)
const stateCombinations =
onlyNormalState && !virtualComponents.has(component.name)
? [['normal']]
: [
['normal'],
...getAllPossibleCombinations(permutationStateKeys)
.map((combination) => ['normal', ...combination])
.filter((combo) => {
// Optimization: filter out some hard-coded combinations that don't make sense
- if (combo.indexOf('disabled') >= 0) {
+ if (combo.includes('disabled')) {
return !(
- combo.indexOf('hover') >= 0 ||
- combo.indexOf('focused') >= 0 ||
- combo.indexOf('pressed') >= 0
+ combo.includes('hover') ||
+ combo.includes('focused') ||
+ combo.includes('pressed')
)
}
return true
}),
]
const stateVariantCombination = Object.keys(variants)
.map((variant) => {
return stateCombinations.map((state) => ({ variant, state }))
})
- .reduce((acc, x) => [...acc, ...x], [])
+ .flat()
stateVariantCombination.forEach((combination) => {
combination.component = component.name
combination.lazy = component.lazy || parent?.lazy
combination.parent = parent
- if (!liteMode && combination.state.indexOf('hover') >= 0) {
+ if (!liteMode && combination.state.includes('hover')) {
combination.lazy = true
}
if (
!liteMode &&
parent?.component !== 'Root' &&
!virtualComponents.has(component.name) &&
!transparentComponents.has(component.name) &&
extraCompileComponents.has(component.name)
) {
combination.lazy = true
}
combinations.push(combination)
innerComponents.forEach((innerComponent) => {
combinations.push(...processInnerComponent(innerComponent, combination))
})
})
return combinations
}
const t0 = performance.now()
const combinations = processInnerComponent(
components[rootComponentName] ?? components.Root,
)
const t1 = performance.now()
if (debug) {
console.debug('Tree traveral took ' + (t1 - t0) + ' ms')
}
const result = combinations
.map((combination) => {
if (combination.lazy) {
return async () => processCombination(combination)
} else {
return processCombination(combination)
}
})
.filter(Boolean)
const t2 = performance.now()
if (debug) {
console.debug('Eager processing took ' + (t2 - t1) + ' ms')
}
// optimization to traverse big-ass array only once instead of twice
const eager = []
const lazy = []
result.forEach((x) => {
if (typeof x === 'function') {
lazy.push(x)
} else {
eager.push(x)
}
})
return {
lazy,
eager,
staticVars,
engineChecksum,
themeChecksum: sum([lazy, eager]),
}
}
diff --git a/src/services/user_highlighter/user_highlighter.js b/src/services/user_highlighter/user_highlighter.js
index 697496c918..abd0d96332 100644
--- a/src/services/user_highlighter/user_highlighter.js
+++ b/src/services/user_highlighter/user_highlighter.js
@@ -1,54 +1,54 @@
import { hex2rgb } from '../color_convert/color_convert.js'
const highlightStyle = (prefs) => {
if (prefs === undefined) return
const { color = '#FFFFFF', type } = prefs
if (typeof color !== 'string') return
const rgb = hex2rgb(color)
if (rgb == null) return
const solidColor = `rgb(${Math.floor(rgb.r)}, ${Math.floor(rgb.g)}, ${Math.floor(rgb.b)})`
const tintColor = `rgba(${Math.floor(rgb.r)}, ${Math.floor(rgb.g)}, ${Math.floor(rgb.b)}, .1)`
const tintColor2 = `rgba(${Math.floor(rgb.r)}, ${Math.floor(rgb.g)}, ${Math.floor(rgb.b)}, .2)`
const customProps = {
'--____highlight-solidColor': solidColor,
'--____highlight-tintColor': tintColor,
'--____highlight-tintColor2': tintColor2,
}
if (type === 'striped') {
return {
backgroundImage: [
'repeating-linear-gradient(135deg,',
`${tintColor} ,`,
`${tintColor} 20px,`,
`${tintColor2} 20px,`,
`${tintColor2} 40px`,
].join(' '),
backgroundPosition: '0 0',
...customProps,
}
} else if (type === 'solid') {
return {
backgroundColor: tintColor2,
...customProps,
}
} else if (type === 'side') {
return {
backgroundImage: [
'linear-gradient(to right,',
`${solidColor} ,`,
`${solidColor} 2px,`,
'transparent 6px',
].join(' '),
backgroundPosition: '0 0',
...customProps,
}
}
}
const highlightClass = (user) => {
return (
- 'USER____' + user.screen_name?.replace(/\./g, '_').replace(/@/g, '_AT_')
+ 'USER____' + user.screen_name?.replaceAll('.', '_').replaceAll('@', '_AT_')
)
}
export { highlightClass, highlightStyle }
diff --git a/src/stores/chats.js b/src/stores/chats.js
index bc5b7f101e..5ade0c126c 100644
--- a/src/stores/chats.js
+++ b/src/stores/chats.js
@@ -1,114 +1,113 @@
import { find, omitBy, orderBy, sumBy } from 'lodash'
import { defineStore } from 'pinia'
import { maybeShowChatNotification } from '../services/chat_utils/chat_utils.js'
import { promiseInterval } from '../services/promise_interval/promise_interval.js'
import { useOAuthStore } from 'src/stores/oauth.js'
import { chats } from 'src/api/chats.js'
const emptyChatList = () => ({
data: [],
idStore: {},
})
const defaultState = {
chatList: emptyChatList(),
chatListFetcher: null,
}
const getChatById = (state, id) => {
return find(state.chatList.data, { id })
}
export const useChatsStore = defineStore('chats', {
state: () => ({ ...defaultState }),
getters: {
sortedChatList(state) {
return orderBy(state.chatList.data, ['updated_at'], ['desc'])
},
unreadChatsCount(state) {
return sumBy(state.chatList.data, 'unread')
},
},
actions: {
startFetchingChats() {
const fetcher = () => this.fetchChats()
this.setChatListFetcher(() => promiseInterval(fetcher, 5000))
},
stopFetchingChats() {
this.setChatListFetcher(null)
},
async fetchChats() {
const { data } = await chats({
credentials: useOAuthStore().token,
})
this.addNewChats(data)
},
setChatListFetcher(fetcher) {
const prevFetcher = this.chatListFetcher
if (prevFetcher) {
prevFetcher.stop()
}
this.chatListFetcher = fetcher?.()
},
resetChats() {
this.chatList = emptyChatList()
this.setChatListFetcher(null)
},
addNewChats(chats) {
window.vuex.commit(
'addNewUsers',
- chats.map((k) => k.account).filter((k) => k),
+ chats.map((k) => k.account).filter(Boolean),
)
chats.forEach((updatedChat) => {
const chat = getChatById(this, updatedChat.id)
if (chat) {
const isNewMessage =
- (chat.lastMessage && chat.lastMessage.id) !==
- (updatedChat.lastMessage && updatedChat.lastMessage.id)
+ chat.lastMessage?.id !== updatedChat.lastMessage?.id
chat.lastMessage = updatedChat.lastMessage
chat.unread = updatedChat.unread
chat.updated_at = updatedChat.updated_at
if (isNewMessage && chat.unread) {
maybeShowChatNotification(chat)
}
} else {
this.chatList.data.push(updatedChat)
this.chatList.idStore[updatedChat.id] = updatedChat
}
})
},
readChat(id) {
const chat = getChatById(this, id)
if (chat) {
chat.unread = 0
}
},
updateChat({ chat: updatedChat }) {
const chat = getChatById(this, updatedChat.id)
if (chat) {
chat.lastMessage = updatedChat.lastMessage
chat.unread = updatedChat.unread
chat.updated_at = updatedChat.updated_at
}
if (!chat) {
this.chatList.data.unshift(updatedChat)
}
this.chatList.idStore[updatedChat.id] = updatedChat
},
deleteChat(id) {
this.chats.data = this.chats.data.filter(
(conversation) => conversation.last_status.id !== id,
)
this.chats.idStore = omitBy(
this.chats.idStore,
(conversation) => conversation.last_status.id === id,
)
},
},
})
diff --git a/src/stores/emoji.js b/src/stores/emoji.js
index 3316f83284..8135a483e5 100644
--- a/src/stores/emoji.js
+++ b/src/stores/emoji.js
@@ -1,321 +1,317 @@
import { merge } from 'lodash'
import { defineStore } from 'pinia'
import { useInstanceStore } from 'src/stores/instance.js'
import { useOAuthStore } from 'src/stores/oauth.js'
import { listEmojiPacks } from 'src/api/public.js'
import { ensureFinalFallback } from 'src/i18n/languages.js'
import { annotationsLoader } from 'virtual:pleroma-fe/emoji-annotations'
const defaultState = {
// Custom emoji from server
customEmoji: [],
customEmojiFetched: false,
adminPacksLocal: null,
adminPacksLocalLoading: true,
// Unicode emoji from bundle
emoji: {},
emojiFetched: false,
unicodeEmojiAnnotations: {},
// Stickers
stickers: null,
}
const SORTED_EMOJI_GROUP_IDS = [
'smileys-and-emotion',
'people-and-body',
'animals-and-nature',
'food-and-drink',
'travel-and-places',
'activities',
'objects',
'symbols',
'flags',
]
const REGIONAL_INDICATORS = (() => {
const start = 0x1f1e6
const end = 0x1f1ff
const A = 'A'.codePointAt(0)
const res = new Array(end - start + 1)
for (let i = start; i <= end; ++i) {
const letter = String.fromCodePoint(A + i - start)
res[i - start] = {
replacement: String.fromCodePoint(i),
imageUrl: false,
displayText: 'regional_indicator_' + letter,
displayTextI18n: {
key: 'emoji.regional_indicator',
args: { letter },
},
}
}
return res
})()
const loadAnnotations = (lang) => {
return annotationsLoader[lang]().then((k) => k.default)
}
const injectAnnotations = (emoji, annotations) => {
const availableLangs = Object.keys(annotations)
return {
...emoji,
annotations: availableLangs.reduce((acc, cur) => {
acc[cur] = annotations[cur][emoji.replacement]
return acc
}, {}),
}
}
const injectRegionalIndicators = (groups) => {
groups.symbols.push(...REGIONAL_INDICATORS)
return groups
}
export const useEmojiStore = defineStore('emoji', {
state: () => ({ ...defaultState }),
getters: {
groupedCustomEmojis(state) {
const packsOf = (emoji) => {
const packs = emoji.tags
.filter((k) => k.startsWith('pack:'))
.map((k) => {
const packName = k.slice(5) // remove 'pack:' prefix
return {
id: `custom-${packName}`,
text: packName,
}
})
if (!packs.length) {
return [
{
id: 'unpacked',
},
]
} else {
return packs
}
}
return this.customEmoji.reduce((res, emoji) => {
packsOf(emoji).forEach(({ id: packId, text: packName }) => {
if (!res[packId]) {
res[packId] = {
id: packId,
text: packName,
image: emoji.imageUrl,
emojis: [],
}
}
res[packId].emojis.push(emoji)
})
return res
}, {})
},
standardEmojiList(state) {
- return (
- SORTED_EMOJI_GROUP_IDS.map((groupId) =>
- (this.emoji[groupId] || []).map((k) =>
- injectAnnotations(k, this.unicodeEmojiAnnotations),
- ),
- ).reduce((a, b) => a.concat(b), []) ?? []
- )
+ return SORTED_EMOJI_GROUP_IDS.map((groupId) =>
+ (this.emoji[groupId] || []).map((k) =>
+ injectAnnotations(k, this.unicodeEmojiAnnotations),
+ ),
+ ).flat()
},
standardEmojiGroupList(state) {
- return (
- SORTED_EMOJI_GROUP_IDS.map((groupId) => ({
- id: groupId,
- emojis: (this.emoji[groupId] || []).map((k) =>
- injectAnnotations(k, this.unicodeEmojiAnnotations),
- ),
- })) ?? []
- )
+ return SORTED_EMOJI_GROUP_IDS.map((groupId) => ({
+ id: groupId,
+ emojis: (this.emoji[groupId] || []).map((k) =>
+ injectAnnotations(k, this.unicodeEmojiAnnotations),
+ ),
+ }))
},
},
actions: {
setStickers(stickers) {
this.stickers = stickers
},
async getStaticEmoji() {
try {
// See build/emojis_plugin for more details
- const values = (await import('/src/assets/emoji.json')).default
+ const values = (await import('src/assets/emoji.json')).default
const emoji = Object.keys(values).reduce((res, groupId) => {
res[groupId] = values[groupId].map((e) => ({
displayText: e.slug,
imageUrl: false,
replacement: e.emoji,
}))
return res
}, {})
this.emoji = injectRegionalIndicators(emoji)
} catch (e) {
console.warn("Can't load static emoji\n", e)
}
},
loadUnicodeEmojiData(language) {
const langList = ensureFinalFallback(language)
return Promise.all(
langList.map(async (lang) => {
if (!this.unicodeEmojiAnnotations[lang]) {
try {
const annotations = await loadAnnotations(lang)
this.unicodeEmojiAnnotations[lang] = annotations
} catch (e) {
console.warn(
`Error loading unicode emoji annotations for ${lang}: `,
e,
)
// ignore
}
}
}),
)
},
async getAdminPacksLocal(refresh) {
if (!refresh && this.adminPacksLocal) return this.adminPacksLocal
this.adminPacksLocalLoading = true
this.adminPacksLocal = await this.getAdminPacks(
useInstanceStore().server,
(params) =>
listEmojiPacks({
...params,
credentials: useOAuthStore().token,
}).then(({ data }) => data),
)
this.adminPacksLocalLoading = false
},
async getAdminPacks(instance, listFunction) {
const currentUser = window.vuex.state.users.currentUser
if (!currentUser.rights.admin) return
const pageSize = 25
return await listFunction({
instance,
page: 1,
pageSize: 0,
})
.then((data) => {
const promises = []
for (let i = 0; i < Math.ceil(data.count / pageSize); i++) {
promises.push(
listFunction({
instance,
page: i,
pageSize,
}).then((pageData) => {
return pageData.packs
}),
)
}
return Promise.all(promises).then((results) => {
return merge({}, ...results)
})
})
.then((allPacks) => {
// Sort by key
return Object.keys(allPacks)
- .sort()
+ .sort((a, b) => a.localeCompare(b))
.reduce((acc, key) => {
if (key.length === 0) return acc
acc[key] = allPacks[key]
return acc
}, {})
})
.catch((data) => {
console.error(data)
})
},
async getCustomEmoji() {
try {
let res = await window.fetch('/api/v1/pleroma/emoji')
if (!res.ok) {
res = await window.fetch('/api/pleroma/emoji.json')
}
if (res.ok) {
const result = await res.json()
const values = Array.isArray(result)
? Object.assign({}, ...result)
: result
const caseInsensitiveStrCmp = (a, b) => {
const la = a.toLowerCase()
const lb = b.toLowerCase()
return la > lb ? 1 : la < lb ? -1 : 0
}
const noPackLast = (a, b) => {
const aNull = a === ''
const bNull = b === ''
if (aNull === bNull) {
return 0
} else if (aNull && !bNull) {
return 1
} else {
return -1
}
}
const byPackThenByName = (a, b) => {
const packOf = (emoji) =>
(emoji.tags.filter((k) => k.startsWith('pack:'))[0] || '').slice(
5,
)
const packOfA = packOf(a)
const packOfB = packOf(b)
return (
noPackLast(packOfA, packOfB) ||
caseInsensitiveStrCmp(packOfA, packOfB) ||
caseInsensitiveStrCmp(a.displayText, b.displayText)
)
}
const emoji = Object.entries(values)
.map(([key, value]) => {
const imageUrl = value.image_url
return {
displayText: key,
imageUrl: imageUrl
? useInstanceStore().server + imageUrl
: value,
tags: imageUrl
? value.tags.sort((a, b) => (a > b ? 1 : 0))
: ['utf'],
replacement: `:${key}: `,
}
// Technically could use tags but those are kinda useless right now,
// should have been "pack" field, that would be more useful
})
.sort(byPackThenByName)
this.customEmoji = emoji
} else {
throw res
}
} catch (e) {
console.warn("Can't load custom emojis\n", e)
}
},
fetchEmoji() {
if (!this.customEmojiFetched) {
this.getCustomEmoji().then(() => (this.customEmojiFetched = true))
}
if (!this.emojiFetched) {
this.getStaticEmoji().then(() => (this.emojiFetched = true))
}
},
},
})
diff --git a/src/stores/interface.js b/src/stores/interface.js
index 87ef658650..7be72a8d2a 100644
--- a/src/stores/interface.js
+++ b/src/stores/interface.js
@@ -1,803 +1,803 @@
import { defineStore } from 'pinia'
import {
applyTheme,
getResourcesIndex,
tryLoadCache,
} from '../services/style_setter/style_setter.js'
import { deserialize } from '../services/theme_data/iss_deserializer.js'
import { useInstanceStore } from 'src/stores/instance.js'
import { useMergedConfigStore } from 'src/stores/merged_config.js'
import { useSyncConfigStore } from 'src/stores/sync_config.js'
import {
CURRENT_VERSION,
generatePreset,
} from 'src/services/theme_data/theme_data.service.js'
import { convertTheme2To3 } from 'src/services/theme_data/theme2_to_theme3.js'
const GENERIC_FONT_NAMES = new Set([
'serif',
'sans-serif',
'system-ui',
'cursive',
'fantasy',
'math',
'monospace',
])
export const useInterfaceStore = defineStore('interface', {
state: () => ({
localFonts: null,
themeApplied: false,
themeChangeInProgress: false,
themeVersion: 'v3',
styleNameUsed: null,
styleDataUsed: null,
useStylePalette: false, // hack for applying styles from appearance tab
paletteNameUsed: null,
paletteDataUsed: null,
themeNameUsed: null,
themeDataUsed: null,
temporaryChangesTimeoutId: null,
temporaryChangesCountdown: -1, // used for temporary options that revert after a timeout
temporaryChangesConfirm: () => {
/* no-op */
}, // used for applying temporary options
temporaryChangesRevert: () => {
/* no-op */
}, // used for reverting temporary options
settingsModalState: 'hidden',
settingsModalLoadedUser: false,
settingsModalLoadedAdmin: false,
settingsModalTargetTab: null,
settingsModalMode: 'user',
settings: {
currentSaveStateNotice: null,
noticeClearTimeout: null,
notificationPermission: null,
},
browserSupport: {
cssFilter:
- window.CSS &&
- window.CSS.supports &&
+ window.CSS?.supports &&
(window.CSS.supports('filter', 'drop-shadow(0 0)') ||
window.CSS.supports('-webkit-filter', 'drop-shadow(0 0)')),
localFonts: typeof window.queryLocalFonts === 'function',
},
layoutType: 'normal',
globalNotices: [],
globalError: null,
layoutHeight: 0,
lastTimeline: null,
foreignProfileBackground: null,
}),
actions: {
setTemporaryChanges({ confirm, revert }) {
this.temporaryChangesCountdown = 10
this.temporaryChangesConfirm = confirm
this.temporaryChangesRevert = revert
const countdownFunc = () => {
if (this.temporaryChangesCountdown <= 1) {
this.temporaryChangesRevert()
this.clearTemporaryChanges()
} else {
this.temporaryChangesCountdown--
this.temporaryChangesTimeoutId = setTimeout(countdownFunc, 1000)
}
}
this.temporaryChangesTimeoutId = setTimeout(countdownFunc, 1000)
},
clearTemporaryChanges() {
this.temporaryChangesTimeoutId ??
clearTimeout(this.temporaryChangesTimeoutId)
this.temporaryChangesTimeoutId = null
this.temporaryChangesCountdown = -1
this.temporaryChangesConfirm = () => {
/* no-op */
}
this.temporaryChangesRevert = () => {
/* no-op */
}
},
setPageTitle(option = '') {
try {
document.title = `${option} ${useInstanceStore().instanceIdentity.name}`
} catch (error) {
console.error(`${error}`)
}
},
setForeignProfileBackground(url) {
this.foreignProfileBackground = url
},
settingsSaved({ success, error }) {
if (success) {
if (this.noticeClearTimeout) {
clearTimeout(this.noticeClearTimeout)
}
this.settings.currentSaveStateNotice = { error: false, data: success }
this.settings.noticeClearTimeout = setTimeout(
() => delete this.settings.currentSaveStateNotice,
2000,
)
} else {
this.settings.currentSaveStateNotice = { error: true, errorData: error }
}
},
setNotificationPermission(permission) {
this.notificationPermission = permission
},
closeSettingsModal() {
this.settingsModalState = 'hidden'
},
openSettingsModal(value) {
this.settingsModalMode = value
this.settingsModalState = 'visible'
if (value === 'user') {
if (!this.settingsModalLoadedUser) {
this.settingsModalLoadedUser = true
}
} else if (value === 'admin') {
if (!this.settingsModalLoadedAdmin) {
this.settingsModalLoadedAdmin = true
}
}
},
setSettingsModalState(newState) {
const oldState = this.settingsModalState
const legal = (() => {
switch (oldState) {
case 'minimized':
return true
case 'visible':
return true
case 'hidden':
return newState === 'visible'
}
})()
if (legal) {
this.settingsModalState = newState
}
},
toggleMinimizeSettingsModal() {
switch (this.settingsModalState) {
case 'minimized':
this.settingsModalState = 'visible'
return
case 'visible':
this.settingsModalState = 'minimized'
return
case 'hidden':
return
default:
throw new Error(
`Illegal minimization state of settings modal: ${this.settingsModalState}`,
)
}
},
clearSettingsModalTargetTab() {
this.settingsModalTargetTab = null
},
openSettingsModalTab(value, mode = 'user') {
this.settingsModalTargetTab = value
this.openSettingsModal(mode)
},
removeGlobalNotice(notice) {
this.globalNotices = this.globalNotices.filter((n) => n !== notice)
},
setGlobalError({ error, instance, info }) {
switch (info) {
case 'https://vuejs.org/error-reference/#runtime-13': {
this.globalError = {
title: 'general.refresh_required',
content: 'general.refresh_required_content',
// `true` disables cache on Firefox (non-standard)
recover: () => window.location.reload(true),
recoverText: 'general.refresh_required_refresh',
error,
}
break
}
default: {
this.globalError = { error }
break
}
}
},
clearGlobalError() {
this.globalError = null
},
pushGlobalNotice({
messageKey,
messageArgs = {},
level = 'error',
timeout = 5000,
}) {
const notice = {
messageKey,
messageArgs,
level,
}
this.globalNotices.push(notice)
// Adding a new element to array wraps it in a Proxy, which breaks the comparison
// TODO: Generate UUID or something instead or relying on !== operator?
const newNotice = this.globalNotices[this.globalNotices.length - 1]
if (timeout > 0) {
setTimeout(() => this.removeGlobalNotice(newNotice), timeout)
}
return newNotice
},
setLayoutHeight(value) {
this.layoutHeight = value
},
setLayoutWidth(value) {
let width = value
if (value !== undefined) {
this.layoutWidth = value
} else {
width = this.layoutWidth
}
const mobileLayout = width <= 800
const normalOrMobile = mobileLayout ? 'mobile' : 'normal'
const { thirdColumnMode } = useMergedConfigStore().mergedConfig
if (thirdColumnMode === 'none' || !window.vuex.state.users.currentUser) {
this.layoutType = normalOrMobile
} else {
const wideLayout = width >= 1300
this.layoutType = wideLayout ? 'wide' : normalOrMobile
}
},
setFontsList(value) {
this.localFonts = [...new Set(value.map(({ family }) => family)).values()]
},
queryLocalFonts() {
if (this.localFonts !== null) return
this.setFontsList([])
if (!this.browserSupport.localFonts) {
return
}
window
.queryLocalFonts()
.then((fonts) => {
this.setFontsList(fonts)
})
.catch((e) => {
this.pushGlobalNotice({
messageKey: 'settings.style.themes3.font.font_list_unavailable',
messageArgs: {
error: e,
},
level: 'error',
})
})
},
setLastTimeline(value) {
this.lastTimeline = value
},
async fetchPalettesIndex() {
try {
const value = await getResourcesIndex('/static/palettes/index.json')
useInstanceStore().set({
path: 'palettesIndex',
value,
})
return value
} catch (e) {
console.error('Could not fetch palettes index', e)
useInstanceStore().set({
path: 'palettesIndex',
value: { _error: e },
})
- return Promise.resolve({})
+ return {}
}
},
setPalette(value) {
this.resetThemeV3Palette()
this.resetThemeV2()
useSyncConfigStore().setPreference({ path: 'simple.palette', value })
useSyncConfigStore().pushSyncConfig()
this.applyTheme({ recompile: true })
},
setPaletteCustom(value) {
this.resetThemeV3Palette()
this.resetThemeV2()
useSyncConfigStore().setPreference({
path: 'simple.paletteCustomData',
value,
})
useSyncConfigStore().pushSyncConfig()
this.applyTheme({ recompile: true })
},
async fetchStylesIndex() {
try {
const value = await getResourcesIndex(
'/static/styles/index.json',
deserialize,
)
useInstanceStore().set({ path: 'stylesIndex', value })
return value
} catch (e) {
console.error('Could not fetch styles index', e)
useInstanceStore().set({
path: 'simple.stylesIndex',
value: { _error: e },
})
- return Promise.resolve({})
+ return {}
}
},
setStyle(value) {
this.resetThemeV3()
this.resetThemeV2()
this.resetThemeV3Palette()
useSyncConfigStore().setPreference({ path: 'simple.style', value })
useSyncConfigStore().pushSyncConfig()
this.useStylePalette = true
this.applyTheme({ recompile: true }).then(() => {
this.useStylePalette = false
})
},
setStyleCustom(value) {
this.resetThemeV3()
this.resetThemeV2()
this.resetThemeV3Palette()
useSyncConfigStore().setPreference({
path: 'simple.styleCustomData',
value,
})
useSyncConfigStore().pushSyncConfig()
this.useStylePalette = true
this.applyTheme({ recompile: true }).then(() => {
this.useStylePalette = false
})
},
async fetchThemesIndex() {
try {
const value = await getResourcesIndex('/static/styles.json')
useInstanceStore().set({ path: 'themesIndex', value })
return value
} catch (e) {
console.error('Could not fetch themes index', e)
useInstanceStore().set({
path: 'themesIndex',
value: { _error: e },
})
- return Promise.resolve({})
+ return {}
}
},
setTheme(value) {
this.resetThemeV3()
this.resetThemeV3Palette()
this.resetThemeV2()
useSyncConfigStore().setPreference({ path: 'simple.theme', value })
useSyncConfigStore().pushSyncConfig()
this.applyTheme({ recompile: true })
},
setThemeCustom(value) {
this.resetThemeV3()
this.resetThemeV3Palette()
this.resetThemeV2()
useSyncConfigStore().setPreference({ path: 'simple.customTheme', value })
useSyncConfigStore().setPreference({
path: 'simple.customThemeSource',
value,
})
useSyncConfigStore().pushSyncConfig()
this.applyTheme({ recompile: true })
},
resetThemeV3() {
useSyncConfigStore().setPreference({ path: 'simple.style', value: null })
useSyncConfigStore().setPreference({
path: 'simple.styleCustomData',
value: null,
})
},
resetThemeV3Palette() {
useSyncConfigStore().setPreference({
path: 'simple.palette',
value: null,
})
useSyncConfigStore().setPreference({
path: 'simple.paletteCustomData',
value: null,
})
},
resetThemeV2() {
useSyncConfigStore().setPreference({ path: 'simple.theme', value: null })
useSyncConfigStore().setPreference({
path: 'simple.customTheme',
value: null,
})
useSyncConfigStore().setPreference({
path: 'simple.customThemeSource',
value: null,
})
},
async getThemeData() {
const getData = async (resource, index, customData, name) => {
const capitalizedResource =
resource[0].toUpperCase() + resource.slice(1)
const result = {}
if (customData) {
result.nameUsed = 'custom' // custom data overrides name
result.dataUsed = customData
} else {
result.nameUsed = name
if (result.nameUsed == null) {
result.dataUsed = null
return result
}
let fetchFunc = index[result.nameUsed]
// Fallbacks
if (!fetchFunc) {
if (resource === 'style' || resource === 'palette') {
return result
}
const newName = Object.keys(index)[0]
fetchFunc = index[newName]
console.warn(
`${capitalizedResource} with id '${this.styleNameUsed}' not found, trying back to '${newName}'`,
)
if (!fetchFunc) {
console.warn(
`${capitalizedResource} doesn't have a fallback, defaulting to stock.`,
)
fetchFunc = () => Promise.resolve(null)
}
}
result.dataUsed = await fetchFunc()
}
return result
}
let {
theme: instanceThemeName,
style: instanceStyleName,
palette: instancePaletteName,
} = useInstanceStore().instanceIdentity
let { themesIndex, stylesIndex, palettesIndex } = useInstanceStore()
const {
style: userStyleName,
styleCustomData: userStyleCustomData,
palette: userPaletteName,
paletteCustomData: userPaletteCustomData,
} = useMergedConfigStore().mergedConfig
let {
theme: userThemeV2Name,
customTheme: userThemeV2Snapshot,
customThemeSource: userThemeV2Source,
} = useMergedConfigStore().mergedConfig
let majorVersionUsed
console.debug(
`User V3 palette: ${userPaletteName}, style: ${userStyleName} , custom: ${!!userStyleCustomData}`,
)
console.debug(
`User V2 name: ${userThemeV2Name}, source: ${!!userThemeV2Source}, snapshot: ${!!userThemeV2Snapshot}`,
)
console.debug(
`Instance V3 palette: ${instancePaletteName}, style: ${instanceStyleName}`,
)
console.debug('Instance V2 theme: ' + instanceThemeName)
if (
userPaletteName ||
userPaletteCustomData ||
userStyleName ||
userStyleCustomData ||
// User V2 overrides instance V3
((instancePaletteName || instanceStyleName) &&
instanceThemeName == null &&
userThemeV2Name == null)
) {
// Palette and/or style overrides V2 themes
instanceThemeName = null
userThemeV2Name = null
userThemeV2Source = null
userThemeV2Snapshot = null
majorVersionUsed = 'v3'
} else if (
userThemeV2Name ||
userThemeV2Snapshot ||
userThemeV2Source ||
instanceThemeName
) {
majorVersionUsed = 'v2'
} else {
// if all fails fallback to v3
majorVersionUsed = 'v3'
}
if (majorVersionUsed === 'v3') {
const result = await Promise.all([
this.fetchPalettesIndex(),
this.fetchStylesIndex(),
])
palettesIndex = result[0]
stylesIndex = result[1]
} else {
// Promise.all just to be uniform with v3
const result = await Promise.all([this.fetchThemesIndex()])
themesIndex = result[0]
}
this.themeVersion = majorVersionUsed
console.debug('Version used', majorVersionUsed)
if (majorVersionUsed === 'v3') {
this.themeDataUsed = null
this.themeNameUsed = null
const style = await getData(
'style',
stylesIndex,
userStyleCustomData,
userStyleName || instanceStyleName,
)
this.styleNameUsed = style.nameUsed
this.styleDataUsed = style.dataUsed
let firstStylePaletteName = null
style.dataUsed
?.filter((x) => x.component === '@palette')
.map((x) => {
const cleanDirectives = Object.fromEntries(
Object.entries(x.directives).filter(([k]) => k),
)
return { name: x.variant, ...cleanDirectives }
})
.forEach((palette) => {
- const key = 'style.' + palette.name.toLowerCase().replace(/ /g, '_')
+ const key =
+ 'style.' + palette.name.toLowerCase().replaceAll(' ', '_')
if (!firstStylePaletteName) firstStylePaletteName = key
palettesIndex[key] = () => Promise.resolve(palette)
})
const palette = await getData(
'palette',
palettesIndex,
userPaletteCustomData,
this.useStylePalette
? firstStylePaletteName
: userPaletteName || instancePaletteName,
)
if (this.useStylePalette) {
useSyncConfigStore().setPreference({
path: 'simple.palette',
value: firstStylePaletteName,
})
useSyncConfigStore().pushSyncConfig()
}
this.paletteNameUsed = palette.nameUsed
this.paletteDataUsed = palette.dataUsed
if (this.paletteDataUsed) {
this.paletteDataUsed.link =
this.paletteDataUsed.link || this.paletteDataUsed.accent
this.paletteDataUsed.accent =
this.paletteDataUsed.accent || this.paletteDataUsed.link
}
if (Array.isArray(this.paletteDataUsed)) {
const [
name,
bg,
fg,
text,
link,
cRed = '#FF0000',
cGreen = '#00FF00',
cBlue = '#0000FF',
cOrange = '#E3FF00',
] = palette.dataUsed
this.paletteDataUsed = {
name,
bg,
fg,
text,
link,
accent: link,
cRed,
cBlue,
cGreen,
cOrange,
}
}
console.debug('Palette data used', palette.dataUsed)
} else {
this.styleNameUsed = null
this.styleDataUsed = null
this.paletteNameUsed = null
this.paletteDataUsed = null
const theme = await getData(
'theme',
themesIndex,
userThemeV2Source || userThemeV2Snapshot,
userThemeV2Name || instanceThemeName,
)
this.themeNameUsed = theme.nameUsed
this.themeDataUsed = theme.dataUsed
}
},
async setThemeApplied() {
this.themeApplied = true
},
async applyTheme({ recompile = false } = {}) {
const { mergedConfig } = useMergedConfigStore()
const { forceThemeRecompilation, themeDebug } = mergedConfig
this.themeChangeInProgress = true
// If we're not forced to recompile try using
// cache (tryLoadCache return true if load successful)
const forceRecompile = forceThemeRecompilation || recompile
await this.getThemeData()
if (!forceRecompile && !themeDebug && (await tryLoadCache())) {
this.themeChangeInProgress = false
return this.setThemeApplied()
}
window.splashUpdate('splash.theme')
try {
const paletteIss = (() => {
if (!this.paletteDataUsed) return null
const result = {
component: 'Root',
directives: {},
}
Object.entries(this.paletteDataUsed)
.filter(([k]) => k !== 'name')
.forEach(([k, v]) => {
let issRootDirectiveName
switch (k) {
case 'background':
issRootDirectiveName = 'bg'
break
case 'foreground':
issRootDirectiveName = 'fg'
break
default:
issRootDirectiveName = k
}
result.directives['--' + issRootDirectiveName] = 'color | ' + v
})
return result
})()
const theme2ruleset =
this.themeDataUsed &&
convertTheme2To3(normalizeThemeData(this.themeDataUsed))
const hacks = []
const fontMap = {
Interface: 'Root',
Input: 'Input',
Posts: 'Post',
Monospace: 'Root',
}
Object.entries(fontMap).forEach(([font, component]) => {
const family = mergedConfig[`font${font}`]
const variable = font === 'Monospace' ? '--monoFont' : '--font'
if (typeof family === 'string') {
const familyString = GENERIC_FONT_NAMES.has(family)
? family
: `"${family}"`
hacks.push({
component,
directives: {
[variable]: `generic | ${familyString}`,
},
})
}
})
if (mergedConfig.underlay !== 'none') {
const newRule = {
component: 'Underlay',
directives: {},
}
if (mergedConfig.underlay === 'opaque') {
newRule.directives.opacity = 1
newRule.directives.background = '--wallpaper'
}
if (mergedConfig.underlay === 'transparent') {
newRule.directives.opacity = 0
}
hacks.push(newRule)
}
const rulesetArray = [
theme2ruleset,
this.styleDataUsed,
paletteIss,
hacks,
].filter(Boolean)
return applyTheme(
rulesetArray.flat(),
() => this.setThemeApplied(),
() => {
this.themeChangeInProgress = false
},
themeDebug,
)
} catch (e) {
console.error(e)
window.splashError(e)
}
},
},
})
export const normalizeThemeData = (input) => {
let themeData, themeSource
if (input.themeFileVerison === 1) {
// this might not be even used at all, some leftover of unimplemented code in V2 editor
return generatePreset(input).theme
} else if (
Object.hasOwn(input, '_pleroma_theme_version') ||
Object.hasOwn(input, 'source') ||
Object.hasOwn(input, 'theme')
) {
// We got passed a full theme file
themeData = input.theme
themeSource = input.source
} else if (
Object.hasOwn(input, 'themeEngineVersion') ||
Object.hasOwn(input, 'colors')
) {
// We got passed a source/snapshot
themeData = input
themeSource = input
}
// New theme presets don't have 'theme' property, they use 'source'
let out // shout, shout let it all out
if (themeSource?.themeEngineVersion === CURRENT_VERSION) {
// There are some themes in wild that have completely broken source
out = { ...(themeData || {}), ...themeSource }
} else {
out = themeData
}
// generatePreset here basically creates/updates "snapshot",
// while also fixing the 2.2 -> 2.3 colors/shadows/etc
return generatePreset(out).theme
}
diff --git a/src/stores/sync_config.js b/src/stores/sync_config.js
index 87083d8500..9464ffb686 100644
--- a/src/stores/sync_config.js
+++ b/src/stores/sync_config.js
@@ -1,841 +1,844 @@
import sum from 'hash-sum'
import {
merge as _merge,
clamp,
cloneDeep,
findLastIndex,
get,
groupBy,
isEqual,
last,
set,
take,
uniqWith,
unset,
} from 'lodash'
import { defineStore } from 'pinia'
import { v4 as uuidv4 } from 'uuid'
import { toRaw } from 'vue'
import { CURRENT_UPDATE_COUNTER } from 'src/components/update_notification/update_notification.js'
import { useLocalConfigStore } from 'src/stores/local_config.js'
import { useOAuthStore } from 'src/stores/oauth.js'
import { updateProfileJSON } from 'src/api/user.js'
import { storage } from 'src/lib/storage.js'
import {
makeUndefined,
ROOT_CONFIG,
ROOT_CONFIG_DEFINITIONS,
validateSetting,
} from 'src/modules/default_config_state.js'
import { oldDefaultConfigSync } from 'src/modules/old_default_config_state.js'
export const VERSION = 2
export const NEW_USER_DATE = new Date('2026-03-16') // date of writing this, basically
export const COMMAND_TRIM_FLAGS = 1000
export const COMMAND_TRIM_FLAGS_AND_RESET = 1001
export const COMMAND_WIPE_JOURNAL = 1010
export const COMMAND_WIPE_JOURNAL_AND_STORAGE = 1011
export const defaultState = {
// do we need to update data on server?
dirty: false,
// storage of flags - stuff that can only be set and incremented
flagStorage: {
updateCounter: 0, // Counter for most recent update notification seen
reset: 0, // special flag that can be used to force-reset all data, debug purposes only
// special reset codes:
// 1000: trim keys to those known by currently running FE
// 1001: same as above + reset everything to 0
},
prefsStorage: {
_journal: [],
simple: {
muteFilters: {},
...makeUndefined({ ...ROOT_CONFIG }),
},
collections: {
pinnedStatusActions: ['reply', 'retweet', 'favorite', 'emoji'],
pinnedNavItems: ['home', 'dms', 'chats'],
},
},
// raw data
raw: null,
// local cache
cache: null,
}
export const newUserFlags = {
...defaultState.flagStorage,
updateCounter: CURRENT_UPDATE_COUNTER, // new users don't need to see update notification
}
export const _moveItemInArray = (array, value, movement) => {
const oldIndex = array.indexOf(value)
const newIndex = oldIndex + movement
const newArray = [...array]
// remove old
newArray.splice(oldIndex, 1)
// add new
newArray.splice(clamp(newIndex, 0, newArray.length + 1), 0, value)
return newArray
}
const _wrapData = (data, userName) => {
return {
...data,
_user: userName,
_timestamp: Date.now(),
_version: VERSION,
}
}
const _checkValidity = (data) => data._timestamp > 0 && data._version > 0
const _verifyPrefs = (state) => {
state.prefsStorage = state.prefsStorage || {
simple: {},
collections: {},
}
// Simple
Object.entries(defaultState.prefsStorage.simple).forEach(([k, v]) => {
- if (typeof v === 'undefined') return
+ if (v === undefined) return
if (typeof v === 'number' || typeof v === 'boolean') return
if (typeof v === 'object') return
console.warn(
`Preference simple.${k} as invalid type ${typeof v}, reinitializing`,
)
set(state.prefsStorage.simple, k, defaultState.prefsStorage.simple[k])
})
// Collections
Object.entries(defaultState.prefsStorage.collections).forEach(([k, v]) => {
if (Array.isArray(v)) return
console.warn(
`Preference collections.${k} as invalid type ${typeof v}, reinitializing`,
)
set(
state.prefsStorage.collections,
k,
defaultState.prefsStorage.collections[k],
)
})
}
export const _getRecentData = (cache, live, isTest) => {
const result = { recent: null, stale: null, needUpload: false }
const cacheValid = _checkValidity(cache || {})
const liveValid = _checkValidity(live || {})
if (!liveValid && cacheValid) {
result.needUpload = true
console.debug(
'Nothing valid stored on server, assuming cache to be source of truth',
)
result.recent = cache
result.stale = live
} else if (!cacheValid && liveValid) {
console.debug(
'Valid storage on server found, no local cache found, using live as source of truth',
)
result.recent = live
result.stale = cache
} else if (cacheValid && liveValid) {
console.debug('Both sources have valid data, figuring things out...')
if (
live._timestamp === cache._timestamp &&
live._version === cache._version
) {
console.debug(
'Same version/timestamp on both sources, source of truth irrelevant',
)
result.recent = cache
result.stale = live
} else {
console.debug(
'Different timestamp or version, figuring out which one is more recent',
)
if (live._timestamp < cache._timestamp) {
result.recent = cache
result.stale = live
} else {
result.recent = live
result.stale = cache
}
}
} else {
console.debug('Both sources are invalid, start from scratch')
result.needUpload = true
}
const merge = (a, b) => {
return {
_user: a._user ?? b._user,
_version: a._version ?? b._version,
_timestamp: a._timestamp ?? b._timestamp,
needUpload: b.needUpload ?? a.needUpload,
prefsStorage: _merge(cloneDeep(a.prefsStorage), b.prefsStorage),
flagStorage: _merge(a.flagStorage, b.flagStorage),
}
}
result.recent = isTest
? result.recent
: result.recent && merge(defaultState, result.recent)
result.stale = isTest
? result.stale
: result.stale && merge(defaultState, result.stale)
return result
}
export const _getAllFlags = (recent, stale) => {
+ const recentStorage = toRaw(recent?.flagStorage)
+ const staleStorage = toRaw(stale?.flagStorage)
+
return Array.from(
new Set([
- ...Object.keys(toRaw((recent || {}).flagStorage || {})),
- ...Object.keys(toRaw((stale || {}).flagStorage || {})),
+ ...Object.keys(recentStorage || {}),
+ ...Object.keys(staleStorage || {}),
]),
)
}
export const _mergeFlags = (recent, stale, allFlagKeys) => {
if (!stale.flagStorage) return recent.flagStorage
if (!recent.flagStorage) return stale.flagStorage
return Object.fromEntries(
allFlagKeys.map((flag) => {
const recentFlag = recent.flagStorage[flag]
const staleFlag = stale.flagStorage[flag]
// use flag that is of higher value
return [
flag,
Number((recentFlag > staleFlag ? recentFlag : staleFlag) || 0),
]
}),
)
}
export const _mergeJournal = (...journals) => {
// Ignore invalid journal entries
const allJournals = journals
.map((j) => (Array.isArray(j) ? j : []))
.flat()
.filter(
(entry) =>
Object.hasOwn(entry, 'path') &&
Object.hasOwn(entry, 'operation') &&
Object.hasOwn(entry, 'args') &&
Object.hasOwn(entry, 'timestamp'),
)
const grouped = groupBy(allJournals, 'path')
const trimmedGrouped = Object.entries(grouped).map(([path, rawJournal]) => {
const journal = rawJournal
.map((data, index) => ({ data, index }))
.toSorted(({ data: a, index: ai }, { data: b, index: bi }) => {
if (a.timestamp === b.timestamp) {
return ai - bi
} else {
return a.timestamp > b.timestamp ? 1 : -1
}
})
.map((x) => x.data)
if (path.startsWith('collections')) {
const lastRemoveIndex = findLastIndex(
journal,
({ operation }) => operation === 'removeFromCollection',
)
// everything before last remove is unimportant
let remainder
if (lastRemoveIndex > 0) {
remainder = journal.slice(lastRemoveIndex)
} else {
// everything else doesn't need trimming
remainder = journal
}
return uniqWith(remainder, (a, b) => {
if (a.path !== b.path) {
return false
}
if (a.operation !== b.operation) {
return false
}
if (a.operation === 'addToCollection') {
return a.args[0] === b.args[0]
}
return false
})
} else if (path.startsWith('simple')) {
// Only the last record is important
return [last(journal)]
} else {
return journal
}
})
const flat = trimmedGrouped
.flat()
.map((data, index) => ({ data, index }))
.toSorted(({ data: a, index: ai }, { data: b, index: bi }) => {
if (a.timestamp === b.timestamp) {
return ai - bi
} else {
return a.timestamp > b.timestamp ? 1 : -1
}
})
.map((x) => x.data)
return take(flat, 500)
}
export const _mergePrefs = (recent, stale) => {
if (!stale) return recent
if (!recent) return stale
const { _journal: recentJournal, ...recentData } = recent
const { _journal: staleJournal } = stale
/** Journal entry format:
* path: path to entry in prefsStorage
* timestamp: timestamp of the change
* operation: operation type
* arguments: array of arguments, depends on operation type
*
* currently only supported operation type is "set" which just sets the value
* to requested one. Intended only to be used with simple preferences (boolean, number)
* shouldn't be used with collections!
*/
const resultOutput = { ...recentData }
const totalJournal = _mergeJournal(staleJournal, recentJournal)
totalJournal
.filter(({ path, operation, args }) => {
const entry = path.split('.')[1]
if (operation === 'unset') return ROOT_CONFIG[entry] !== undefined
if (operation !== 'set') return true
const definition = path.startsWith('simple.muteFilters')
? { default: {} }
: ROOT_CONFIG_DEFINITIONS[entry]
const finalValue = validateSetting({
path,
value: args[0],
definition,
throwError: false,
defaultState: ROOT_CONFIG,
})
return finalValue !== undefined
})
.forEach(({ path, operation, args }) => {
if (path.startsWith('_')) {
throw new Error(
`journal contains entry to edit internal (starts with _) field '${path}', something is incorrect here, ignoring.`,
)
}
switch (operation) {
case 'set': {
if (path.startsWith('collections')) {
return console.error('Illegal operation "set" on a collection')
}
if (path.split(/\./g).length <= 1) {
return console.error(
`Calling set on depth <= 1 (path: ${path}) is not allowed`,
)
}
set(resultOutput, path, args[0])
break
}
case 'unset':
if (path.startsWith('collections')) {
return console.error('Illegal operation "unset" on a collection')
}
if (path.split(/\./g).length <= 2) {
return console.error(
`Calling unset on depth <= 2 (path: ${path}) is not allowed`,
)
}
unset(resultOutput, path)
break
case 'addToCollection':
if (!path.startsWith('collections')) {
return console.error(
'Illegal operation "addToCollection" on a non-collection',
)
}
set(
resultOutput,
path,
Array.from(new Set(get(resultOutput, path)).add(args[0])),
)
break
case 'removeFromCollection': {
if (!path.startsWith('collections')) {
return console.error(
'Illegal operation "removeFromCollection" on a non-collection',
)
}
const newSet = new Set(get(resultOutput, path))
newSet.delete(args[0])
set(resultOutput, path, Array.from(newSet))
break
}
case 'reorderCollection': {
const [value, movement] = args
set(
resultOutput,
path,
_moveItemInArray(get(resultOutput, path), value, movement),
)
break
}
default:
return console.error(
`Unknown journal operation: '${operation}', did we forget to run reverse migrations beforehand?`,
)
}
})
return { ...resultOutput, _journal: totalJournal }
}
export const _resetFlags = (
totalFlags,
knownKeys = defaultState.flagStorage,
) => {
let result = { ...totalFlags }
const allFlagKeys = Object.keys(totalFlags)
// flag reset functionality
if (
totalFlags.reset >= COMMAND_TRIM_FLAGS &&
totalFlags.reset <= COMMAND_TRIM_FLAGS_AND_RESET
) {
console.debug('Received command to trim the flags')
const knownKeysSet = new Set(Object.keys(knownKeys))
// Trim
result = {}
allFlagKeys.forEach((flag) => {
if (knownKeysSet.has(flag)) {
result[flag] = totalFlags[flag]
}
})
// Reset
if (totalFlags.reset === COMMAND_TRIM_FLAGS_AND_RESET) {
// 1001 - and reset everything to 0
console.debug('Received command to reset the flags')
Object.keys(knownKeys).forEach((flag) => {
result[flag] = 0
})
}
}
result.reset = 0
return result
}
const _resetPrefs = (
totalPrefs,
totalFlags,
knownKeys = defaultState.flagStorage,
) => {
// prefs reset functionality
if (
totalFlags.reset >= COMMAND_WIPE_JOURNAL &&
totalFlags.reset <= COMMAND_WIPE_JOURNAL_AND_STORAGE
) {
console.debug('Received command to reset journals')
this.flagStorage.reset = COMMAND_WIPE_JOURNAL
this.prefsStorage._journal = []
this.cache.prefsStorage._journal = []
this.raw.prefsStorage._journal = []
this.pushSyncConfig()
if (totalFlags.reset === COMMAND_WIPE_JOURNAL_AND_STORAGE) {
console.debug('Received command to reset storage')
return cloneDeep(defaultState)
}
}
return totalPrefs
}
const _doMigrations = async (data, setPreference) => {
if (data._version < VERSION) {
console.debug(
'Data has older version, seeing if there any migrations that can be applied',
)
}
if (data._version > VERSION) {
console.debug(
'Data has newer version, seeing if there any reverse migrations that can be applied',
)
// no reverse migrations right now but we leave a possibility of loading a hotpatch if need be
if (window._PLEROMA_HOTPATCH) {
if (window._PLEROMA_HOTPATCH.reverseMigrations) {
console.debug('Found hotpatch migration, applying')
return window._PLEROMA_HOTPATCH.reverseMigrations.call(
{},
'syncConfigStore',
{ from: data._version, to: VERSION },
data,
)
}
}
}
return data
}
export const useSyncConfigStore = defineStore('sync_config', {
state() {
return cloneDeep(defaultState)
},
actions: {
setFlag({ flag, value }) {
this.flagStorage[flag] = value
this.dirty = true
},
setSimplePrefAndSave({ path, value }) {
this.setPreference({ path: `simple.${path}`, value })
this.pushSyncConfig()
},
unsetSimplePrefAndSave({ path }) {
this.unsetPreference({ path: `simple.${path}` })
this.pushSyncConfig()
},
setPreference({ path, value }) {
if (path.startsWith('_')) {
throw new Error(
`Tried to edit internal (starts with _) field '${path}', ignoring.`,
)
}
if (path.startsWith('collections')) {
throw new Error(
`Invalid operation 'set' for collection field '${path}', ignoring.`,
)
}
if (path.split(/\./g).length <= 1) {
throw new Error(
`Calling set on depth <= 1 (path: ${path}) is not allowed`,
)
}
if (path.split(/\./g).length > 3) {
throw new Error(
`Calling set on depth > 3 (path: ${path}) is not allowed`,
)
}
if (path.startsWith('collections.')) return value
const definition = path.startsWith('simple.muteFilters')
? { default: {} }
: ROOT_CONFIG_DEFINITIONS[path.split('.')[1]]
const finalValue = validateSetting({
path,
value,
definition,
throwError: false,
defaultState: ROOT_CONFIG,
})
if (finalValue !== undefined) set(this.prefsStorage, path, finalValue)
this.prefsStorage._journal = [
...this.prefsStorage._journal,
{ operation: 'set', path, args: [value], timestamp: Date.now() },
]
this.dirty = true
},
unsetPreference({ path, value }) {
if (path.startsWith('_')) {
throw new Error(
`Tried to edit internal (starts with _) field '${path}', ignoring.`,
)
}
if (path.startsWith('collections')) {
throw new Error(
`Invalid operation 'unset' for collection field '${path}', ignoring.`,
)
}
if (path.split(/\./g).length <= 2) {
throw new Error(
`Calling unset on depth <= 2 (path: ${path}) is not allowed`,
)
}
if (path.split(/\./g).length > 3) {
throw new Error(
`Calling unset on depth > 3 (path: ${path}) is not allowed`,
)
}
unset(this.prefsStorage, path)
this.prefsStorage._journal = [
...this.prefsStorage._journal,
{ operation: 'unset', path, args: [], timestamp: Date.now() },
]
this.dirty = true
},
addCollectionPreference({ path, value }) {
if (path.startsWith('_')) {
throw new Error(
`tried to edit internal (starts with _) field '${path}'`,
)
}
if (path.startsWith('collections')) {
const collection = new Set(get(this.prefsStorage, path))
collection.add(value)
set(this.prefsStorage, path, [...collection])
}
this.prefsStorage._journal = [
...this.prefsStorage._journal,
{
operation: 'addToCollection',
path,
args: [value],
timestamp: Date.now(),
},
]
this.dirty = true
},
removeCollectionPreference({ path, value }) {
if (path.startsWith('_')) {
throw new Error(
`tried to edit internal (starts with _) field '${path}', ignoring.`,
)
}
const { _key } = value
if (path.startsWith('collection')) {
const collection = new Set(get(this.prefsStorage, path))
collection.delete(value)
set(this.prefsStorage, path, [...collection])
this.prefsStorage._journal = [
...this.prefsStorage._journal,
{
operation: 'removeFromCollection',
path,
args: [value],
timestamp: Date.now(),
},
]
this.dirty = true
}
},
reorderCollectionPreference({ path, value, movement }) {
if (path.startsWith('_')) {
throw new Error(
`tried to edit internal (starts with _) field '${path}', ignoring.`,
)
}
const collection = get(this.prefsStorage, path)
const newCollection = _moveItemInArray(collection, value, movement)
set(this.prefsStorage, path, newCollection)
this.prefsStorage._journal = [
...this.prefsStorage._journal,
{
operation: 'arrangeCollection',
path,
args: [value],
timestamp: Date.now(),
},
]
this.dirty = true
},
updateCache({ username }) {
this.prefsStorage._journal = _mergeJournal(this.prefsStorage._journal)
this.cache = _wrapData(
{
flagStorage: toRaw(this.flagStorage),
prefsStorage: toRaw(this.prefsStorage),
},
username,
)
},
clearSyncConfig() {
const blankState = { ...cloneDeep(defaultState) }
Object.keys(this).forEach((k) => {
this[k] = blankState[k]
})
this.flagStorage.reset = COMMAND_WIPE_JOURNAL_AND_STORAGE
},
async initSyncConfig(userData) {
const live = userData.storage
this.raw = live
let cache = this.cache
if (cache?._user !== userData.fqn) {
console.warn(
'Cache belongs to another user! reinitializing local cache!',
)
cache = null
}
let { recent, stale, needUpload } = _getRecentData(cache, live)
const userNew = userData.created_at > NEW_USER_DATE
const flagsTemplate = userNew ? newUserFlags : defaultState.flagStorage
let dirty = false
if (recent === null) {
console.debug(
`Data is empty, initializing for ${userNew ? 'new' : 'existing'} user`,
)
recent = _wrapData({
flagStorage: { ...flagsTemplate },
prefsStorage: { ...defaultState.prefsStorage },
})
}
recent = recent && (await _doMigrations(recent, this.setPreference))
stale = stale && (await _doMigrations(stale, this.setPreference))
// Various migrations
console.debug('Migrating from old config')
const vuexState = (await storage.getItem('vuex-lz')) ?? {}
const config = vuexState.config ?? {}
const migratedEntries = new Set(config._syncMigration ?? [])
console.debug(
`Already migrated Values: ${[...migratedEntries].join() || '[none]'}`,
)
Object.entries(oldDefaultConfigSync).forEach(([key, value]) => {
const oldValue = config[key]
const defaultValue = value
const present = oldValue !== undefined
const migrated = migratedEntries.has(key)
const different = !isEqual(oldValue, defaultValue)
if (present && !migrated && different) {
console.debug(`Migrating config ${key}: ${oldValue}`)
if (key === 'theme3hacks') {
useLocalConfigStore().set({
path: 'fontInterface',
value: oldValue.fonts.interface,
})
useLocalConfigStore().set({
path: 'fontInput',
value: oldValue.fonts.input,
})
useLocalConfigStore().set({
path: 'fontPost',
value: oldValue.fonts.post,
})
useLocalConfigStore().set({
path: 'fontMonospace',
value: oldValue.fonts.monospace,
})
useSyncConfigStore().setSimplePrefAndSave({
path: 'underlay',
value: oldValue.underlay,
})
} else if (key == 'muteWords') {
oldValue.forEach((word, order) => {
const uniqueId = uuidv4()
useSyncConfigStore().setPreference({
path: 'simple.muteFilters.' + uniqueId,
value: {
type: 'word',
value: word,
name: word,
enabled: true,
expires: null,
hide: false,
order,
},
})
})
} else {
this.setPreference({ path: `simple.${key}`, value: oldValue })
}
migratedEntries.add(key)
needUpload = true
}
})
config._syncMigration = [...migratedEntries]
vuexState.config = config
storage.setItem('vuex-lz', vuexState)
if (!needUpload && recent && stale) {
console.debug('Checking if data needs merging...')
// discarding timestamps and versions
const { _timestamp: _0, _version: _1, ...recentData } = recent
const { _timestamp: _2, _version: _3, ...staleData } = stale
dirty = sum(recentData) !== sum(staleData)
console.debug(`Data ${dirty ? 'needs' : "doesn't need"} merging`)
}
const allFlagKeys = _getAllFlags(recent, stale)
let totalFlags
let totalPrefs
if (dirty) {
// Merge the flags
console.debug('Merging the data...')
totalFlags = _mergeFlags(recent, stale, allFlagKeys)
_verifyPrefs(recent)
_verifyPrefs(stale)
totalPrefs = _mergePrefs(recent.prefsStorage, stale.prefsStorage)
} else {
totalFlags = recent.flagStorage
totalPrefs = recent.prefsStorage
}
totalPrefs = _resetPrefs(totalPrefs, totalFlags)
totalFlags = _resetFlags(totalFlags)
recent.flagStorage = { ...flagsTemplate, ...totalFlags }
recent.prefsStorage = { ...defaultState.prefsStorage, ...totalPrefs }
this.dirty = dirty || needUpload
this.cache = recent
// set local timestamp to smaller one if we don't have any changes
if (stale && recent && !this.dirty) {
this.cache._timestamp = Math.min(stale._timestamp, recent._timestamp)
}
this.flagStorage = this.cache.flagStorage
this.prefsStorage = this.cache.prefsStorage
this.pushSyncConfig()
},
pushSyncConfig({ force = false } = {}) {
const needPush = this.dirty || force
if (!needPush) return
this.updateCache({ username: window.vuex.state.users.currentUser.fqn })
const params = { pleroma_settings_store: { 'pleroma-fe': this.cache } }
updateProfileJSON({
params,
credentials: useOAuthStore().token,
})
},
},
persist: {
afterLoad(state) {
console.debug('Validating persisted state of SyncConfig')
const newState = { ...state }
newState.prefsStorage = newState.prefsStorage || {}
const newEntries = Object.entries(ROOT_CONFIG).map(([path, value]) => {
const definition = ROOT_CONFIG_DEFINITIONS[path]
const finalValue = validateSetting({
path,
value: newState.prefsStorage.simple?.[path],
definition,
throwError: false,
validateObjects: false,
defaultState: ROOT_CONFIG,
})
return finalValue === undefined
? [path, definition.default]
: [path, finalValue]
})
newState.prefsStorage.simple = Object.fromEntries(
- newEntries.filter((_) => _),
+ newEntries.filter(Boolean),
)
return newState
},
},
})
diff --git a/src/stores/user_highlight.js b/src/stores/user_highlight.js
index 0db37703d0..3cd3bfe7dd 100644
--- a/src/stores/user_highlight.js
+++ b/src/stores/user_highlight.js
@@ -1,349 +1,349 @@
import {
merge as _merge,
clone,
cloneDeep,
groupBy,
isEqual,
last,
} from 'lodash'
import { defineStore } from 'pinia'
import { toRaw } from 'vue'
import { useOAuthStore } from 'src/stores/oauth.js'
import { updateProfileJSON } from 'src/api/user.js'
import { storage } from 'src/lib/storage.js'
export const NEW_USER_DATE = new Date('2022-08-04') // date of writing this, basically
export const defaultState = {
// do we need to update data on server?
dirty: false,
highlight: {
_journal: [],
},
// raw data
raw: null,
// local cache
cache: null,
}
const _wrapData = (data, userName) => {
return {
...data,
_user: userName,
_timestamp: Date.now(),
}
}
const _checkValidity = (data) => data._timestamp > 0
const _verifyHighlights = (state) => {
state.highlight = state.highlight || {}
// Simple
Object.entries(defaultState.highlight).forEach(([k, v]) => {
- if (typeof v === 'undefined') return
+ if (v === undefined) return
if (typeof v === 'object') return
console.warn(`User highlight ${k} is invalid type ${typeof v}, unsetting`)
delete state.highlight[k]
})
}
export const _getRecentData = (cache, live, isTest) => {
const result = { recent: null, stale: null, needUpload: false }
const cacheValid = _checkValidity(cache || {})
const liveValid = _checkValidity(live || {})
if (!liveValid && cacheValid) {
result.needUpload = true
console.debug(
'[HIGHLIGHT] Nothing valid stored on server, assuming cache to be source of truth',
)
result.recent = cache
result.stale = live
} else if (!cacheValid && liveValid) {
console.debug(
'[HIGHLIGHT] Valid storage on server found, no local cache found, using live as source of truth',
)
result.recent = live
result.stale = cache
} else if (cacheValid && liveValid) {
console.debug(
'[HIGHLIGHT] Both sources have valid data, figuring things out...',
)
if (live._timestamp === cache._timestamp) {
console.debug(
'[HIGHLIGHT] Same timestamp on both sources, source of truth irrelevant',
)
result.recent = cache
result.stale = live
} else {
console.debug(
'[HIGHLIGHT] Different timestamp, figuring out which one is more recent',
)
if (live._timestamp < cache._timestamp) {
result.recent = cache
result.stale = live
} else {
result.recent = live
result.stale = cache
}
}
} else {
console.debug('[HIGHLIGHT] Both sources are invalid, start from scratch')
result.needUpload = true
}
const merge = (a, b) => {
return {
_user: a._user ?? b._user,
_timestamp: a._timestamp ?? b._timestamp,
needUpload: b.needUpload ?? a.needUpload,
highlight: _merge(cloneDeep(a.highlight), b.highlight),
}
}
result.recent = isTest
? result.recent
: result.recent && merge(defaultState, result.recent)
result.stale = isTest
? result.stale
: result.stale && merge(defaultState, result.stale)
return result
}
const _mergeJournal = (...journals) => {
// Ignore invalid journal entries
const allJournals = journals
.map((j) => (Array.isArray(j) ? j : []))
.flat()
.filter(
(entry) =>
Object.hasOwn(entry, 'user') &&
Object.hasOwn(entry, 'operation') &&
Object.hasOwn(entry, 'args') &&
Object.hasOwn(entry, 'timestamp'),
)
const grouped = groupBy(allJournals, 'user')
const trimmedGrouped = Object.entries(grouped).map(([user, journal]) => {
// side effect
journal.sort((a, b) => (a.timestamp > b.timestamp ? 1 : -1))
return [last(journal)]
})
return trimmedGrouped
.flat()
.sort((a, b) => (a.timestamp > b.timestamp ? 1 : -1))
}
export const _mergeHighlights = (recent, stale) => {
if (!stale) return recent
if (!recent) return stale
const { _journal: recentJournal, ...recentHighlight } = recent
const { _journal: staleJournal } = stale
/** Journal entry format:
* user: user to entry in highlight storage
* timestamp: timestamp of the change
* operation: operation type
* arguments: array of arguments, depends on operation type
*
* currently only supported operation type is "set" which just sets the value
* to requested one. Intended only to be used with simple preferences (boolean, number)
*/
const resultHighlight = { ...recentHighlight }
const Journal = _mergeJournal(staleJournal, recentJournal)
Journal.forEach(({ user, operation, args }) => {
if (user.startsWith('_')) {
throw new Error(
`journal contains entry to edit internal (starts with _) field '${user}', something is incorrect here, ignoring.`,
)
}
switch (operation) {
case 'set':
resultHighlight[user] = args[0]
break
case 'unset':
delete resultHighlight[user]
break
default:
return console.error(`Unknown journal operation: '${operation}'`)
}
})
return { ...resultHighlight, _journal: Journal }
}
export const useUserHighlightStore = defineStore('user_highlight', {
state() {
return cloneDeep(defaultState)
},
actions: {
setAndSave({ user, value }) {
this.set({ user, value })
this.pushHighlight()
},
unsetAndSave({ user }) {
this.unset({ user })
this.pushHighlight()
},
get(user) {
const present = this.highlight[user] || {}
return {
user,
type: 'disabled',
color: '#FFFFFF',
...present,
}
},
set({ user, value }) {
if (user.startsWith('_')) {
throw new Error(
`Tried to edit internal (starts with _) field '${user}', ignoring.`,
)
}
const oldValue = this.highlight[user]
const newValue = {
user,
...oldValue,
...value,
}
this.highlight[user] = newValue
this.highlight._journal = [
...this.highlight._journal,
{ operation: 'set', user, args: [newValue], timestamp: Date.now() },
]
this.dirty = true
},
unset({ user, value }) {
if (user.startsWith('_')) {
throw new Error(
`Tried to edit internal (starts with _) field '${user}', ignoring.`,
)
}
delete this.highlight[user]
this.highlight._journal = [
...this.highlight._journal,
{ operation: 'unset', user, args: [], timestamp: Date.now() },
]
this.dirty = true
},
updateCache({ username }) {
this.highlight._journal = _mergeJournal(this.highlight._journal)
this.cache = _wrapData(
{
highlight: toRaw(this.highlight),
},
username,
)
},
clearSyncConfig() {
const blankState = { ...cloneDeep(defaultState) }
Object.keys(this).forEach((k) => {
this[k] = blankState[k]
})
},
clearJournals() {
this.highlight._journal = []
this.cache.highlight._journal = []
this.raw.highlight._journal = []
this.pushSyncConfig()
},
async initUserHighlight(userData) {
const live = userData.user_highlight
this.raw = live
let cache = this.cache
if (cache?._user !== userData.fqn) {
console.warn(
'Cache belongs to another user! reinitializing local cache!',
)
cache = null
}
let { recent, stale, needUpload } = _getRecentData(cache, live)
const userNew = userData.created_at > NEW_USER_DATE
let dirty = false
const vuexState = (await storage.getItem('vuex-lz')) ?? {}
const config = vuexState.config ?? {}
const highlight = config.highlight ?? {}
Object.entries(highlight).forEach(([user, value]) => {
if ((highlight[user]._migrated || 0) < 1) {
dirty = true
needUpload = true
this.set({ user, value: clone(value) })
highlight[user]._migrated = 1
console.debug(
`[HIGHLIGHT] Migrating user ${user}: ${JSON.stringify(value)}`,
)
}
})
storage.setItem('vuex-lz', {
...vuexState,
config: { ...config, highlight },
})
if (recent === null) {
console.debug(
`[HIGHLIGHT] Data is empty, initializing for ${userNew ? 'new' : 'existing'} user`,
)
recent = _wrapData({
highlight: { ...defaultState.highlight },
})
}
if (!needUpload && recent && stale) {
console.debug('[HIGHLIGHT] Checking if data needs merging...')
// discarding timestamps
const { _timestamp: _0, ...recentData } = recent
const { _timestamp: _2, ...staleData } = stale
dirty = !isEqual(recentData, staleData)
console.debug(
`[HIGHLIGHT] Data ${dirty ? 'needs' : "doesn't need"} merging`,
)
}
let highlights
if (dirty) {
console.debug('[HIGHLIGHT] Merging the data...')
_verifyHighlights(recent)
_verifyHighlights(stale)
highlights = _mergeHighlights(recent.highlight, stale.highlight)
} else {
highlights = recent.highlight
}
recent.highlight = { ...defaultState.highlight, ...highlights }
this.dirty = dirty || needUpload
this.cache = recent
// set local timestamp to smaller one if we don't have any changes
if (stale && recent && !this.dirty) {
this.cache._timestamp = Math.min(stale._timestamp, recent._timestamp)
}
this.highlight = this.cache.highlight
},
pushHighlight({ force = false } = {}) {
const needPush = this.dirty || force
if (!needPush) return
this.updateCache({ username: window.vuex.state.users.currentUser.fqn })
const params = {
pleroma_settings_store: { user_highlight: this.cache },
}
updateProfileJSON({
params,
credentials: useOAuthStore().token,
}).then(({ data: user }) => {
this.initUserHighlight(user)
this.dirty = false
})
},
},
persist: {
afterLoad(state) {
return state
},
},
})
diff --git a/src/sw.js b/src/sw.js
index b7629b5f29..908e865673 100644
--- a/src/sw.js
+++ b/src/sw.js
@@ -1,299 +1,303 @@
/* eslint-env serviceworker */
// biome-ignore: side effect import of assets list
import 'virtual:pleroma-fe/service_worker_env'
import { createI18n } from 'vue-i18n'
import { storage } from 'src/lib/storage.js'
import { INSTANCE_DEFAULT_CONFIG } from 'src/modules/default_config_state.js'
import { parseNotification } from 'src/services/entity_normalizer/entity_normalizer.service.js'
import { prepareNotificationObject } from 'src/services/notification_utils/notification_utils.js'
import { cacheKey, emojiCacheKey, shouldCache } from 'src/services/sw/sw.js'
// Collects all messages for service workers
// Needed because service workers cannot use dynamic imports
// See /build/sw_plugin.js for more information
import messages from 'virtual:pleroma-fe/service_worker_messages'
const i18n = createI18n({
// By default, use the browser locale, we will update it if neccessary
locale: 'en',
fallbackLocale: 'en',
messages,
})
const state = {
lastFocused: null,
notificationIds: new Set(),
allowedNotificationTypes: null,
}
function getWindowClients() {
return clients
.matchAll({ includeUncontrolled: true })
.then((clientList) => clientList.filter(({ type }) => type === 'window'))
}
const setSettings = async () => {
const piniaState = await storage.getItem('pinia-local-sync_config')
const locale = piniaState.prefsStorage.simple.interfaceLanguage || 'en'
i18n.locale = locale
const notificationsNativeArray = Object.entries(
piniaState.prefsStorage.simple.notificationNative ||
INSTANCE_DEFAULT_CONFIG.notificationNative,
)
state.webPushAlwaysShowNotifications =
piniaState.prefsStorage.simple.webPushAlwaysShowNotifications
state.allowedNotificationTypes = new Set(
notificationsNativeArray
.filter(([, v]) => v)
.map(([k]) => {
switch (k) {
case 'mentions':
return 'mention'
case 'statuses':
return 'status'
case 'likes':
return 'like'
case 'repeats':
return 'repeat'
case 'emojiReactions':
return 'pleroma:emoji_reaction'
case 'reports':
return 'pleroma:report'
case 'followRequest':
return 'follow_request'
case 'follows':
return 'follow'
case 'polls':
return 'poll'
default:
return k
}
}),
)
}
const showPushNotification = async (event) => {
const activeClients = await getWindowClients()
await setSettings()
// Only show push notifications if all tabs/windows are closed
if (state.webPushAlwaysShowNotifications || activeClients.length === 0) {
const data = event.data.json()
const url = `${self.registration.scope}api/v1/notifications/${data.notification_id}`
const notification = await fetch(url, {
headers: { Authorization: 'Bearer ' + data.access_token },
})
const notificationJson = await notification.json()
const parsedNotification = parseNotification(notificationJson)
const res = prepareNotificationObject(parsedNotification, i18n)
if (
state.webPushAlwaysShowNotifications ||
state.allowedNotificationTypes.has(parsedNotification.type)
) {
return self.registration.showNotification(res.title, res)
}
}
return Promise.resolve()
}
const cacheFiles = self.serviceWorkerOption.assets
const isEmoji = (req) => {
if (req.method !== 'GET') {
return false
}
const url = new URL(req.url)
return url.pathname.startsWith('/emoji/')
}
const isNotMedia = (req) => {
if (req.method !== 'GET') {
return false
}
const url = new URL(req.url)
return !url.pathname.startsWith('/media/')
}
const isAsset = (req) => {
const url = new URL(req.url)
return cacheFiles.includes(url.pathname)
}
const isSuccessful = (resp) => {
if (!resp.ok) {
return false
}
if (new URL(resp.url).pathname === '/index.html') {
// For index.html itself, there is no fallback possible.
return true
}
const type = resp.headers.get('Content-Type')
// Backend will revert to index.html if the file does not exist, so text/html for emojis and assets is a failure
return type && !type.includes('text/html')
}
self.addEventListener('install', async (event) => {
if (shouldCache) {
event.waitUntil(
(async () => {
// Do not preload i18n and emoji annotations to speed up loading
const shouldPreload = (route) => {
return (
!route.startsWith('/static/js/i18n/') &&
!route.startsWith('/static/js/emoji-annotations/')
)
}
const cache = await caches.open(cacheKey)
await Promise.allSettled(
cacheFiles.filter(shouldPreload).map(async (route) => {
// https://developer.mozilla.org/en-US/docs/Web/API/Cache/add
// originally we used addAll() but it will raise a problem in one edge case:
// when the file for the route is not found, backend will return index.html with code 200
// but it's wrong, and it's cached, so we end up with a bad cache.
// this can happen when you refresh when you are in the process of upgrading
// the frontend.
const resp = await fetch(route)
if (isSuccessful(resp)) {
await cache.put(route, resp)
}
}),
)
})(),
)
}
})
self.addEventListener('activate', async (event) => {
if (shouldCache) {
event.waitUntil(
(async () => {
const cache = await caches.open(cacheKey)
const keys = await cache.keys()
await Promise.all(
keys
.filter((request) => {
const url = new URL(request.url)
const shouldKeep = cacheFiles.includes(url.pathname)
return !shouldKeep
})
.map((k) => cache.delete(k)),
)
})(),
)
}
})
self.addEventListener('push', async (event) => {
if (event.data) {
// Supposedly, we HAVE to return a promise inside waitUntil otherwise it will
// show (extra) notification that website is updated in background
event.waitUntil(showPushNotification(event))
}
})
self.addEventListener('message', async (event) => {
await setSettings()
const { type, content } = event.data
+ if (self.location.origin !== event.origin) {
+ console.error('SW Message with strange origin received', event)
+ return
+ }
if (type === 'desktopNotification') {
const { title, ...rest } = content
const { tag, type } = rest
if (state.notificationIds.has(tag)) return
state.notificationIds.add(tag)
setTimeout(() => state.notificationIds.delete(tag), 10000)
if (state.allowedNotificationTypes.has(type)) {
self.registration.showNotification(title, rest)
}
}
if (type === 'desktopNotificationClose') {
const { id, all } = content
const search = all ? null : { tag: id }
const notifications = await self.registration.getNotifications(search)
notifications.forEach((n) => n.close())
}
if (type === 'updateFocus') {
state.lastFocused = event.source.id
const notifications = await self.registration.getNotifications()
notifications.forEach((n) => n.close())
}
})
self.addEventListener('notificationclick', (event) => {
event.notification.close()
event.waitUntil(
getWindowClients().then((list) => {
for (let i = 0; i < list.length; i++) {
const client = list[i]
client.postMessage({
type: 'notificationClicked',
id: event.notification.tag,
})
}
for (let i = 0; i < list.length; i++) {
const client = list[i]
if (state.lastFocused === null || client.id === state.lastFocused) {
if ('focus' in client) return client.focus()
}
}
if (clients.openWindow) return clients.openWindow('/')
}),
)
})
self.addEventListener('fetch', (event) => {
// Do not mess up with remote things
const isSameOrigin =
new URL(event.request.url).origin === self.location.origin
if (
shouldCache &&
event.request.method === 'GET' &&
isSameOrigin &&
isNotMedia(event.request)
) {
// this is a bit spammy
// console.debug('[Service worker] fetch:', event.request.url)
event.respondWith(
(async () => {
const r = await caches.match(event.request)
const isEmojiReq = isEmoji(event.request)
if (r && isSuccessful(r)) {
console.debug('[Service worker] already cached:', event.request.url)
return r
}
try {
const response = await fetch(event.request)
if (
response.ok &&
isSuccessful(response) &&
(isEmojiReq || isAsset(event.request))
) {
console.debug(
`[Service worker] caching ${isEmojiReq ? 'emoji' : 'asset'}:`,
event.request.url,
)
const cache = await caches.open(
isEmojiReq ? emojiCacheKey : cacheKey,
)
await cache.put(event.request.clone(), response.clone())
}
return response
} catch (e) {
console.error('[Service worker] error when caching emoji:', e)
throw e
}
})(),
)
}
})
diff --git a/test/e2e/runner.js b/test/e2e/runner.js
index 33fcc322cd..06cae7813a 100644
--- a/test/e2e/runner.js
+++ b/test/e2e/runner.js
@@ -1,33 +1,33 @@
// 1. start the dev server using production config
process.env.NODE_ENV = 'testing'
const server = require('../../build/dev-server.js')
// 2. run the nightwatch test suite against it
// to run in additional browsers:
// 1. add an entry in test/e2e/nightwatch.conf.json under "test_settings"
// 2. add it to the --env flag below
// or override the environment flag, for example: `npm run e2e -- --env chrome,firefox`
// For more information on Nightwatch's config file, see
// http://nightwatchjs.org/guide#settings-file
let opts = process.argv.slice(2)
-if (opts.indexOf('--config') === -1) {
+if (!opts.includes('--config')) {
opts = opts.concat(['--config', 'test/e2e/nightwatch.conf.js'])
}
-if (opts.indexOf('--env') === -1) {
+if (!opts.includes('--env')) {
opts = opts.concat(['--env', 'chrome'])
}
const spawn = require('cross-spawn')
const runner = spawn('./node_modules/.bin/nightwatch', opts, {
stdio: 'inherit',
})
runner.on('exit', function (code) {
server.close()
process.exit(code)
})
runner.on('error', function (err) {
server.close()
throw err
})
diff --git a/test/unit/specs/components/chat_view.spec.js b/test/unit/specs/components/chat_view.spec.js
index c308e4fbc3..d03809e387 100644
--- a/test/unit/specs/components/chat_view.spec.js
+++ b/test/unit/specs/components/chat_view.spec.js
@@ -1,137 +1,137 @@
import { createTestingPinia } from '@pinia/testing'
import { shallowMount } from '@vue/test-utils'
import { setActivePinia } from 'pinia'
import ChatView from 'src/components/chat_view/chat_view.vue'
const message1 = {
id: '1',
chat_id: 2,
idempotency_key: '1',
created_at: new Date('2020-06-22T18:45:53.000Z'),
}
const message2 = {
id: '2',
chat_id: 2,
idempotency_key: '2',
account_id: '9vmRb29zLQReckr5ay',
created_at: new Date('2020-06-22T18:45:56.000Z'),
}
const message3 = {
id: '3',
chat_id: 2,
idempotency_key: '3',
account_id: '9vmRb29zLQReckr5ay',
created_at: new Date('2020-07-22T18:45:59.000Z'),
}
const global = {
mocks: {
$store: {
state: {
api: {},
users: {},
statuses: {
allStatusesObject: {},
},
},
},
$route: {
params: {
recipient_id: 2,
},
},
$router: {
push: () => {
/* noop */
},
},
},
stubs: {
FAIcon: true,
},
}
describe('ChatView methods', () => {
let component
beforeEach(() => {
setActivePinia(createTestingPinia())
component = shallowMount(ChatView, { global, props: { testMode: true } })
component.vm.chat = { id: 2 }
})
describe('addMessages', () => {
it("Doesn't add duplicates", () => {
component.vm.addMessages({ messages: [message1] })
component.vm.addMessages({ messages: [message1] })
- expect(component.vm.messages.length).to.eql(1)
+ expect(component.vm.messages).to.have.length(1)
component.vm.addMessages({ messages: [message2] })
- expect(component.vm.messages.length).to.eql(2)
+ expect(component.vm.messages).to.have.length(2)
})
it('Updates minId and lastMessage and newMessageCount', async () => {
component.vm.addMessages({ messages: [message1] })
expect(component.vm.maxId).to.eql(message1.id)
expect(component.vm.minId).to.eql(message1.id)
expect(component.vm.newMessageCount).to.eql(1)
component.vm.addMessages({ messages: [message2] })
expect(component.vm.maxId).to.eql(message2.id)
expect(component.vm.minId).to.eql(message1.id)
expect(component.vm.newMessageCount).to.eql(2)
await component.vm.readChat()
expect(component.vm.newMessageCount).to.eql(0)
expect(component.vm.lastReadMessageId).to.eql(message2.id)
// Add message with higher id
component.vm.addMessages({ messages: [message3] })
expect(component.vm.newMessageCount).to.eql(1)
})
})
describe('deleteChatMessage', () => {
it('Updates minId and lastMessage', () => {
component.vm.addMessages({ messages: [message1] })
component.vm.addMessages({ messages: [message2] })
component.vm.addMessages({ messages: [message3] })
expect(component.vm.maxId).to.eql(message3.id)
expect(component.vm.minId).to.eql(message1.id)
component.vm.deleteChatMessage({ messageId: message3.id })
expect(component.vm.maxId).to.eql(message2.id)
expect(component.vm.minId).to.eql(message1.id)
component.vm.deleteChatMessage({ messageId: message1.id })
expect(component.vm.maxId).to.eql(message2.id)
expect(component.vm.minId).to.eql(message2.id)
})
})
describe('cullOlder', () => {
it('keeps 50 newest messages and messagesIndex matches', () => {
for (let i = 100; i > 0; i--) {
// Use decimal values with toFixed to hack together constant length predictable strings
component.vm.addMessages({
messages: [
{
...message1,
id: 'a' + (i / 1000).toFixed(3),
idempotency_key: i,
},
],
})
}
component.vm.cullOlder()
- expect(component.vm.messages.length).to.eql(50)
+ expect(component.vm.messages).to.have.length(50)
expect(component.vm.messages[0].id).to.eql('a0.051')
expect(component.vm.minId).to.eql('a0.051')
expect(component.vm.messages[49].id).to.eql('a0.100')
- expect(Object.keys(component.vm.messagesIndex).length).to.eql(50)
+ expect(Object.keys(component.vm.messagesIndex)).to.have.length(50)
})
})
})
diff --git a/test/unit/specs/components/post_status_form.spec.js b/test/unit/specs/components/post_status_form.spec.js
index 03c0ec8b91..f89102504d 100644
--- a/test/unit/specs/components/post_status_form.spec.js
+++ b/test/unit/specs/components/post_status_form.spec.js
@@ -1,322 +1,322 @@
import { mount } from '@vue/test-utils'
import { vi } from 'vitest'
import PostStatusForm from 'src/components/post_status_form/post_status_form.vue'
import { mountOpts } from '../../../fixtures/setup_test'
import { useInstanceCapabilitiesStore } from 'src/stores/instance_capabilities.js'
import { useMergedConfigStore } from 'src/stores/merged_config.js'
const currentUser = {
id: 'current-user',
default_scope: 'public',
locked: false,
}
const repliedUser = {
id: 'replied-user',
screen_name: 'replied',
}
const repliedStatus = {
id: 'status-1',
visibility: 'public',
user: repliedUser,
}
const repliedStatus2 = {
id: 'status-2',
visibility: 'private',
summary: 'subject',
user: repliedUser,
}
const replyMountOpts = (props) =>
mountOpts({
props,
afterStore(store) {
store.state.users.currentUser = currentUser
store.state.statuses.allStatusesObject = {
[repliedStatus.id]: repliedStatus,
}
},
})
describe('PostStatusForm', () => {
beforeEach(() => {
vi.useFakeTimers()
})
it('Clean empty initial state', () => {
const wrapper = mount(PostStatusForm, replyMountOpts())
expect(wrapper.vm.statusType).to.equal('new')
expect(wrapper.vm.newStatus.spoilerText).to.eql('')
expect(wrapper.vm.newStatus.mentions).to.eql('')
expect(wrapper.vm.newStatus.status).to.eql('')
})
it('Reset cleans form to pristine state equal to state form was when created', () => {
const wrapper = mount(PostStatusForm, replyMountOpts())
const initial = { ...wrapper.vm.newStatus }
wrapper.vm.clearStatus()
expect(wrapper.vm.newStatus).to.eql(initial)
})
it('Initializes a reply form', () => {
const wrapper = mount(
PostStatusForm,
replyMountOpts({
repliedStatus: repliedStatus,
}),
)
useInstanceCapabilitiesStore().quotingAvailable = true
expect(wrapper.vm.statusType).to.equal('reply')
expect(wrapper.vm.isReply).to.equal(true)
expect(wrapper.vm.refId).to.equal('status-1')
expect(wrapper.vm.quotable).to.equal(true)
expect(wrapper.vm.inReplyToStatusId).to.equal('status-1')
- expect(wrapper.vm.newStatus.quote).to.eql(null)
- expect(wrapper.vm.newStatus.poll).to.eql(null)
+ expect(wrapper.vm.newStatus.quote).to.be.null
+ expect(wrapper.vm.newStatus.poll).to.be.null
expect(wrapper.vm.newStatus.spoilerText).to.eql('')
expect(wrapper.vm.newStatus.mentions).to.eql('@replied')
expect(wrapper.vm.newStatus.status).to.eql('@replied ')
expect(wrapper.vm.newStatus.visibility).to.eql('public')
})
it('Copies scope and subject line, disables quoting for locked posts', () => {
const wrapper = mount(
PostStatusForm,
replyMountOpts({
repliedStatus: repliedStatus2,
}),
)
useInstanceCapabilitiesStore().quotingAvailable = true
expect(wrapper.vm.statusType).to.equal('reply')
expect(wrapper.vm.isReply).to.equal(true)
expect(wrapper.vm.quotable).to.equal(false)
- expect(wrapper.vm.newStatus.quote).to.eql(null)
- expect(wrapper.vm.newStatus.poll).to.eql(null)
+ expect(wrapper.vm.newStatus.quote).to.be.null
+ expect(wrapper.vm.newStatus.poll).to.be.null
expect(wrapper.vm.newStatus.spoilerText).to.eql('re: subject')
expect(wrapper.vm.newStatus.mentions).to.eql('@replied')
expect(wrapper.vm.newStatus.status).to.eql('@replied ')
expect(wrapper.vm.newStatus.visibility).to.eql('private')
expect(wrapper.vm.postingOptions.status).to.eql('@replied ')
expect(wrapper.vm.postingOptions.spoilerText).to.eql('re: subject')
expect(wrapper.vm.postingOptions.visibility).to.eql('private')
expect(wrapper.vm.postingOptions.sensitive).to.eql(false)
expect(wrapper.vm.postingOptions.media).to.eql([])
expect(wrapper.vm.postingOptions.inReplyToStatusId).to.eql('status-2')
- expect(wrapper.vm.postingOptions.quoteId).to.eql(null)
+ expect(wrapper.vm.postingOptions.quoteId).to.be.null
expect(wrapper.vm.postingOptions.contentType).to.eql('text/plain')
- expect(wrapper.vm.postingOptions.poll).to.eql(null)
+ expect(wrapper.vm.postingOptions.poll).to.be.null
})
it('Forces direct mode when replying to a DM, mastodon style subject handling', () => {
// We need to initialize pinia first which is happening here...
const options = replyMountOpts({
repliedStatus: { ...repliedStatus2, visibility: 'direct' },
})
// ...set our settings...
useMergedConfigStore().mergedConfig = {
...useMergedConfigStore().mergedConfig,
subjectLineBehavior: 'masto',
}
// ...and only then mount our component
const wrapper = mount(PostStatusForm, options)
// Otherwise we get multiple instances of pinia that don't talk to each other
expect(wrapper.vm.statusType).to.equal('reply')
expect(wrapper.vm.isReply).to.equal(true)
expect(wrapper.vm.quotable).to.equal(false)
- expect(wrapper.vm.newStatus.quote).to.eql(null)
- expect(wrapper.vm.newStatus.poll).to.eql(null)
+ expect(wrapper.vm.newStatus.quote).to.be.null
+ expect(wrapper.vm.newStatus.poll).to.be.null
expect(wrapper.vm.newStatus.spoilerText).to.eql('subject')
expect(wrapper.vm.newStatus.mentions).to.eql('@replied')
expect(wrapper.vm.newStatus.status).to.eql('@replied ')
expect(wrapper.vm.newStatus.visibility).to.eql('direct')
})
it('Sets status to statusText without mentions if mentions line is enabled', () => {
const wrapper = mount(
PostStatusForm,
replyMountOpts({
repliedStatus: repliedStatus2,
statusText: 'testing',
mentionsLine: true,
}),
)
expect(wrapper.vm.statusType).to.equal('reply')
expect(wrapper.vm.isReply).to.equal(true)
expect(wrapper.vm.newStatus.mentions).to.eql('@replied')
expect(wrapper.vm.newStatus.status).to.eql('testing')
})
it('Sets mention when asked for it', () => {
const wrapper = mount(
PostStatusForm,
replyMountOpts({
profileMention: repliedUser,
}),
)
expect(wrapper.vm.statusType).to.equal('mention')
expect(wrapper.vm.isReply).to.equal(false)
expect(wrapper.vm.newStatus.mentions).to.eql('@replied')
expect(wrapper.vm.newStatus.status).to.eql('@replied ')
})
it('Initializes quote when reply/quote toggled to quote', () => {
const wrapper = mount(
PostStatusForm,
replyMountOpts({
repliedStatus: repliedStatus2,
}),
)
expect(wrapper.vm.statusType).to.equal('reply')
expect(wrapper.vm.isReply).to.equal(true)
wrapper.vm.quoteThreadToggled = true
expect(wrapper.vm.newStatus.quote).to.eql({ thread: true, id: 'status-2' })
})
it('Resets quote when reply/quote toggled to reply', () => {
const wrapper = mount(
PostStatusForm,
replyMountOpts({
repliedStatus: repliedStatus2,
}),
)
expect(wrapper.vm.statusType).to.equal('reply')
expect(wrapper.vm.isReply).to.equal(true)
wrapper.vm.quoteThreadToggled = true
wrapper.vm.quoteThreadToggled = false
- expect(wrapper.vm.newStatus.quote).to.eql(null)
+ expect(wrapper.vm.newStatus.quote).to.be.null
})
it('Initializes and reset quote when toggling quote attachment', () => {
const wrapper = mount(
PostStatusForm,
replyMountOpts({
repliedStatus: repliedStatus2,
}),
)
expect(wrapper.vm.statusType).to.equal('reply')
expect(wrapper.vm.isReply).to.equal(true)
wrapper.vm.toggleQuoteForm()
expect(wrapper.vm.newStatus.quote).to.eql({
thread: false,
id: null,
url: '',
})
wrapper.vm.toggleQuoteForm()
- expect(wrapper.vm.newStatus.quote).to.eql(null)
+ expect(wrapper.vm.newStatus.quote).to.be.null
})
it('Status editing', () => {
const wrapper = mount(
PostStatusForm,
replyMountOpts({
statusId: 'edited',
statusText: 'text',
statusSubject: 'heading',
statusIsSensitive: true,
statusPoll: {},
statusQuote: {},
statusFiles: [],
statusMediaDescriptions: {},
statusVisibility: 'unlisted',
statusContentType: 'text/markdown',
}),
)
expect(wrapper.vm.statusType).to.equal('edit')
expect(wrapper.vm.isReply).to.equal(false) // edits don't support changing reply-to so it's pretty much ignored
expect(wrapper.vm.isEdit).to.equal(true)
expect(wrapper.vm.newStatus.quote).to.eql({})
expect(wrapper.vm.newStatus.poll).to.eql({})
expect(wrapper.vm.newStatus.spoilerText).to.eql('heading')
expect(wrapper.vm.newStatus.mentions).to.eql('')
expect(wrapper.vm.newStatus.status).to.eql('text')
expect(wrapper.vm.newStatus.visibility).to.eql('unlisted')
expect(wrapper.vm.newStatus.contentType).to.eql('text/markdown')
expect(wrapper.vm.newStatus.nsfw).to.equal(true)
expect(wrapper.vm.newStatus.files).to.eql([])
})
it('Posting should reset idempotency key', async () => {
vi.setSystemTime(new Date(2027, 1, 1, 13))
const wrapper = mount(PostStatusForm, replyMountOpts())
const oldIdempotency = wrapper.vm.idempotencyKey
vi.setSystemTime(new Date(2028, 1, 1, 13))
wrapper.vm.newStatus.status = 'Testing'
await wrapper.vm.postStatus()
expect(wrapper.vm.idempotencyKey).to.not.eql(oldIdempotency)
})
// TODO Probably better to separate attachment upload/manipulation into its own component?
// we need to upload-on-submit for compression setting anyway
it('Attachments manipulations (moving, adding, removing)', () => {
const wrapper = mount(PostStatusForm, replyMountOpts())
const i1 = { id: '1', url: 'a' }
const i2 = { id: '2', url: 'b' }
const i3 = { id: '3', url: 'c' }
const i4 = { id: '4', url: 'd' }
const iX = { id: 'x', url: 'x' }
wrapper.vm.newStatus.files = [i3, i1, iX, i2]
wrapper.vm.removeMediaFile(iX)
expect(wrapper.vm.newStatus.files).to.eql([i3, i1, i2])
wrapper.vm.shiftUpMediaFile(i1)
expect(wrapper.vm.newStatus.files).to.eql([i1, i3, i2])
wrapper.vm.shiftUpMediaFile(i1) // should ignore
expect(wrapper.vm.newStatus.files).to.eql([i1, i3, i2])
wrapper.vm.shiftDnMediaFile(i3)
expect(wrapper.vm.newStatus.files).to.eql([i1, i2, i3])
wrapper.vm.shiftDnMediaFile(i3) // should ignore
expect(wrapper.vm.newStatus.files).to.eql([i1, i2, i3])
wrapper.vm.addMediaFile(i4)
expect(wrapper.vm.newStatus.files).to.eql([i1, i2, i3, i4])
})
it('Attachment descriptions', () => {
const wrapper = mount(PostStatusForm, replyMountOpts())
const i1 = { id: '1', url: 'a' }
wrapper.vm.addMediaFile(i1)
expect(wrapper.vm.newStatus.files).to.eql([i1])
wrapper.vm.editAttachment(i1, 'description')
expect(wrapper.vm.newStatus.mediaDescriptions['1']).to.eql('description')
})
// TODO: Drafts (needs vuex to pinia migration)
})
diff --git a/test/unit/specs/components/rich_content.spec.js b/test/unit/specs/components/rich_content.spec.js
index fdf6c7f8f4..cc79fe3776 100644
--- a/test/unit/specs/components/rich_content.spec.js
+++ b/test/unit/specs/components/rich_content.spec.js
@@ -1,547 +1,547 @@
import { mount, shallowMount } from '@vue/test-utils'
import RichContent from 'src/components/rich_content/rich_content.jsx'
const attentions = []
const global = {
mocks: {
$store: {
state: {},
getters: {
mergedConfig: () => ({
mentionLinkShowTooltip: true,
}),
findUserByUrl: () => null,
},
},
},
stubs: {
FAIcon: true,
},
}
const makeMention = (who, noClass) => {
attentions.push({ statusnet_profile_url: `https://fake.tld/@${who}` })
return noClass
? `<span><a href="https://fake.tld/@${who}">@<span>${who}</span></a></span>`
: `<span class="h-card"><a class="u-url mention" href="https://fake.tld/@${who}">@<span>${who}</span></a></span>`
}
const p = (...data) => `<p>${data.join('')}</p>`
const compwrap = (...data) =>
`<span class="RichContent">${data.join('')}</span>`
const mentionsLine = (times) =>
[
'<mentions-line-stub mentions="',
new Array(times).fill('[object Object]').join(','),
'"></mentions-line-stub>',
].join('')
describe('RichContent', () => {
it('renders simple post without exploding', () => {
const html = p('Hello world!')
const wrapper = shallowMount(RichContent, {
global,
props: {
attentions,
handleLinks: true,
greentext: true,
emoji: [],
html,
},
})
- expect(wrapper.html().replace(/\n/g, '')).to.eql(compwrap(html))
+ expect(wrapper.html().replaceAll('\n', '')).to.eql(compwrap(html))
})
it('unescapes everything as needed', () => {
const html = [p('Testing &#39;em all'), 'Testing &#39;em all'].join('')
const expected = [p("Testing 'em all"), "Testing 'em all"].join('')
const wrapper = shallowMount(RichContent, {
global,
props: {
attentions,
handleLinks: true,
greentext: true,
emoji: [],
html,
},
})
- expect(wrapper.html().replace(/\n/g, '')).to.eql(compwrap(expected))
+ expect(wrapper.html().replaceAll('\n', '')).to.eql(compwrap(expected))
})
it('replaces mention with mentionsline', () => {
const html = p(makeMention('John'), ' how are you doing today?')
const wrapper = shallowMount(RichContent, {
global,
props: {
attentions,
handleLinks: true,
greentext: true,
emoji: [],
html,
},
})
- expect(wrapper.html().replace(/\n/g, '')).to.eql(
+ expect(wrapper.html().replaceAll('\n', '')).to.eql(
compwrap(p(mentionsLine(1), ' how are you doing today?')),
)
})
it('replaces mentions at the end of the hellpost', () => {
const html = [
p('How are you doing today, fine gentlemen?'),
p(makeMention('John'), makeMention('Josh'), makeMention('Jeremy')),
].join('')
const expected = [
p('How are you doing today, fine gentlemen?'),
// TODO fix this extra line somehow?
p(
'<mentions-line-stub mentions="',
'[object Object],',
'[object Object],',
'[object Object]',
'"></mentions-line-stub>',
),
].join('')
const wrapper = shallowMount(RichContent, {
global,
props: {
attentions,
handleLinks: true,
greentext: true,
emoji: [],
html,
},
})
- expect(wrapper.html().replace(/\n/g, '')).to.eql(compwrap(expected))
+ expect(wrapper.html().replaceAll('\n', '')).to.eql(compwrap(expected))
})
it('Does not touch links if link handling is disabled', () => {
const html = [
[makeMention('Jack'), "let's meet up with ", makeMention('Janet')].join(
'',
),
[makeMention('John'), makeMention('Josh'), makeMention('Jeremy')].join(
'',
),
].join('\n')
const strippedHtml = [
[
makeMention('Jack', true),
"let's meet up with ",
makeMention('Janet', true),
].join(''),
[
makeMention('John', true),
makeMention('Josh', true),
makeMention('Jeremy', true),
].join(''),
].join('\n')
const wrapper = shallowMount(RichContent, {
global,
props: {
attentions,
handleLinks: false,
greentext: true,
emoji: [],
html,
},
})
expect(wrapper.html()).to.eql(compwrap(strippedHtml))
})
it('Adds greentext and cyantext to the post', () => {
const html = ['&gt;preordering videogames', '&gt;any year'].join('\n')
const expected = [
'<span class="greentext">&gt;preordering videogames</span>',
'<span class="greentext">&gt;any year</span>',
].join('\n')
const wrapper = shallowMount(RichContent, {
global,
props: {
attentions,
handleLinks: false,
greentext: true,
emoji: [],
html,
},
})
expect(wrapper.html()).to.eql(compwrap(expected))
})
it('Does not add greentext and cyantext if setting is set to false', () => {
const html = ['&gt;preordering videogames', '&gt;any year'].join('\n')
const wrapper = shallowMount(RichContent, {
global,
props: {
attentions,
handleLinks: false,
greentext: false,
emoji: [],
html,
},
})
expect(wrapper.html()).to.eql(compwrap(html))
})
it('Adds emoji to post', () => {
const html = p('Ebin :DDDD :spurdo:')
const expected = p(
'Ebin :DDDD ',
'<anonymous-stub shortcode="spurdo" islocal="true" class="emoji img" src="about:blank" title=":spurdo:" alt=":spurdo:"></anonymous-stub>',
)
const wrapper = shallowMount(RichContent, {
global,
props: {
attentions,
handleLinks: false,
greentext: false,
emoji: [{ url: 'about:blank', shortcode: 'spurdo' }],
html,
},
})
- expect(wrapper.html().replace(/\n/g, '')).to.eql(compwrap(expected))
+ expect(wrapper.html().replaceAll('\n', '')).to.eql(compwrap(expected))
})
it("Doesn't add nonexistent emoji to post", () => {
const html = p('Lol :lol:')
const wrapper = shallowMount(RichContent, {
global,
props: {
attentions,
handleLinks: false,
greentext: false,
emoji: [],
html,
},
})
- expect(wrapper.html().replace(/\n/g, '')).to.eql(compwrap(html))
+ expect(wrapper.html().replaceAll('\n', '')).to.eql(compwrap(html))
})
it('Greentext + last mentions', () => {
const html = [
'&gt;quote',
makeMention('lol'),
'&gt;quote',
'&gt;quote',
].join('\n')
const expected = [
'<span class="greentext">&gt;quote</span>',
mentionsLine(1),
'<span class="greentext">&gt;quote</span>',
'<span class="greentext">&gt;quote</span>',
].join('\n')
const wrapper = shallowMount(RichContent, {
global,
props: {
attentions,
handleLinks: true,
greentext: true,
emoji: [],
html,
},
})
expect(wrapper.html()).to.eql(compwrap(expected))
})
it('One buggy example', () => {
const html = [
'Bruh',
'Bruh',
[makeMention('foo'), makeMention('bar'), makeMention('baz')].join(''),
'Bruh',
].join('<br>')
const expected = ['Bruh', 'Bruh', mentionsLine(3), 'Bruh'].join('<br>')
const wrapper = shallowMount(RichContent, {
global,
props: {
attentions,
handleLinks: true,
greentext: true,
emoji: [],
html,
},
})
- expect(wrapper.html().replace(/\n/g, '')).to.eql(compwrap(expected))
+ expect(wrapper.html().replaceAll('\n', '')).to.eql(compwrap(expected))
})
it('buggy example/hashtags', () => {
const html = [
'<p>',
'<a href="http://macrochan.org/images/N/H/NHCMDUXJPPZ6M3Z2CQ6D2EBRSWGE7MZY.jpg">',
'NHCMDUXJPPZ6M3Z2CQ6D2EBRSWGE7MZY.jpg</a>',
' <a class="hashtag" data-tag="nou" href="https://shitposter.club/tag/nou">',
'#nou</a>',
' <a class="hashtag" data-tag="screencap" href="https://shitposter.club/tag/screencap">',
'#screencap</a>',
' </p>',
].join('')
const expected = [
'<p>',
'<a href="http://macrochan.org/images/N/H/NHCMDUXJPPZ6M3Z2CQ6D2EBRSWGE7MZY.jpg" target="_blank">',
'NHCMDUXJPPZ6M3Z2CQ6D2EBRSWGE7MZY.jpg</a>',
' <hashtag-link-stub url="https://shitposter.club/tag/nou" content="#nou" tag="nou">',
'</hashtag-link-stub>',
' <hashtag-link-stub url="https://shitposter.club/tag/screencap" content="#screencap" tag="screencap">',
'</hashtag-link-stub>',
' </p>',
].join('')
const wrapper = shallowMount(RichContent, {
global,
props: {
attentions,
handleLinks: true,
greentext: true,
emoji: [],
html,
},
})
- expect(wrapper.html().replace(/\n/g, '')).to.eql(compwrap(expected))
+ expect(wrapper.html().replaceAll('\n', '')).to.eql(compwrap(expected))
})
it('rich contents of a mention are handled properly', () => {
attentions.push({ statusnet_profile_url: 'lol' })
const html = [
p(
'<a href="lol" class="mention">',
'<span>',
'https://</span>',
'<span>',
'lol.tld/</span>',
'<span>',
'</span>',
'</a>',
),
p('Testing'),
].join('')
const expected = [
p(
'<span class="MentionsLine">',
'<span class="MentionLink mention-link">',
'<a href="lol" class="original" target="_blank">',
'<span>',
'https://</span>',
'<span>',
'lol.tld/</span>',
'<span>',
'</span>',
'</a>',
'</span>',
'</span>',
),
p('Testing'),
].join('')
const wrapper = mount(RichContent, {
global,
props: {
attentions,
handleLinks: true,
greentext: true,
emoji: [],
html,
},
})
expect(
wrapper
.html()
- .replace(/\n/g, '')
- .replace(/<!--.*?-->/g, ''),
+ .replaceAll('\n', '')
+ .replaceAll(/<!--.*?-->/g, ''),
).to.eql(compwrap(expected))
})
it('rich contents of nested mentions are handled properly', () => {
attentions.push({ statusnet_profile_url: 'lol' })
const html = [
'<span class="poast-style">',
'<a href="lol" class="mention">',
'<span>',
'https://</span>',
'<span>',
'lol.tld/</span>',
'<span>',
'</span>',
'</a>',
' ',
'<a href="lol" class="mention">',
'<span>',
'https://</span>',
'<span>',
'lol.tld/</span>',
'<span>',
'</span>',
'</a>',
' ',
'</span>',
'Testing',
].join('')
const expected = [
'<span>',
'<span class="MentionsLine">',
'<span class="MentionLink mention-link">',
'<a href="lol" class="original" target="_blank">',
'<span>',
'https://</span>',
'<span>',
'lol.tld/</span>',
'<span>',
'</span>',
'</a>',
'</span>',
'<span class="MentionLink mention-link">',
'<a href="lol" class="original" target="_blank">',
'<span>',
'https://</span>',
'<span>',
'lol.tld/</span>',
'<span>',
'</span>',
'</a>',
'</span>',
'</span>',
' ',
'</span>',
'Testing',
].join('')
const wrapper = mount(RichContent, {
global,
props: {
attentions,
handleLinks: true,
greentext: true,
emoji: [],
html,
},
})
expect(
wrapper
.html()
- .replace(/\n/g, '')
- .replace(/<!--.*?-->/g, ''),
+ .replaceAll('\n', '')
+ .replaceAll(/<!--.*?-->/g, ''),
).to.eql(compwrap(expected))
})
it('rich contents of a link are handled properly', () => {
const html = [
'<p>',
'Freenode is dead.</p>',
'<p>',
'<a href="https://isfreenodedeadyet.com/">',
'<span>',
'https://</span>',
'<span>',
'isfreenodedeadyet.com/</span>',
'<span>',
'</span>',
'</a>',
'</p>',
].join('')
const expected = [
'<p>',
'Freenode is dead.</p>',
'<p>',
'<a href="https://isfreenodedeadyet.com/" target="_blank">',
'<span>',
'https://</span>',
'<span>',
'isfreenodedeadyet.com/</span>',
'<span>',
'</span>',
'</a>',
'</p>',
].join('')
const wrapper = shallowMount(RichContent, {
global,
props: {
attentions,
handleLinks: true,
greentext: true,
emoji: [],
html,
},
})
- expect(wrapper.html().replace(/\n/g, '')).to.eql(compwrap(expected))
+ expect(wrapper.html().replaceAll('\n', '')).to.eql(compwrap(expected))
})
it.skip('[INFORMATIVE] Performance testing, 10 000 simple posts', () => {
const amount = 20
const onePost = p(
makeMention('Lain'),
makeMention('Lain'),
makeMention('Lain'),
makeMention('Lain'),
makeMention('Lain'),
makeMention('Lain'),
makeMention('Lain'),
makeMention('Lain'),
makeMention('Lain'),
makeMention('Lain'),
' i just landed in l a where are you',
)
const TestComponent = {
template: `
<div v-if="!vhtml">
${new Array(amount).fill(`<RichContent html="${onePost}" :greentext="true" :handleLinks="handeLinks" :emoji="[]" :attentions="attentions"/>`)}
</div>
<div v-else="vhtml">
${new Array(amount).fill(`<div v-html="${onePost}"/>`)}
</div>
`,
props: ['handleLinks', 'attentions', 'vhtml'],
}
const ptest = (handleLinks, vhtml) => {
const t0 = performance.now()
const wrapper = mount(TestComponent, {
global,
props: {
attentions,
handleLinks,
vhtml,
},
})
const t1 = performance.now()
wrapper.destroy()
const t2 = performance.now()
return `Mount: ${t1 - t0}ms, destroy: ${t2 - t1}ms, avg ${(t1 - t0) / amount}ms - ${(t2 - t1) / amount}ms per item`
}
console.debug(`${amount} items with links handling:`)
console.debug(ptest(true))
console.debug(`${amount} items without links handling:`)
console.debug(ptest(false))
console.debug(`${amount} items plain v-html:`)
console.debug(ptest(false, true))
})
})
diff --git a/test/unit/specs/lib/persisted_state.spec.js b/test/unit/specs/lib/persisted_state.spec.js
index de7d92bd89..c37d0f99f0 100644
--- a/test/unit/specs/lib/persisted_state.spec.js
+++ b/test/unit/specs/lib/persisted_state.spec.js
@@ -1,326 +1,326 @@
import { flushPromises } from '@vue/test-utils'
import { createPinia, defineStore, setActivePinia } from 'pinia'
import { createApp } from 'vue'
import { piniaPersistPlugin } from 'src/lib/persisted_state.js'
const app = createApp({})
const getMockStorage = () => {
let state = {}
return {
getItem: vi.fn(async (key) => {
console.info('get:', key, state[key])
return state[key]
}),
setItem: vi.fn(async (key, value) => {
console.info('set:', key, value)
state[key] = value
}),
_clear: () => {
state = {}
},
}
}
let mockStorage
beforeEach(() => {
mockStorage = getMockStorage()
const pinia = createPinia().use(piniaPersistPlugin({ storage: mockStorage }))
app.use(pinia)
setActivePinia(pinia)
})
describe('piniaPersistPlugin', () => {
describe('initial state', () => {
it('does not load anything if it is not enabled', async () => {
await mockStorage.setItem('pinia-local-test', { a: 3 })
const useTestStore = defineStore('test', {
state: () => ({ a: 1, b: 2 }),
})
const test = useTestStore()
await test.$persistLoaded
expect(test.a).to.eql(1)
expect(test.b).to.eql(2)
})
test('$persistLoaded rejects if getItem() throws', async () => {
const error = new Error('unable to get storage')
mockStorage.getItem = vi.fn(async () => {
throw error
})
const useTestStore = defineStore('test', {
state: () => ({ a: 1, b: 2, c: { d: 4, e: 5 } }),
persist: {},
})
const test = useTestStore()
await expect(test.$persistLoaded).rejects.toThrowError(error)
})
it('loads from pinia storage', async () => {
await mockStorage.setItem('pinia-local-test', { a: 3, c: { d: 0 } })
await mockStorage.setItem('vuex-lz', { test: { a: 4 } })
const useTestStore = defineStore('test', {
state: () => ({ a: 1, b: 2, c: { d: 4, e: 5 } }),
persist: {},
})
const test = useTestStore()
await test.$persistLoaded
expect(test.a).to.eql(3)
expect(test.b).to.eql(2)
expect(test.c.d).to.eql(0)
expect(test.c.e).to.eql(5)
})
it('loads from vuex storage as fallback', async () => {
await mockStorage.setItem('vuex-lz', { test: { a: 4, c: { d: 0 } } })
const useTestStore = defineStore('test', {
state: () => ({ a: 1, b: 2, c: { d: 4, e: 5 } }),
persist: {},
})
const test = useTestStore()
await test.$persistLoaded
expect(test.a).to.eql(4)
expect(test.b).to.eql(2)
expect(test.c.d).to.eql(0)
expect(test.c.e).to.eql(5)
})
it('loads from vuex storage and writes it into pinia storage', async () => {
await mockStorage.setItem('vuex-lz', { test: { a: 4, c: { d: 0 } } })
const useTestStore = defineStore('test', {
state: () => ({ a: 1, b: 2, c: { d: 4, e: 5 } }),
persist: {
afterLoad(state) {
return {
...state,
a: 5,
}
},
},
})
const test = useTestStore()
await test.$persistLoaded
expect(await mockStorage.getItem('pinia-local-test')).to.eql({
a: 4,
c: { d: 0 },
})
expect(test.a).to.eql(5)
expect(test.b).to.eql(2)
expect(test.c.d).to.eql(0)
expect(test.c.e).to.eql(5)
})
it('does not modify state if there is nothing to load', async () => {
await mockStorage.setItem('vuex-lz', { test2: { a: 4 } })
const useTestStore = defineStore('test', {
state: () => ({ a: 1, b: 2 }),
persist: {},
})
const test = useTestStore()
await test.$persistLoaded
expect(test.a).to.eql(1)
expect(test.b).to.eql(2)
})
})
describe('paths', () => {
it('saves everything if paths is unspecified', async () => {
const useTestStore = defineStore('test', {
state: () => ({ a: 1, b: 2 }),
persist: {},
})
const test = useTestStore()
await test.$persistLoaded
test.$patch({ a: 3 })
await flushPromises()
expect(await mockStorage.getItem('pinia-local-test')).to.eql({
a: 3,
b: 2,
})
})
it('saves only specified paths', async () => {
const useTestStore = defineStore('test', {
state: () => ({ a: 1, b: 2, c: { d: 4, e: 5 } }),
persist: {
paths: ['a', 'c.d'],
},
})
const test = useTestStore()
await test.$persistLoaded
test.$patch({ a: 3 })
await flushPromises()
expect(await mockStorage.getItem('pinia-local-test')).to.eql({
a: 3,
c: { d: 4 },
})
})
})
it('only saves after load', async () => {
const onSaveError = vi.fn()
const onSaveSuccess = vi.fn()
const useTestStore = defineStore('test', {
state: () => ({ a: 1, b: 2 }),
persist: {
onSaveSuccess,
onSaveError,
},
})
const test = useTestStore()
test.$patch({ a: 3 })
- expect(await mockStorage.getItem('pinia-local-test')).to.eql(undefined)
+ expect(await mockStorage.getItem('pinia-local-test')).to.be.undefined
// NOTE: it should not even have tried to save, because the subscribe function
// is called only after loading the initial state.
expect(mockStorage.setItem).not.toHaveBeenCalled()
// this asserts that it has not called setState() in persistCurrentState()
expect(onSaveError).not.toHaveBeenCalled()
expect(onSaveSuccess).not.toHaveBeenCalled()
await test.$persistLoaded
test.$patch({ a: 4 })
expect(await mockStorage.getItem('pinia-local-test')).to.eql({ a: 4, b: 2 })
})
describe('saveImmediatelyActions', () => {
it('should only persist state after specified actions', async () => {
const useTestStore = defineStore('test', {
state: () => ({ a: 1, b: 2 }),
actions: {
increaseA() {
++this.a
},
increaseB() {
++this.b
},
},
persist: {
saveImmediatelyActions: ['increaseA'],
},
})
const test = useTestStore()
await test.$persistLoaded
await test.increaseA()
expect(await mockStorage.getItem('pinia-local-test')).to.eql({
a: 2,
b: 2,
})
await test.increaseB()
expect(await mockStorage.getItem('pinia-local-test')).to.eql({
a: 2,
b: 2,
})
await test.increaseA()
expect(await mockStorage.getItem('pinia-local-test')).to.eql({
a: 3,
b: 3,
})
})
})
describe('onSaveSuccess / onSaveError', () => {
test('onSaveSuccess is called after setState', async () => {
const onSaveSuccess = vi.fn()
const onSaveError = vi.fn()
const useTestStore = defineStore('test', {
state: () => ({ a: 1, b: 2 }),
persist: {
onSaveSuccess,
onSaveError,
},
})
const test = useTestStore()
await test.$persistLoaded
test.$patch({ a: 3 })
await flushPromises()
expect(onSaveSuccess).toHaveBeenCalledTimes(1)
expect(onSaveError).toHaveBeenCalledTimes(0)
test.$patch({ a: 4 })
await flushPromises()
expect(onSaveSuccess).toHaveBeenCalledTimes(2)
expect(onSaveError).toHaveBeenCalledTimes(0)
})
test('onSaveError is called after setState fails', async () => {
mockStorage.setItem = vi.fn(async () => {
throw new Error('cannot save')
})
const onSaveSuccess = vi.fn()
const onSaveError = vi.fn()
const useTestStore = defineStore('test', {
state: () => ({ a: 1, b: 2 }),
persist: {
onSaveSuccess,
onSaveError,
},
})
const test = useTestStore()
await test.$persistLoaded
await test.$patch({ a: 3 })
expect(onSaveSuccess).toHaveBeenCalledTimes(0)
expect(onSaveError).toHaveBeenCalledTimes(1)
await test.$patch({ a: 4 })
expect(onSaveSuccess).toHaveBeenCalledTimes(0)
expect(onSaveError).toHaveBeenCalledTimes(2)
})
})
describe('afterLoad', () => {
it('is called with the saved state object', async () => {
await mockStorage.setItem('pinia-local-test', { a: 2 })
const afterLoad = vi.fn(async (orig) => {
return { a: orig.a + 1 }
})
const useTestStore = defineStore('test', {
state: () => ({ a: 1, b: 2 }),
persist: {
afterLoad,
},
})
const test = useTestStore()
await test.$persistLoaded
expect(afterLoad).toHaveBeenCalledTimes(1)
expect(afterLoad).toHaveBeenCalledWith({ a: 2 })
expect(test.a).to.eql(3)
})
it('is called with empty object if there is no saved state', async () => {
const afterLoad = vi.fn(async () => {
return { a: 3 }
})
const useTestStore = defineStore('test', {
state: () => ({ a: 1, b: 2 }),
persist: {
afterLoad,
},
})
const test = useTestStore()
await test.$persistLoaded
expect(afterLoad).toHaveBeenCalledTimes(1)
expect(afterLoad).toHaveBeenCalledWith({})
expect(test.a).to.eql(3)
})
})
})
diff --git a/test/unit/specs/modules/statuses.spec.js b/test/unit/specs/modules/statuses.spec.js
index 1315724daa..04bd07ffec 100644
--- a/test/unit/specs/modules/statuses.spec.js
+++ b/test/unit/specs/modules/statuses.spec.js
@@ -1,447 +1,447 @@
import { createTestingPinia } from '@pinia/testing'
import {
defaultState,
mutations,
prepareStatus,
} from '../../../../src/modules/statuses.js'
createTestingPinia()
const makeMockStatus = ({ id, text, type = 'status' }) => {
return {
id,
user: { id: '0' },
name: 'status',
text: text || `Text number ${id}`,
fave_num: 0,
uri: '',
type,
attentions: [],
}
}
describe('Statuses module', () => {
describe('prepareStatus', () => {
it('sets deleted flag to false', () => {
const aStatus = makeMockStatus({ id: '1', text: 'Hello oniichan' })
expect(prepareStatus(aStatus).deleted).to.eq(false)
})
})
describe('addNewStatuses', () => {
it('adds the status to allStatuses and to the given timeline', () => {
const state = defaultState()
const status = makeMockStatus({ id: '1' })
mutations.addNewStatuses(state, {
statuses: [status],
timeline: 'public',
})
expect(state.allStatuses).to.eql([status])
expect(state.timelines.public.statuses).to.eql([status])
expect(state.timelines.public.visibleStatuses).to.eql([])
expect(state.timelines.public.newStatusCount).to.equal(1)
})
it('counts the status as new if it has not been seen on this timeline', () => {
const state = defaultState()
const status = makeMockStatus({ id: '1' })
mutations.addNewStatuses(state, {
statuses: [status],
timeline: 'public',
})
mutations.addNewStatuses(state, {
statuses: [status],
timeline: 'friends',
})
expect(state.allStatuses).to.eql([status])
expect(state.timelines.public.statuses).to.eql([status])
expect(state.timelines.public.visibleStatuses).to.eql([])
expect(state.timelines.public.newStatusCount).to.equal(1)
expect(state.allStatuses).to.eql([status])
expect(state.timelines.friends.statuses).to.eql([status])
expect(state.timelines.friends.visibleStatuses).to.eql([])
expect(state.timelines.friends.newStatusCount).to.equal(1)
})
it('add the statuses to allStatuses if no timeline is given', () => {
const state = defaultState()
const status = makeMockStatus({ id: '1' })
mutations.addNewStatuses(state, { statuses: [status] })
expect(state.allStatuses).to.eql([status])
expect(state.timelines.public.statuses).to.eql([])
expect(state.timelines.public.visibleStatuses).to.eql([])
expect(state.timelines.public.newStatusCount).to.equal(0)
})
it('adds the status to allStatuses and to the given timeline, directly visible', () => {
const state = defaultState()
const status = makeMockStatus({ id: '1' })
mutations.addNewStatuses(state, {
statuses: [status],
showImmediately: true,
timeline: 'public',
})
expect(state.allStatuses).to.eql([status])
expect(state.timelines.public.statuses).to.eql([status])
expect(state.timelines.public.visibleStatuses).to.eql([status])
expect(state.timelines.public.newStatusCount).to.equal(0)
})
it('does not update the maxId when the noIdUpdate flag is set', () => {
const state = defaultState()
const status = makeMockStatus({ id: '1' })
const secondStatus = makeMockStatus({ id: '2' })
mutations.addNewStatuses(state, {
statuses: [status],
showImmediately: true,
timeline: 'public',
})
- expect(state.timelines.public.maxId).to.eql('1')
+ expect(state.timelines.public.maxId).to.equal('1')
mutations.addNewStatuses(state, {
statuses: [secondStatus],
showImmediately: true,
timeline: 'public',
noIdUpdate: true,
})
expect(state.timelines.public.statuses).to.eql([secondStatus, status])
expect(state.timelines.public.visibleStatuses).to.eql([
secondStatus,
status,
])
- expect(state.timelines.public.maxId).to.eql('1')
+ expect(state.timelines.public.maxId).to.equal('1')
})
it('keeps a descending by id order in timeline.visibleStatuses and timeline.statuses', () => {
const state = defaultState()
const nonVisibleStatus = makeMockStatus({ id: '1' })
const status = makeMockStatus({ id: '3' })
const statusTwo = makeMockStatus({ id: '2' })
const statusThree = makeMockStatus({ id: '4' })
mutations.addNewStatuses(state, {
statuses: [nonVisibleStatus],
showImmediately: false,
timeline: 'public',
})
mutations.addNewStatuses(state, {
statuses: [status],
showImmediately: true,
timeline: 'public',
})
mutations.addNewStatuses(state, {
statuses: [statusTwo],
showImmediately: true,
timeline: 'public',
})
expect(state.timelines.public.minVisibleId).to.equal('2')
mutations.addNewStatuses(state, {
statuses: [statusThree],
showImmediately: true,
timeline: 'public',
})
expect(state.timelines.public.statuses).to.eql([
statusThree,
status,
statusTwo,
nonVisibleStatus,
])
expect(state.timelines.public.visibleStatuses).to.eql([
statusThree,
status,
statusTwo,
])
})
it('splits retweets from their status and links them', () => {
const state = defaultState()
const status = makeMockStatus({ id: '1' })
const retweet = makeMockStatus({ id: '2', type: 'retweet' })
const modStatus = makeMockStatus({ id: '1', text: 'something else' })
retweet.retweeted_status = status
// It adds both statuses, but only the retweet to visible.
mutations.addNewStatuses(state, {
statuses: [retweet],
timeline: 'public',
showImmediately: true,
})
expect(state.timelines.public.visibleStatuses).to.have.length(1)
expect(state.timelines.public.statuses).to.have.length(1)
expect(state.allStatuses).to.have.length(2)
expect(state.allStatuses[0].id).to.equal('1')
expect(state.allStatuses[1].id).to.equal('2')
// It refers to the modified status.
mutations.addNewStatuses(state, {
statuses: [modStatus],
timeline: 'public',
})
expect(state.allStatuses).to.have.length(2)
expect(state.allStatuses[0].id).to.equal('1')
expect(state.allStatuses[0].text).to.equal(modStatus.text)
expect(state.allStatuses[1].id).to.equal('2')
expect(retweet.retweeted_status.text).to.eql(modStatus.text)
})
it('replaces existing statuses with the same id', () => {
const state = defaultState()
const status = makeMockStatus({ id: '1' })
const modStatus = makeMockStatus({ id: '1', text: 'something else' })
// Add original status
mutations.addNewStatuses(state, {
statuses: [status],
showImmediately: true,
timeline: 'public',
})
expect(state.timelines.public.visibleStatuses).to.have.length(1)
expect(state.allStatuses).to.have.length(1)
// Add new version of status
mutations.addNewStatuses(state, {
statuses: [modStatus],
showImmediately: true,
timeline: 'public',
})
expect(state.timelines.public.visibleStatuses).to.have.length(1)
expect(state.allStatuses).to.have.length(1)
expect(state.allStatuses[0].text).to.eql(modStatus.text)
})
it('replaces existing statuses with the same id, coming from a retweet', () => {
const state = defaultState()
const status = makeMockStatus({ id: '1' })
const modStatus = makeMockStatus({ id: '1', text: 'something else' })
const retweet = makeMockStatus({ id: '2', type: 'retweet' })
retweet.retweeted_status = modStatus
// Add original status
mutations.addNewStatuses(state, {
statuses: [status],
showImmediately: true,
timeline: 'public',
})
expect(state.timelines.public.visibleStatuses).to.have.length(1)
expect(state.allStatuses).to.have.length(1)
// Add new version of status
mutations.addNewStatuses(state, {
statuses: [retweet],
showImmediately: false,
timeline: 'public',
})
expect(state.timelines.public.visibleStatuses).to.have.length(1)
// Don't add the retweet itself if the tweet is visible
expect(state.timelines.public.statuses).to.have.length(1)
expect(state.allStatuses).to.have.length(2)
expect(state.allStatuses[0].text).to.eql(modStatus.text)
})
it('handles favorite actions', () => {
const state = defaultState()
const status = makeMockStatus({ id: '1' })
const favorite = {
id: '2',
type: 'favorite',
in_reply_to_status_id: '1', // The API uses strings here...
uri: 'tag:shitposter.club,2016-08-21:fave:3895:note:773501:2016-08-21T16:52:15+00:00',
text: 'a favorited something by b',
user: { id: '99' },
}
mutations.addNewStatuses(state, {
statuses: [status],
showImmediately: true,
timeline: 'public',
})
mutations.addNewStatuses(state, {
statuses: [favorite],
showImmediately: true,
timeline: 'public',
})
- expect(state.timelines.public.visibleStatuses.length).to.eql(1)
+ expect(state.timelines.public.visibleStatuses).to.have.length(1)
expect(state.timelines.public.visibleStatuses[0].fave_num).to.eql(1)
expect(state.timelines.public.maxId).to.eq(favorite.id)
// Adding it again does nothing
mutations.addNewStatuses(state, {
statuses: [favorite],
showImmediately: true,
timeline: 'public',
})
- expect(state.timelines.public.visibleStatuses.length).to.eql(1)
+ expect(state.timelines.public.visibleStatuses).to.have.length(1)
expect(state.timelines.public.visibleStatuses[0].fave_num).to.eql(1)
expect(state.timelines.public.maxId).to.eq(favorite.id)
// If something is favorited by the current user, it also sets the 'favorited' property but does not increment counter to avoid over-counting. Counter is incremented (updated, really) via response to the favorite request.
const user = {
id: '1',
}
const ownFavorite = {
id: '3',
type: 'favorite',
in_reply_to_status_id: '1', // The API uses strings here...
uri: 'tag:shitposter.club,2016-08-21:fave:3895:note:773501:2016-08-21T16:52:15+00:00',
text: 'a favorited something by b',
user,
}
mutations.addNewStatuses(state, {
statuses: [ownFavorite],
showImmediately: true,
timeline: 'public',
user,
})
- expect(state.timelines.public.visibleStatuses.length).to.eql(1)
+ expect(state.timelines.public.visibleStatuses).to.have.length(1)
expect(state.timelines.public.visibleStatuses[0].fave_num).to.eql(1)
expect(state.timelines.public.visibleStatuses[0].favorited).to.eql(true)
})
})
describe('emojiReactions', () => {
it('increments count in existing reaction', () => {
const state = defaultState()
const status = makeMockStatus({ id: '1' })
status.emoji_reactions = [{ name: '😂', count: 1, accounts: [] }]
mutations.addNewStatuses(state, {
statuses: [status],
showImmediately: true,
timeline: 'public',
})
mutations.addOwnReaction(state, {
id: '1',
emoji: '😂',
currentUser: { id: 'me' },
})
expect(state.allStatusesObject['1'].emoji_reactions[0].count).to.eql(2)
expect(state.allStatusesObject['1'].emoji_reactions[0].me).to.eql(true)
expect(
state.allStatusesObject['1'].emoji_reactions[0].accounts[0].id,
- ).to.eql('me')
+ ).to.equal('me')
})
it('adds a new reaction', () => {
const state = defaultState()
const status = makeMockStatus({ id: '1' })
status.emoji_reactions = []
mutations.addNewStatuses(state, {
statuses: [status],
showImmediately: true,
timeline: 'public',
})
mutations.addOwnReaction(state, {
id: '1',
emoji: '😂',
currentUser: { id: 'me' },
})
expect(state.allStatusesObject['1'].emoji_reactions[0].count).to.eql(1)
expect(state.allStatusesObject['1'].emoji_reactions[0].me).to.eql(true)
expect(
state.allStatusesObject['1'].emoji_reactions[0].accounts[0].id,
- ).to.eql('me')
+ ).to.equal('me')
})
it('decreases count in existing reaction', () => {
const state = defaultState()
const status = makeMockStatus({ id: '1' })
status.emoji_reactions = [
{ name: '😂', count: 2, accounts: [{ id: 'me' }] },
]
mutations.addNewStatuses(state, {
statuses: [status],
showImmediately: true,
timeline: 'public',
})
mutations.removeOwnReaction(state, {
id: '1',
emoji: '😂',
currentUser: { id: 'me' },
})
expect(state.allStatusesObject['1'].emoji_reactions[0].count).to.eql(1)
expect(state.allStatusesObject['1'].emoji_reactions[0].me).to.eql(false)
expect(state.allStatusesObject['1'].emoji_reactions[0].accounts).to.eql(
[],
)
})
it('removes a reaction', () => {
const state = defaultState()
const status = makeMockStatus({ id: '1' })
status.emoji_reactions = [
{ name: '😂', count: 1, accounts: [{ id: 'me' }] },
]
mutations.addNewStatuses(state, {
statuses: [status],
showImmediately: true,
timeline: 'public',
})
mutations.removeOwnReaction(state, {
id: '1',
emoji: '😂',
currentUser: { id: 'me' },
})
- expect(state.allStatusesObject['1'].emoji_reactions.length).to.eql(0)
+ expect(state.allStatusesObject['1'].emoji_reactions).to.have.length(0)
})
})
describe('showNewStatuses', () => {
it('resets the minId to the min of the visible statuses when adding new to visible statuses', () => {
const state = defaultState()
const status = makeMockStatus({ id: '10' })
mutations.addNewStatuses(state, {
statuses: [status],
showImmediately: true,
timeline: 'public',
})
const newStatus = makeMockStatus({ id: '20' })
mutations.addNewStatuses(state, {
statuses: [newStatus],
showImmediately: false,
timeline: 'public',
})
state.timelines.public.minId = '5'
mutations.showNewStatuses(state, { timeline: 'public' })
- expect(state.timelines.public.visibleStatuses.length).to.eql(2)
- expect(state.timelines.public.minVisibleId).to.eql('10')
- expect(state.timelines.public.minId).to.eql('10')
+ expect(state.timelines.public.visibleStatuses).to.have.length(2)
+ expect(state.timelines.public.minVisibleId).to.equal('10')
+ expect(state.timelines.public.minId).to.equal('10')
})
})
describe('clearTimeline', () => {
it('keeps userId when clearing user timeline when excludeUserId param is true', () => {
const state = defaultState()
state.timelines.user.userId = 123
mutations.clearTimeline(state, { timeline: 'user', excludeUserId: true })
expect(state.timelines.user.userId).to.eql(123)
})
})
})
diff --git a/test/unit/specs/modules/users.spec.js b/test/unit/specs/modules/users.spec.js
index c07f817d82..1b33f8c4ea 100644
--- a/test/unit/specs/modules/users.spec.js
+++ b/test/unit/specs/modules/users.spec.js
@@ -1,120 +1,120 @@
import { cloneDeep } from 'lodash'
import {
defaultState,
getters,
mutations,
} from '../../../../src/modules/users.js'
describe('The users module', () => {
describe('mutations', () => {
it('adds new users to the set, merging in new information for old users', () => {
const state = cloneDeep(defaultState)
const user = { id: '1', name: 'Guy' }
const modUser = { id: '1', name: 'Dude' }
mutations.addNewUsers(state, [user])
expect(state.users).to.have.length(1)
expect(state.users).to.eql([user])
mutations.addNewUsers(state, [modUser])
expect(state.users).to.have.length(1)
expect(state.users).to.eql([user])
expect(state.users[0].name).to.eql('Dude')
})
it('merging array field in new information for old users', () => {
const state = cloneDeep(defaultState)
const user = {
id: '1',
fields: [{ name: 'Label 1', value: 'Content 1' }],
}
const firstModUser = {
id: '1',
fields: [
{ name: 'Label 2', value: 'Content 2' },
{ name: 'Label 3', value: 'Content 3' },
],
}
const secondModUser = {
id: '1',
fields: [{ name: 'Label 4', value: 'Content 4' }],
}
mutations.addNewUsers(state, [user])
expect(state.users[0].fields).to.have.length(1)
expect(state.users[0].fields[0].name).to.eql('Label 1')
mutations.addNewUsers(state, [firstModUser])
expect(state.users[0].fields).to.have.length(2)
expect(state.users[0].fields[0].name).to.eql('Label 2')
expect(state.users[0].fields[1].name).to.eql('Label 3')
mutations.addNewUsers(state, [secondModUser])
expect(state.users[0].fields).to.have.length(1)
expect(state.users[0].fields[0].name).to.eql('Label 4')
})
})
describe('findUser', () => {
it('does not return user with matching screen_name', () => {
const user = { screen_name: 'Guy', id: '1' }
const state = {
usersObject: {
1: user,
},
usersByNameObject: {
guy: user,
},
}
const name = 'Guy'
- expect(getters.findUser(state)(name)).to.eql(undefined)
+ expect(getters.findUser(state)(name)).to.be.undefined
})
it('returns user with matching id', () => {
const user = { screen_name: 'Guy', id: '1' }
const state = {
usersObject: {
1: user,
},
usersByNameObject: {
guy: user,
},
}
const id = '1'
const expected = { screen_name: 'Guy', id: '1' }
expect(getters.findUser(state)(id)).to.eql(expected)
})
})
describe('findUserByName', () => {
it('returns user with matching screen_name', () => {
const user = { screen_name: 'Guy', id: '1' }
const state = {
usersObject: {
1: user,
},
usersByNameObject: {
guy: user,
},
}
const name = 'Guy'
const expected = { screen_name: 'Guy', id: '1' }
expect(getters.findUserByName(state)(name)).to.eql(expected)
})
it('does not return user with matching id', () => {
const user = { screen_name: 'Guy', id: '1' }
const state = {
usersObject: {
1: user,
},
usersByNameObject: {
guy: user,
},
}
const id = '1'
- expect(getters.findUserByName(state)(id)).to.eql(undefined)
+ expect(getters.findUserByName(state)(id)).to.be.undefined
})
})
})
diff --git a/test/unit/specs/services/entity_normalizer/entity_normalizer.spec.js b/test/unit/specs/services/entity_normalizer/entity_normalizer.spec.js
index da3f88636f..c68c832a0f 100644
--- a/test/unit/specs/services/entity_normalizer/entity_normalizer.spec.js
+++ b/test/unit/specs/services/entity_normalizer/entity_normalizer.spec.js
@@ -1,205 +1,205 @@
import mastoapidata from '../../../../fixtures/mastoapi.json'
import {
parseLinkHeaderPagination,
parseStatus,
parseUser,
} from 'src/services/entity_normalizer/entity_normalizer.service.js'
const makeMockUserMasto = (overrides = {}) => {
return {
acct: 'hj',
avatar:
'https://shigusegubu.club/media/1657b945-8d5b-4ce6-aafb-4c3fc5772120/8ce851029af84d55de9164e30cc7f46d60cbf12eee7e96c5c0d35d9038ddade1.png',
avatar_static:
'https://shigusegubu.club/media/1657b945-8d5b-4ce6-aafb-4c3fc5772120/8ce851029af84d55de9164e30cc7f46d60cbf12eee7e96c5c0d35d9038ddade1.png',
bot: false,
created_at: '2017-12-17T21:54:14.000Z',
display_name: 'whatever whatever whatever witch',
emojis: [],
fields: [],
followers_count: 705,
following_count: 326,
header:
'https://shigusegubu.club/media/7ab024d9-2a8a-4fbc-9ce8-da06756ae2db/6aadefe4e264133bc377ab450e6b045b6f5458542a5c59e6c741f86107f0388b.png',
header_static:
'https://shigusegubu.club/media/7ab024d9-2a8a-4fbc-9ce8-da06756ae2db/6aadefe4e264133bc377ab450e6b045b6f5458542a5c59e6c741f86107f0388b.png',
id: '1',
locked: false,
note: 'Volatile Internet Weirdo. Name pronounced as Hee Jay. JS and Java dark arts mage, Elixir trainee. I love sampo and lain. Matrix is <span><a data-user="1" href="https://shigusegubu.club/users/hj">@<span>hj</span></a></span>:matrix.heldscal.la Pronouns are whatever. Do not DM me unless it\'s truly private matter and you\'re instance\'s admin or you risk your DM to be reposted publicly.Wish i was Finnish girl.',
pleroma: { confirmation_pending: false, tags: null },
source: { note: '', privacy: 'public', sensitive: false },
statuses_count: 41775,
url: 'https://shigusegubu.club/users/hj',
username: 'hj',
...overrides,
}
}
const makeMockStatusMasto = (overrides = {}) => {
return {
account: makeMockUserMasto(),
application: { name: 'Web', website: null },
content:
'<span><a data-user="14660" href="https://pleroma.soykaf.com/users/sampo">@<span>sampo</span></a></span> god i wish i was there',
created_at: '2019-01-17T16:29:23.000Z',
emojis: [],
favourited: false,
favourites_count: 1,
id: '10423476',
in_reply_to_account_id: '14660',
in_reply_to_id: '10423197',
language: null,
media_attachments: [],
mentions: [
{
acct: 'sampo@pleroma.soykaf.com',
id: '14660',
url: 'https://pleroma.soykaf.com/users/sampo',
username: 'sampo',
},
],
muted: false,
reblog: null,
reblogged: false,
reblogs_count: 0,
replies_count: 0,
sensitive: false,
spoiler_text: '',
tags: [],
uri: 'https://shigusegubu.club/objects/16033fbb-97c0-4f0e-b834-7abb92fb8639',
url: 'https://shigusegubu.club/objects/16033fbb-97c0-4f0e-b834-7abb92fb8639',
visibility: 'public',
pleroma: {
local: true,
},
...overrides,
}
}
const makeMockEmojiMasto = (overrides = [{}]) => {
return [
{
shortcode: 'image',
static_url: 'https://example.com/image.png',
url: 'https://example.com/image.png',
visible_in_picker: false,
...overrides[0],
},
{
shortcode: 'thinking',
static_url: 'https://example.com/think.png',
url: 'https://example.com/think.png',
visible_in_picker: false,
...overrides[1],
},
]
}
describe('API Entities normalizer', () => {
describe('parseStatus', () => {
describe('Mastoapi preprocessing and converting', () => {
it("doesn't blow up", () => {
const parsed = mastoapidata.map(parseStatus)
- expect(parsed.length).to.eq(mastoapidata.length)
+ expect(parsed).to.have.length(mastoapidata.length)
})
it('processes repeats correctly', () => {
const post = makeMockStatusMasto({ reblog: null, id: 'deadbeef' })
const repeat = makeMockStatusMasto({ reblog: post, id: 'foobar' })
const parsedPost = parseStatus(post)
const parsedRepeat = parseStatus(repeat)
expect(parsedPost).to.have.property('type', 'status')
expect(parsedRepeat).to.have.property('type', 'retweet')
expect(parsedRepeat).to.have.property('retweeted_status')
expect(parsedRepeat).to.have.nested.property(
'retweeted_status.id',
'deadbeef',
)
})
})
})
// Statuses generally already contain some info regarding users and there's nearly 1:1 mapping, so very little to test
describe('parseUsers (MastoAPI)', () => {
it('sets correct is_local for users depending on their screen_name', () => {
const local = makeMockUserMasto({ acct: 'foo' })
const remote = makeMockUserMasto({ acct: 'foo@bar.baz' })
expect(parseUser(local)).to.have.property('is_local', true)
expect(parseUser(remote)).to.have.property('is_local', false)
})
it('removes html tags from user profile fields', () => {
const user = makeMockUserMasto({
emojis: makeMockEmojiMasto(),
fields: [
{
name: 'user',
value: '<a rel="me" href="https://example.com/@user">@user</a>',
},
],
})
const parsedUser = parseUser(user)
expect(parsedUser).to.have.property('fields_text').to.be.an('array')
const field = parsedUser.fields_text[0]
expect(field).to.have.property('name').that.equal('user')
expect(field).to.have.property('value').that.equal('@user')
})
it('adds hide_follows and hide_followers user settings', () => {
const user = makeMockUserMasto({
pleroma: {
hide_followers: true,
hide_follows: false,
hide_followers_count: false,
hide_follows_count: true,
},
})
expect(parseUser(user)).to.have.property('hide_followers', true)
expect(parseUser(user)).to.have.property('hide_follows', false)
expect(parseUser(user)).to.have.property('hide_followers_count', false)
expect(parseUser(user)).to.have.property('hide_follows_count', true)
})
it('converts IDN to unicode and marks it as internatonal', () => {
const user = makeMockUserMasto({ acct: 'lain@xn--lin-6cd.com' })
expect(parseUser(user))
.to.have.property('screen_name_ui')
.that.equal('lain@lаin.com')
expect(parseUser(user))
.to.have.property('screen_name_ui_contains_non_ascii')
.that.equal(true)
})
})
describe('Link header pagination', () => {
it('Parses min and max ids as integers', () => {
const linkHeader =
'<https://example.com/api/v1/notifications?max_id=861676>; rel="next", <https://example.com/api/v1/notifications?min_id=861741>; rel="prev"'
const result = parseLinkHeaderPagination(linkHeader)
expect(result).to.eql({
maxId: 861676,
minId: 861741,
})
})
it('Parses min and max ids as flakes', () => {
const linkHeader =
'<http://example.com/api/v1/timelines/home?max_id=9waQx5IIS48qVue2Ai>; rel="next", <http://example.com/api/v1/timelines/home?min_id=9wi61nIPnfn674xgie>; rel="prev"'
const result = parseLinkHeaderPagination(linkHeader, { flakeId: true })
expect(result).to.eql({
maxId: '9waQx5IIS48qVue2Ai',
minId: '9wi61nIPnfn674xgie',
})
})
})
})
diff --git a/test/unit/specs/services/style_setter/style_setter.spec.js b/test/unit/specs/services/style_setter/style_setter.spec.js
new file mode 100644
index 0000000000..c1bacdfa13
--- /dev/null
+++ b/test/unit/specs/services/style_setter/style_setter.spec.js
@@ -0,0 +1,24 @@
+import { hasInvalidCachedThemeRules } from 'src/services/style_setter/style_setter.js'
+
+describe('style setter cache', () => {
+ it('rejects cached rules containing serialized objects', () => {
+ expect(
+ hasInvalidCachedThemeRules([
+ ['html { --font: [object Object]; }'],
+ ['.post { --font: sans-serif; }'],
+ ]),
+ ).to.equal(true)
+ })
+
+ it('accepts cached rules containing valid font families', () => {
+ expect(
+ hasInvalidCachedThemeRules([
+ ['html { --font: sans-serif; }'],
+ [
+ '.post { --font: "Atkinson Hyperlegible"; }',
+ '.post::after { content: "[object Object]"; }',
+ ],
+ ]),
+ ).to.equal(false)
+ })
+})
diff --git a/test/unit/specs/services/theme_data/theme2_to_theme3.spec.js b/test/unit/specs/services/theme_data/theme2_to_theme3.spec.js
new file mode 100644
index 0000000000..7165d88db4
--- /dev/null
+++ b/test/unit/specs/services/theme_data/theme2_to_theme3.spec.js
@@ -0,0 +1,42 @@
+import {
+ basePaletteKeys,
+ convertTheme2To3,
+} from 'src/services/theme_data/theme2_to_theme3.js'
+
+describe('Theme 2 to Theme 3 conversion', () => {
+ it('converts font descriptors to CSS font families', () => {
+ const colors = Object.fromEntries(
+ [...basePaletteKeys].map((key) => [key, '#000000']),
+ )
+ const rules = convertTheme2To3({
+ colors,
+ fonts: {
+ interface: { family: 'sans-serif' },
+ input: { family: 'Open Sans' },
+ post: { family: 'Atkinson Hyperlegible' },
+ postCode: { family: 'monospace' },
+ },
+ })
+
+ expect(rules).to.deep.include({
+ source: '2to3',
+ component: 'Root',
+ directives: { '--font': 'generic | sans-serif' },
+ })
+ expect(rules).to.deep.include({
+ source: '2to3',
+ component: 'Root',
+ directives: { '--monoFont': 'generic | monospace' },
+ })
+ expect(rules).to.deep.include({
+ source: '2to3',
+ component: 'Input',
+ directives: { '--font': 'generic | Open Sans' },
+ })
+ expect(rules).to.deep.include({
+ source: '2to3',
+ component: 'RichContent',
+ directives: { '--font': 'generic | Atkinson Hyperlegible' },
+ })
+ })
+})
diff --git a/test/unit/specs/stores/sync_config.spec.js b/test/unit/specs/stores/sync_config.spec.js
index a045aa18ae..d2e6d42d31 100644
--- a/test/unit/specs/stores/sync_config.spec.js
+++ b/test/unit/specs/stores/sync_config.spec.js
@@ -1,674 +1,674 @@
import { cloneDeep } from 'lodash'
import { createPinia, setActivePinia } from 'pinia'
import {
_getAllFlags,
_getRecentData,
_mergeFlags,
_mergePrefs,
_moveItemInArray,
_resetFlags,
COMMAND_TRIM_FLAGS,
COMMAND_TRIM_FLAGS_AND_RESET,
defaultState,
newUserFlags,
useSyncConfigStore,
VERSION,
} from 'src/stores/sync_config.js'
describe('The SyncConfig store', () => {
beforeEach(() => {
setActivePinia(createPinia())
})
describe('mutations', () => {
describe('initSyncConfig', () => {
const user = {
created_at: new Date('1999-02-09'),
storage: {},
}
it('should initialize storage if none present', async () => {
const store = useSyncConfigStore()
// PushSyncConfig is very simple but uses vuex to push data
store.pushSyncConfig = () => {
/* no-op */
}
await store.initSyncConfig({ ...user })
expect(store.cache._version).to.eql(VERSION)
expect(store.cache._timestamp).to.be.a('number')
expect(store.cache.flagStorage).to.eql(defaultState.flagStorage)
expect(store.cache.prefsStorage).to.eql(defaultState.prefsStorage)
})
it('should initialize storage with proper flags for new users if none present', async () => {
const store = useSyncConfigStore()
// PushSyncConfig is very simple but uses vuex to push data
store.pushSyncConfig = () => {
/* no-op */
}
await store.initSyncConfig({ ...user, created_at: new Date() })
expect(store.cache._version).to.eql(VERSION)
expect(store.cache._timestamp).to.be.a('number')
expect(store.cache.flagStorage).to.eql(newUserFlags)
expect(store.cache.prefsStorage).to.eql(defaultState.prefsStorage)
})
it('should merge flags even if remote timestamp is older', async () => {
const store = useSyncConfigStore()
// PushSyncConfig is very simple but uses vuex to push data
store.pushSyncConfig = () => {
/* no-op */
}
store.cache = {
_timestamp: Date.now(),
_version: VERSION,
...cloneDeep(defaultState),
}
await store.initSyncConfig({
...user,
storage: {
_timestamp: 123,
_version: VERSION,
flagStorage: {
...defaultState.flagStorage,
updateCounter: 1,
},
prefsStorage: {
...defaultState.prefsStorage,
},
},
})
expect(store.flagStorage).to.eql({
...defaultState.flagStorage,
updateCounter: 1,
})
})
it('should trim journal to 500 entries', async () => {
const store = useSyncConfigStore()
// PushSyncConfig is very simple but uses vuex to push data
store.pushSyncConfig = () => {
/* no-op */
}
store.cache = {
_timestamp: Date.now(),
_version: VERSION,
...cloneDeep(defaultState),
}
const largeJournal = []
for (let value = 0; value < 1000; value++) {
largeJournal.push({
path: 'simple.palette' + value,
operation: 'set',
args: [value],
// should have A timestamp, we don't really care what it is
timestamp: 123456,
})
}
await store.initSyncConfig({
...user,
storage: {
_timestamp: 123,
_version: VERSION,
flagStorage: {
...defaultState.flagStorage,
updateCounter: 1,
},
prefsStorage: {
...defaultState.prefsStorage,
_journal: largeJournal,
},
},
})
- expect(store.prefsStorage._journal.length).to.eql(500)
+ expect(store.prefsStorage._journal).to.have.length(500)
})
it('should reset local timestamp to remote if contents are the same', async () => {
const store = useSyncConfigStore()
store.cache = null
// PushSyncConfig is very simple but uses vuex to push data
store.pushSyncConfig = () => {
/* no-op */
}
await store.initSyncConfig({
...user,
storage: {
_timestamp: 123,
_version: VERSION,
flagStorage: {
...defaultState.flagStorage,
updateCounter: 999,
},
},
})
expect(store.cache._timestamp).to.eql(123)
expect(store.flagStorage.updateCounter).to.eql(999)
expect(store.cache.flagStorage.updateCounter).to.eql(999)
})
it('should use remote version if local missing', async () => {
const store = useSyncConfigStore()
// PushSyncConfig is very simple but uses vuex to push data
store.pushSyncConfig = () => {
/* no-op */
}
await store.initSyncConfig(store, user)
expect(store.cache._version).to.eql(VERSION)
expect(store.cache._timestamp).to.be.a('number')
expect(store.cache.flagStorage).to.eql(defaultState.flagStorage)
})
})
describe('setPreference', () => {
it('should set preference and update journal log accordingly', () => {
const store = useSyncConfigStore()
// PushSyncConfig is very simple but uses vuex to push data
store.pushSyncConfig = () => {
/* no-op */
}
store.setPreference({ path: 'simple.palette', value: '1' })
expect(store.prefsStorage.simple.palette).to.eql('1')
- expect(store.prefsStorage._journal.length).to.eql(1)
+ expect(store.prefsStorage._journal).to.have.length(1)
expect(store.prefsStorage._journal[0]).to.eql({
path: 'simple.palette',
operation: 'set',
args: ['1'],
// should have A timestamp, we don't really care what it is
timestamp: store.prefsStorage._journal[0].timestamp,
})
})
it('should keep journal to a minimum', () => {
const store = useSyncConfigStore()
// PushSyncConfig is very simple but uses vuex to push data
store.pushSyncConfig = () => {
/* no-op */
}
store.setPreference({ path: 'simple.palette', value: 1 })
store.setPreference({ path: 'simple.palette', value: 2 })
store.addCollectionPreference({ path: 'collections.palette', value: 2 })
store.removeCollectionPreference({
path: 'collections.palette',
value: 2,
})
store.updateCache({ username: 'test' })
expect(store.prefsStorage.simple.palette).to.eql(2)
expect(store.prefsStorage.collections.palette).to.eql([])
- expect(store.prefsStorage._journal.length).to.eql(2)
+ expect(store.prefsStorage._journal).to.have.length(2)
expect(store.prefsStorage._journal[0]).to.eql({
path: 'simple.palette',
operation: 'set',
args: [2],
// should have A timestamp, we don't really care what it is
timestamp: store.prefsStorage._journal[0].timestamp,
})
expect(store.prefsStorage._journal[1]).to.eql({
path: 'collections.palette',
operation: 'removeFromCollection',
args: [2],
// should have A timestamp, we don't really care what it is
timestamp: store.prefsStorage._journal[1].timestamp,
})
})
it('should remove duplicate entries from journal', () => {
const store = useSyncConfigStore()
// PushSyncConfig is very simple but uses vuex to push data
store.pushSyncConfig = () => {
/* no-op */
}
store.setPreference({ path: 'simple.palette', value: 1 })
store.setPreference({ path: 'simple.palette', value: 1 })
store.addCollectionPreference({ path: 'collections.palette', value: 2 })
store.addCollectionPreference({ path: 'collections.palette', value: 2 })
store.updateCache({ username: 'test' })
expect(store.prefsStorage.simple.palette).to.eql(1)
expect(store.prefsStorage.collections.palette).to.eql([2])
- expect(store.prefsStorage._journal.length).to.eql(2)
+ expect(store.prefsStorage._journal).to.have.length(2)
})
// TODO We need a proper test for object-based stores
it.skip('should remove depth = 3 set/unset entries from journal', () => {
const store = useSyncConfigStore()
// PushSyncConfig is very simple but uses vuex to push data
store.pushSyncConfig = () => {
/* no-op */
}
store.setPreference({ path: 'simple.fontInput', value: 'test' })
store.unsetPreference({ path: 'simple.fontInput' })
store.updateCache(store, { username: 'test' })
expect(store.prefsStorage.simple.fontInput).to.not.have.property(
'family',
)
- expect(store.prefsStorage._journal.length).to.eql(1)
+ expect(store.prefsStorage._journal).to.have.length(1)
})
it('should not allow unsetting depth <= 2', () => {
const store = useSyncConfigStore()
// PushSyncConfig is very simple but uses vuex to push data
store.pushSyncConfig = () => {
/* no-op */
}
store.setPreference({ path: 'simple.object.foo', value: 1 })
expect(() => store.unsetPreference({ path: 'simple' })).to.throw()
expect(() =>
store.unsetPreference({ path: 'simple.object' }),
).to.throw()
})
it('should not allow (un)setting depth > 3', () => {
const store = useSyncConfigStore()
// PushSyncConfig is very simple but uses vuex to push data
store.pushSyncConfig = () => {
/* no-op */
}
store.setPreference({ path: 'simple.object', value: {} })
expect(() =>
store.setPreference({ path: 'simple.object.lv3', value: 1 }),
).to.not.throw()
expect(() =>
store.setPreference({ path: 'simple.object.lv3.lv4', value: 1 }),
).to.throw()
expect(() =>
store.unsetPreference({ path: 'simple.object.lv3', value: 1 }),
).to.not.throw()
expect(() =>
store.unsetPreference({ path: 'simple.object.lv3.lv4', value: 1 }),
).to.throw()
})
})
})
describe('helper functions', () => {
describe('_moveItemInArray', () => {
it('should move item according to movement value', () => {
expect(_moveItemInArray([1, 2, 3, 4], 4, -1)).to.eql([1, 2, 4, 3])
expect(_moveItemInArray([1, 2, 3, 4], 1, 2)).to.eql([2, 3, 1, 4])
})
it('should clamp movement to within array', () => {
expect(_moveItemInArray([1, 2, 3, 4], 4, -10)).to.eql([4, 1, 2, 3])
expect(_moveItemInArray([1, 2, 3, 4], 3, 99)).to.eql([1, 2, 4, 3])
})
})
describe('_getRecentData', () => {
it('should handle nulls correctly', () => {
expect(_getRecentData(null, null, true)).to.eql({
recent: null,
stale: null,
needUpload: true,
})
})
it("doesn't choke on invalid data", () => {
expect(_getRecentData({ a: 1 }, { b: 2 }, true)).to.eql({
recent: null,
stale: null,
needUpload: true,
})
})
it('should prefer the valid non-null correctly, needUpload works properly', () => {
const nonNull = { _version: VERSION, _timestamp: 1 }
expect(_getRecentData(nonNull, null, true)).to.eql({
recent: nonNull,
stale: null,
needUpload: true,
})
expect(_getRecentData(null, nonNull, true)).to.eql({
recent: nonNull,
stale: null,
needUpload: false,
})
})
it('should prefer the one with higher timestamp', () => {
const a = { _version: VERSION, _timestamp: 1 }
const b = { _version: VERSION, _timestamp: 2 }
expect(_getRecentData(a, b, true)).to.eql({
recent: b,
stale: a,
needUpload: false,
})
expect(_getRecentData(b, a, true)).to.eql({
recent: b,
stale: a,
needUpload: false,
})
})
it('case where both are same', () => {
const a = { _version: VERSION, _timestamp: 3 }
const b = { _version: VERSION, _timestamp: 3 }
expect(_getRecentData(a, b, true)).to.eql({
recent: b,
stale: a,
needUpload: false,
})
expect(_getRecentData(b, a, true)).to.eql({
recent: b,
stale: a,
needUpload: false,
})
})
})
describe('_getAllFlags', () => {
it('should handle nulls properly', () => {
expect(_getAllFlags(null, null)).to.eql([])
})
it('should output list of keys if passed single object', () => {
expect(
_getAllFlags({ flagStorage: { a: 1, b: 1, c: 1 } }, null),
).to.eql(['a', 'b', 'c'])
})
it('should union keys of both objects', () => {
expect(
_getAllFlags(
{ flagStorage: { a: 1, b: 1, c: 1 } },
{ flagStorage: { c: 1, d: 1 } },
),
).to.eql(['a', 'b', 'c', 'd'])
})
})
describe('_mergeFlags', () => {
it('should handle merge two flag sets correctly picking higher numbers', () => {
expect(
_mergeFlags(
{ flagStorage: { a: 0, b: 3 } },
{ flagStorage: { b: 1, c: 4, d: 9 } },
['a', 'b', 'c', 'd'],
),
).to.eql({ a: 0, b: 3, c: 4, d: 9 })
})
})
describe('_mergePrefs', () => {
it('should prefer recent and apply journal to it', () => {
expect(
_mergePrefs(
// RECENT
{
simple: { theme: '1', style: '0', hideISP: true },
_journal: [
{
path: 'simple.style',
operation: 'set',
args: ['0'],
timestamp: 2,
},
{
path: 'simple.hideISP',
operation: 'set',
args: [true],
timestamp: 4,
},
],
},
// STALE
{
simple: { theme: '1', style: '1', hideISP: false },
_journal: [
{
path: 'simple.theme',
operation: 'set',
args: ['1'],
timestamp: 1,
},
{
path: 'simple.style',
operation: 'set',
args: ['1'],
timestamp: 3,
},
],
},
),
).to.eql({
simple: { theme: '1', style: '1', hideISP: true },
_journal: [
{
path: 'simple.theme',
operation: 'set',
args: ['1'],
timestamp: 1,
},
{
path: 'simple.style',
operation: 'set',
args: ['1'],
timestamp: 3,
},
{
path: 'simple.hideISP',
operation: 'set',
args: [true],
timestamp: 4,
},
],
})
})
it('should allow setting falsy values', () => {
expect(
_mergePrefs(
// RECENT
{
simple: { theme: '1', style: '0', hideISP: false },
_journal: [
{
path: 'simple.style',
operation: 'set',
args: ['0'],
timestamp: 2,
},
{
path: 'simple.hideISP',
operation: 'set',
args: [false],
timestamp: 4,
},
],
},
// STALE
{
simple: { theme: '0', style: '0', hideISP: true },
_journal: [
{
path: 'simple.theme',
operation: 'set',
args: ['0'],
timestamp: 1,
},
{
path: 'simple.style',
operation: 'set',
args: ['0'],
timestamp: 3,
},
],
},
),
).to.eql({
simple: { theme: '0', style: '0', hideISP: false },
_journal: [
{
path: 'simple.theme',
operation: 'set',
args: ['0'],
timestamp: 1,
},
{
path: 'simple.style',
operation: 'set',
args: ['0'],
timestamp: 3,
},
{
path: 'simple.hideISP',
operation: 'set',
args: [false],
timestamp: 4,
},
],
})
})
it('should work with strings', () => {
expect(
_mergePrefs(
// RECENT
{
simple: { theme: 'foo' },
_journal: [
{
path: 'simple.theme',
operation: 'set',
args: ['foo'],
timestamp: 2,
},
],
},
// STALE
{
simple: { theme: 'bar' },
_journal: [
{
path: 'simple.theme',
operation: 'set',
args: ['bar'],
timestamp: 4,
},
],
},
),
).to.eql({
simple: { theme: 'bar' },
_journal: [
{
path: 'simple.theme',
operation: 'set',
args: ['bar'],
timestamp: 4,
},
],
})
})
it('should work with objects', () => {
expect(
_mergePrefs(
// RECENT
{
simple: { fontInput: { lv3: 'foo' } },
_journal: [
{
path: 'simple.fontInput.lv3',
operation: 'set',
args: ['foo'],
timestamp: 2,
},
],
},
// STALE
{
simple: { fontInput: { lv3: 'bar' } },
_journal: [
{
path: 'simple.fontInput.lv3',
operation: 'set',
args: ['bar'],
timestamp: 4,
},
],
},
),
).to.eql({
simple: { fontInput: { lv3: 'bar' } },
_journal: [
{
path: 'simple.fontInput.lv3',
operation: 'set',
args: ['bar'],
timestamp: 4,
},
],
})
})
it('should work with unset', () => {
expect(
_mergePrefs(
// RECENT
{
simple: { fontInput: { lv3: 'foo' } },
_journal: [
{
path: 'simple.fontInput.lv3',
operation: 'set',
args: ['foo'],
timestamp: 2,
},
],
},
// STALE
{
simple: { fontInput: {} },
_journal: [
{
path: 'simple.fontInput.lv3',
operation: 'unset',
args: [],
timestamp: 4,
},
],
},
),
).to.eql({
simple: { fontInput: {} },
_journal: [
{
path: 'simple.fontInput.lv3',
operation: 'unset',
args: [],
timestamp: 4,
},
],
})
})
})
describe('_resetFlags', () => {
it('should trim all flags to known when reset is set to 1000', () => {
const totalFlags = { a: 0, b: 3, c: 33, reset: COMMAND_TRIM_FLAGS }
expect(_resetFlags(totalFlags, { a: 0, b: 0, reset: 0 })).to.eql({
a: 0,
b: 3,
reset: 0,
})
})
it('should trim all flags to known and reset when reset is set to 1001', () => {
const totalFlags = {
a: 0,
b: 3,
c: 33,
reset: COMMAND_TRIM_FLAGS_AND_RESET,
}
expect(_resetFlags(totalFlags, { a: 0, b: 0, reset: 0 })).to.eql({
a: 0,
b: 0,
reset: 0,
})
})
})
})
})
diff --git a/test/unit/specs/stores/user_highlight.spec.js b/test/unit/specs/stores/user_highlight.spec.js
index f80143b6e0..e97f8f3826 100644
--- a/test/unit/specs/stores/user_highlight.spec.js
+++ b/test/unit/specs/stores/user_highlight.spec.js
@@ -1,314 +1,314 @@
import { createPinia, setActivePinia } from 'pinia'
import {
_getRecentData,
_mergeHighlights,
useUserHighlightStore,
} from 'src/stores/user_highlight.js'
describe('The UserHighlight store', () => {
beforeEach(() => {
setActivePinia(createPinia())
window.vuex = {
state: {
users: {
currentUser: {
fqn: 'foo@bar.tld',
},
},
},
}
})
describe('mutations', () => {
describe('initUserHighlight', () => {
const user = {
created_at: new Date('1999-02-09'),
storage: {},
}
it('should initialize storage if none present', async () => {
const store = useUserHighlightStore()
await store.initUserHighlight({ ...user })
expect(store.cache._timestamp).to.be.a('number')
expect(store.cache.highlight).to.eql({ _journal: [] })
})
it('should initialize storage for new users if none present', async () => {
const store = useUserHighlightStore()
await store.initUserHighlight({ ...user, created_at: new Date() })
expect(store.cache._timestamp).to.be.a('number')
expect(store.cache.highlight).to.eql({ _journal: [] })
})
it('should use remote version if local missing', async () => {
const store = useUserHighlightStore()
await store.initUserHighlight(store, user)
expect(store.cache._timestamp).to.be.a('number')
})
})
describe('set', () => {
it('should set preference and update journal log accordingly', () => {
const store = useUserHighlightStore()
store.set({ user: 'highlight@testing', value: { type: 'test' } })
expect(store.highlight['highlight@testing']).to.eql({
user: 'highlight@testing',
type: 'test',
})
- expect(store.highlight._journal.length).to.eql(1)
+ expect(store.highlight._journal).to.have.length(1)
expect(store.highlight._journal[0]).to.eql({
user: 'highlight@testing',
operation: 'set',
args: [{ user: 'highlight@testing', type: 'test' }],
// should have A timestamp, we don't really care what it is
timestamp: store.highlight._journal[0].timestamp,
})
})
it('should keep journal to a minimum', () => {
const store = useUserHighlightStore()
store.set({ user: 'highlight@testing.xyz', value: { type: 'test' } })
store.set({ user: 'highlight@testing.xyz', value: { type: 'test' } })
store.updateCache({ username: 'test' })
expect(store.highlight['highlight@testing.xyz']).to.eql({
user: 'highlight@testing.xyz',
type: 'test',
})
- expect(store.highlight._journal.length).to.eql(1)
+ expect(store.highlight._journal).to.have.length(1)
expect(store.highlight._journal[0]).to.eql({
user: 'highlight@testing.xyz',
operation: 'set',
args: [
{
user: 'highlight@testing.xyz',
type: 'test',
},
],
// should have A timestamp, we don't really care what it is
timestamp: store.highlight._journal[0].timestamp,
})
})
it('should remove duplicate entries from journal', () => {
const store = useUserHighlightStore()
store.set({ user: 'a@test.xyz', value: { type: 'foo' } })
store.set({ user: 'a@test.xyz', value: { type: 'foo' } })
store.updateCache({ username: 'test' })
expect(store.highlight['a@test.xyz']).to.eql({
user: 'a@test.xyz',
type: 'foo',
})
- expect(store.highlight._journal.length).to.eql(1)
+ expect(store.highlight._journal).to.have.length(1)
})
})
})
describe('helper functions', () => {
describe('_getRecentData', () => {
it('should handle nulls correctly', () => {
expect(_getRecentData(null, null, true)).to.eql({
recent: null,
stale: null,
needUpload: true,
})
})
it("doesn't choke on invalid data", () => {
expect(_getRecentData({ a: 1 }, { b: 2 }, true)).to.eql({
recent: null,
stale: null,
needUpload: true,
})
})
it('should prefer the valid non-null correctly, needUpload works properly', () => {
const nonNull = { _timestamp: 1 }
expect(_getRecentData(nonNull, null, true)).to.eql({
recent: nonNull,
stale: null,
needUpload: true,
})
expect(_getRecentData(null, nonNull, true)).to.eql({
recent: nonNull,
stale: null,
needUpload: false,
})
})
it('should prefer the one with higher timestamp', () => {
const a = { _timestamp: 1 }
const b = { _timestamp: 2 }
expect(_getRecentData(a, b, true)).to.eql({
recent: b,
stale: a,
needUpload: false,
})
expect(_getRecentData(b, a, true)).to.eql({
recent: b,
stale: a,
needUpload: false,
})
})
it('case where both are same', () => {
const a = { _timestamp: 3 }
const b = { _timestamp: 3 }
expect(_getRecentData(a, b, true)).to.eql({
recent: b,
stale: a,
needUpload: false,
})
expect(_getRecentData(b, a, true)).to.eql({
recent: b,
stale: a,
needUpload: false,
})
})
})
describe('_mergeHighlights', () => {
it('should prefer recent and apply journal to it', () => {
expect(
_mergeHighlights(
{
// RECENT
'a@test.xyz': 1,
'b@test.xyz': 0,
'c@test.xyz': true,
_journal: [
{
user: 'b@test.xyz',
operation: 'set',
args: [0],
timestamp: 2,
},
{
user: 'c@test.xyz',
operation: 'set',
args: [true],
timestamp: 4,
},
],
},
{
// STALE
'a@test.xyz': 1,
'b@test.xyz': 1,
'c@test.xyz': false,
_journal: [
{
user: 'a@test.xyz',
operation: 'set',
args: [1],
timestamp: 1,
},
{
user: 'b@test.xyz',
operation: 'set',
args: [1],
timestamp: 3,
},
],
},
),
).to.eql({
'a@test.xyz': 1,
'b@test.xyz': 1,
'c@test.xyz': true,
_journal: [
{ user: 'a@test.xyz', operation: 'set', args: [1], timestamp: 1 },
{ user: 'b@test.xyz', operation: 'set', args: [1], timestamp: 3 },
{
user: 'c@test.xyz',
operation: 'set',
args: [true],
timestamp: 4,
},
],
})
})
it('should work with objects', () => {
expect(
_mergeHighlights(
// RECENT
{
'a@test.xyz': { type: 'foo' },
_journal: [
{
user: 'a@test.xyz',
operation: 'set',
args: [{ type: 'foo' }],
timestamp: 2,
},
],
},
// STALE
{
'a@test.xyz': { type: 'bar' },
_journal: [
{
user: 'a@test.xyz',
operation: 'set',
args: [{ type: 'bar' }],
timestamp: 4,
},
],
},
),
).to.eql({
'a@test.xyz': { type: 'bar' },
_journal: [
{
user: 'a@test.xyz',
operation: 'set',
args: [{ type: 'bar' }],
timestamp: 4,
},
],
})
})
it('should work with unset', () => {
expect(
_mergeHighlights(
// RECENT
{
'a@test.xyz': { type: 'foo' },
_journal: [
{
user: 'a@test.xyz',
operation: 'set',
args: [{ type: 'foo' }],
timestamp: 2,
},
],
},
// STALE
{
_journal: [
{
user: 'a@test.xyz',
operation: 'unset',
args: [],
timestamp: 4,
},
],
},
),
).to.eql({
_journal: [
{
user: 'a@test.xyz',
operation: 'unset',
args: [],
timestamp: 4,
},
],
})
})
})
})
})
diff --git a/tools/emoji_merger.js b/tools/emoji_merger.js
index b49ead4716..1e37134a7a 100644
--- a/tools/emoji_merger.js
+++ b/tools/emoji_merger.js
@@ -1,74 +1,74 @@
/*
Emoji merger script, quick hack of a tool to:
- update some missing emoji from an external source
- sort the emoji
- remove all multipart emoji (reactions don't allow them)
Merges emoji from here: https://gist.github.com/oliveratgithub/0bf11a9aff0d6da7b46f1490f86a71eb
to the simpler format we're using.
*/
// Existing emojis we have
const oldEmojiFilename = '../src/assets/emoji.json'
// The file downloaded from https://gist.github.com/oliveratgithub/0bf11a9aff0d6da7b46f1490f86a71eb
const newEmojiFilename = 'emojis.json'
// Output, replace the static/emoji.json with this file if it looks correct
const outputFilename = 'output.json'
const run = () => {
const fs = require('fs')
let newEmojisObject = {}
let emojisObject = {}
let data = fs.readFileSync(newEmojiFilename, 'utf8')
// First filter out anything that's more than one codepoint
const newEmojis = JSON.parse(data).emojis.filter((e) => e.emoji.length <= 2)
// Create a table with format { shortname: emoji }, remove the :
newEmojis.forEach((e) => {
const name = e.shortname.slice(1, e.shortname.length - 1).toLowerCase()
if (name.length > 0) {
newEmojisObject[name] = e.emoji
}
})
data = fs.readFileSync(oldEmojiFilename, 'utf8')
emojisObject = JSON.parse(data)
// Get rid of longer emojis that don't play nice with reactions
Object.keys(emojisObject).forEach((e) => {
if (emojisObject[e].length > 2) emojisObject[e] = undefined
})
// Add new emojis from the new tables to the old table
Object.keys(newEmojisObject).forEach((e) => {
if (!emojisObject[e] && newEmojisObject[e].length <= 2) {
emojisObject[e] = newEmojisObject[e]
}
})
// Sort by key
const sorted = Object.keys(emojisObject)
- .sort()
+ .sort((a, b) => a.localeCompare(b))
.reduce((acc, key) => {
if (key.length === 0) return acc
acc[key] = emojisObject[key]
return acc
}, {})
fs.writeFile(
outputFilename,
JSON.stringify(sorted, null, 2),
'utf8',
(err) => {
if (err) console.error('Error writing file', err)
},
)
}
run()

File Metadata

Mime Type
text/x-diff
Expires
Sat, Sep 19, 6:24 AM (1 d, 10 h)
Storage Engine
blob
Storage Format
Raw Data
Storage Handle
1769026
Default Alt Text
(1005 KB)

Event Timeline