Merge remote-tracking branch 'origin/develop' into admin-users

This commit is contained in:
Henry Jameson 2026-06-08 00:57:42 +03:00
commit 43936a8725
628 changed files with 72639 additions and 24537 deletions

12
.dockerignore Normal file
View file

@ -0,0 +1,12 @@
node_modules/
dist/
logs/
.DS_Store
.git/
config/local.json
pleroma-backend/
test/e2e/reports/
test/e2e-playwright/test-results/
test/e2e-playwright/playwright-report/
__screenshots__/

View file

@ -0,0 +1,87 @@
name: 'Bug report'
about: 'Bug report for Pleroma FE'
labels:
- Bug
body:
- type: input
id: env-browser
attributes:
label: Browser and OS
description: What browser are you using, including version, and what OS are you running?
placeholder: Firefox 140, Arch Linux
validations:
required: true
- type: input
id: env-instance
attributes:
label: Instance URL
validations:
required: false
- type: input
id: env-backend
attributes:
label: Backend version information
description: Backend version being used. (See Settings->Show advanced->Developer)
placeholder: Pleroma BE 2.10
validations:
required: true
- type: input
id: env-frontend
attributes:
label: Frontend version information
description: Frontend version being used. (See Settings->Show advanced->Developer)
placeholder: Pleroma FE 2.10
validations:
required: true
- type: input
id: env-extensions
attributes:
label: Browser extensions
description: List of browser extensions you are using, like uBlock, rikaichamp etc. If none leave empty.
validations:
required: false
- type: input
id: env-modifications
attributes:
label: Known instance/user customizations
description: Whether you are using a Pleroma FE fork, any mods mods or instance level styles among others.
validations:
required: false
- type: textarea
id: bug-text
attributes:
label: Bug description
description: A short description of the bug. Images can be helpful.
validations:
required: true
- type: textarea
id: bug-reproducer
attributes:
label: Reproduction steps
description: Ordered list of reproduction steps needed to make the bug happen. If you don't have reproduction steps, leave this empty.
placeholder: |
1. Log in with a fresh browser session
2. Open timeline X
3. Click on button Y
4. Z broke
validations:
required: false
- type: textarea
id: bug-seriousness
attributes:
label: Bug seriousness
value: |
* How annoying it is:
* How often does it happen:
* How many people does it affect:
* Is there a workaround for it:
- type: checkboxes
id: duplicate-issues
attributes:
label: Duplicate issues
hide_label: true
description: Before submitting this issue, search for same or similar issues on the [Pleroma FE bug tracker](https://git.pleroma.social/pleroma/pleroma-fe/issues).
options:
- label: I've searched for same or similar issues before submitting this issue.
required: true
visible: [form]

View file

@ -0,0 +1,22 @@
name: 'Feature request / Suggestion / Improvement'
about: 'Feature requests, suggestions and improvements for Pleroma FE'
labels:
- Feature Request / Enhancement
body:
- type: textarea
id: issue-text
attributes:
label: Proposal
placeholder: Make groups happen!
validations:
required: true
- type: checkboxes
id: duplicate-issues
attributes:
label: Duplicate issues
hide_label: true
description: Before submitting this issue, search for same or similar requests on the [Pleroma FE bug tracker](https://git.pleroma.social/pleroma/pleroma-fe/issues).
options:
- label: I've searched for same or similar requests before submitting this issue.
required: true
visible: [form]

View file

@ -0,0 +1,12 @@
### Checklist
- [ ] Adding a changelog: In the `changelog.d` directory, create a file named `<code>.<type>`.
<!--
`<code>` can be anything, but we recommend using a more or less unique identifier to avoid collisions, such as the branch name.
`<type>` can be `add`, `change`, `remove`, `fix`, `security` or `skip`. `skip` is only used if there is no user-visible change in the MR (for example, only editing comments in the code). Otherwise, choose a type that corresponds to your change.
In the file, write the changelog entry. For example, if an MR adds group functionality, we can create a file named `group.add` and write `Add group functionality` in it.
If one changelog entry is not enough, you may add more. But that might mean you can split it into two MRs. Only use more than one changelog entry if you really need to (for example, when one change in the code fix two different bugs, or when refactoring).
-->

3
.gitignore vendored
View file

@ -4,8 +4,11 @@ dist/
npm-debug.log npm-debug.log
test/unit/coverage test/unit/coverage
test/e2e/reports test/e2e/reports
test/e2e-playwright/test-results
test/e2e-playwright/playwright-report
selenium-debug.log selenium-debug.log
.idea/ .idea/
.gitlab-ci-local/
config/local.json config/local.json
src/assets/emoji.json src/assets/emoji.json
logs/ logs/

View file

@ -1,7 +1,7 @@
# This file is a template, and might need editing before it works on your project. # This file is a template, and might need editing before it works on your project.
# Official framework image. Look for the different tagged releases at: # Official framework image. Look for the different tagged releases at:
# https://hub.docker.com/r/library/node/tags/ # https://hub.docker.com/r/library/node/tags/
image: node:18 image: node:20
stages: stages:
- check-changelog - check-changelog
@ -34,12 +34,23 @@ check-changelog:
- apk add git - apk add git
- sh ./tools/check-changelog - sh ./tools/check-changelog
lint: lint-eslint:
stage: lint stage: lint
script: script:
- yarn - yarn
- yarn lint - yarn ci-eslint
- yarn stylelint
lint-biome:
stage: lint
script:
- yarn
- yarn ci-biome
lint-stylelint:
stage: lint
script:
- yarn
- yarn ci-stylelint
test: test:
stage: test stage: test
@ -60,6 +71,135 @@ test:
- test/**/__screenshots__ - test/**/__screenshots__
when: on_failure when: on_failure
e2e-pleroma:
stage: test
image: mcr.microsoft.com/playwright:v1.57.0-jammy
services:
- name: postgres:15-alpine
alias: db
- name: $PLEROMA_IMAGE
alias: pleroma
entrypoint: ["/bin/ash", "-c"]
command:
- |
set -eu
SEED_SENTINEL_PATH=/var/lib/pleroma/.e2e_seeded
CONFIG_OVERRIDE_PATH=/var/lib/pleroma/config.exs
echo '-- Waiting for database...'
while ! pg_isready -U ${DB_USER:-pleroma} -d postgres://${DB_HOST:-db}:${DB_PORT:-5432}/${DB_NAME:-pleroma} -t 1; do
sleep 1s
done
echo '-- Writing E2E config overrides...'
cat > $CONFIG_OVERRIDE_PATH <<EOF
import Config
config :pleroma, Pleroma.Captcha,
enabled: false
config :pleroma, :instance,
registrations_open: true,
account_activation_required: false,
approval_required: false
EOF
echo '-- Running migrations...'
/opt/pleroma/bin/pleroma_ctl migrate
echo '-- Starting!'
/opt/pleroma/bin/pleroma start &
PLEROMA_PID=$!
cleanup() {
if kill -0 $PLEROMA_PID 2>/dev/null; then
kill -TERM $PLEROMA_PID
wait $PLEROMA_PID || true
fi
}
trap cleanup INT TERM
echo '-- Waiting for API...'
api_ok=false
for _i in $(seq 1 120); do
if wget -qO- http://127.0.0.1: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 [ ! -f $SEED_SENTINEL_PATH ]; then
if [ -n ${E2E_ADMIN_USERNAME:-} ] && [ -n ${E2E_ADMIN_PASSWORD:-} ] && [ -n ${E2E_ADMIN_EMAIL:-} ]; then
echo '-- Seeding admin user' $E2E_ADMIN_USERNAME '...'
if ! /opt/pleroma/bin/pleroma_ctl user new $E2E_ADMIN_USERNAME $E2E_ADMIN_EMAIL --admin --password $E2E_ADMIN_PASSWORD -y; then
echo '-- User already exists or creation failed, ensuring admin + confirmed...'
/opt/pleroma/bin/pleroma_ctl user set $E2E_ADMIN_USERNAME --admin --confirmed
fi
else
echo '-- Skipping admin seeding (missing E2E_ADMIN_* env)'
fi
touch $SEED_SENTINEL_PATH
fi
wait $PLEROMA_PID
tags:
- amd64
- himem
variables:
PLAYWRIGHT_SKIP_BROWSER_DOWNLOAD: "1"
FF_NETWORK_PER_BUILD: "true"
PLEROMA_IMAGE: git.pleroma.social:5050/pleroma/pleroma:stable
POSTGRES_USER: pleroma
POSTGRES_PASSWORD: pleroma
POSTGRES_DB: pleroma
DB_USER: pleroma
DB_PASS: pleroma
DB_NAME: pleroma
DB_HOST: db
DB_PORT: 5432
DOMAIN: localhost
INSTANCE_NAME: Pleroma E2E
E2E_ADMIN_USERNAME: admin
E2E_ADMIN_PASSWORD: adminadmin
E2E_ADMIN_EMAIL: admin@example.com
ADMIN_EMAIL: $E2E_ADMIN_EMAIL
NOTIFY_EMAIL: $E2E_ADMIN_EMAIL
VITE_PROXY_TARGET: http://pleroma:4000
VITE_PROXY_ORIGIN: http://localhost:4000
E2E_BASE_URL: http://localhost:8080
script:
- 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
- yarn e2e:pw
artifacts:
when: on_failure
paths:
- test/e2e-playwright/test-results
- test/e2e-playwright/playwright-report
build: build:
stage: build stage: build
tags: tags:

View file

@ -1 +1 @@
18.20.8 20.19.0

43
.woodpecker/build.yaml Normal file
View file

@ -0,0 +1,43 @@
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

View file

@ -0,0 +1,10 @@
when:
- event: pull_request
evaluate: 'CI_COMMIT_SOURCE_BRANCH != "weblate" && not(CI_COMMIT_SOURCE_BRANCH startsWith "renovate/")'
steps:
check-changelog:
image: docker.io/alpine:3.23
commands:
- apk add --no-cache git
- sh ./tools/check-changelog

32
.woodpecker/lint.yaml Normal file
View file

@ -0,0 +1,32 @@
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}
steps:
install-depends:
image: &node-image
docker.io/node:20-alpine
commands:
- yarn --frozen-lockfile
eslint:
image: *node-image
depends_on: install-depends
commands:
- yarn ci-eslint
biome:
image: *node-image
depends_on: install-depends
commands:
- yarn ci-biome
stylelint:
image: *node-image
depends_on: install-depends
commands:
- yarn ci-stylelint

101
.woodpecker/test-e2e.yaml Normal file
View file

@ -0,0 +1,101 @@
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'
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.57.0-jammy
entrypoint: *script_file_entrypoint
environment:
APT_CACHE_DIR: apt-cache
DEBIAN_FRONTEND: noninteractive
E2E_BASE_URL: http://localhost:8080
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"

61
.woodpecker/test.yaml Normal file
View file

@ -0,0 +1,61 @@
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'
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.57.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
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

View file

@ -3,6 +3,40 @@ All notable changes to this project will be documented in this file.
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/). The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/).
## 2.10.1
### Fixed
- fixed being unable to set actor type from profile page
- fixed error when clicking mute menu itself (instead of submenu items)
- fixed mute -> domain status submenu not working
### Internal
- Add playwright E2E-tests with an optional docker-based backend
## 2.10.0
### Changed
- Temporary changes modal now shows actual countdown instead of fixed timeout
- Disabled elements are more disabled now
- Rearranged and split settings to make more sense and be less of a wall of text
- On mobile settings now take up full width and presented in navigation style
improved styles for settings
### Added
- Most of the remaining AdminFE tabs were added into Admin Dashboard
- It's now possible to customize PWA Manfiest from PleromaFE
- Make every configuration option default-overridable by instance admins
### Fixed
- Fixed settings not appearing if user never touched "show advanced" toggle
- Fix display of the broken/deleted/banned users
- Fixed incorrect emoji display in post interaction lists
- Fixed list title not being saved when editing
- Fixed poll notifications not being expandable
## 2.9.3
### Fixed
- Being unable to update profile
## 2.9.2 ## 2.9.2
### Changed ### Changed
- BREAKING: due to some internal technical changes logging into AdminFE through PleromaFE is no longer possible - BREAKING: due to some internal technical changes logging into AdminFE through PleromaFE is no longer possible

View file

@ -6,7 +6,7 @@
# For Translators # 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/blob/develop/src/i18n/) folder and added in a list within [src/i18n/languages.js](https://git.pleroma.social/pleroma/pleroma-fe/blob/develop/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. Pleroma-FE will set your language by your browser locale, but you can change language in settings.
@ -32,10 +32,10 @@ yarn unit
# For Contributors: # For Contributors:
You can create file `/config/local.json` (see [example](https://git.pleroma.social/pleroma/pleroma-fe/blob/develop/config/local.example.json)) to enable some convenience dev options: 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. * `target`: makes local dev server redirect to some existing instance's BE instead of local BE, useful for testing things in near-production environment and searching for real-life use-cases.
* `staticConfigPreference`: makes FE's `/static/config.json` take preference of BE-served `/api/statusnet/config.json`. Only works in dev mode. * `staticConfigPreference`: makes FE's `/static/config.json` take preference of BE-served `/api/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. 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.

149
biome.json Normal file
View file

@ -0,0 +1,149 @@
{
"$schema": "https://biomejs.dev/schemas/2.3.11/schema.json",
"vcs": {
"enabled": true,
"clientKind": "git",
"useIgnoreFile": true
},
"files": {
"includes": ["**", "!!**/dist", "!!tools/emojis.json"]
},
"formatter": {
"enabled": true,
"indentStyle": "space"
},
"linter": {
"enabled": true,
"domains": {
"vue": "recommended"
},
"rules": {
"recommended": false,
"complexity": {
"noAdjacentSpacesInRegex": "error",
"noExtraBooleanCast": "error",
"noUselessCatch": "error",
"noUselessEscapeInRegex": "error"
},
"correctness": {
"noConstAssign": "error",
"noConstantCondition": "error",
"noEmptyCharacterClassInRegex": "error",
"noEmptyPattern": "error",
"noGlobalObjectCalls": "error",
"noInvalidBuiltinInstantiation": "error",
"noInvalidConstructorSuper": "error",
"noNonoctalDecimalEscape": "error",
"noPrecisionLoss": "error",
"noSelfAssign": "error",
"noSetterReturn": "error",
"noSwitchDeclarations": "error",
"noUndeclaredVariables": "error",
"noUnreachable": "error",
"noUnreachableSuper": "error",
"noUnsafeFinally": "error",
"noUnsafeOptionalChaining": "error",
"noUnusedLabels": "error",
"noUnusedPrivateClassMembers": "error",
"noUnusedVariables": "error",
"useIsNan": "error",
"useValidForDirection": "error",
"useValidTypeof": "error",
"useYield": "error"
},
"suspicious": {
"noAsyncPromiseExecutor": "error",
"noCatchAssign": "error",
"noClassAssign": "error",
"noCompareNegZero": "error",
"noConstantBinaryExpressions": "error",
"noControlCharactersInRegex": "error",
"noDebugger": "error",
"noDuplicateCase": "error",
"noDuplicateClassMembers": "error",
"noDuplicateElseIf": "error",
"noDuplicateObjectKeys": "error",
"noDuplicateParameters": "error",
"noEmptyBlockStatements": "error",
"noFallthroughSwitchClause": "error",
"noFunctionAssign": "error",
"noGlobalAssign": "error",
"noImportAssign": "error",
"noIrregularWhitespace": "error",
"noMisleadingCharacterClass": "error",
"noPrototypeBuiltins": "error",
"noRedeclare": "error",
"noShadowRestrictedNames": "error",
"noSparseArray": "error",
"noUnsafeNegation": "error",
"noUselessRegexBackrefs": "error",
"noWith": "error",
"useGetterReturn": "error"
}
}
},
"javascript": {
"formatter": {
"quoteStyle": "single",
"semicolons": "asNeeded"
},
"globals": []
},
"overrides": [
{
"includes": ["**/*.spec.js", "test/fixtures/*.js"],
"javascript": {
"globals": [
"vi",
"describe",
"it",
"test",
"expect",
"before",
"beforeEach",
"after",
"afterEach"
]
}
},
{
"includes": ["**/*.vue"],
"linter": {
"rules": {
"style": {
"useConst": "off",
"useImportType": "off"
},
"correctness": {
"noUnusedVariables": "off",
"noUnusedImports": "off"
}
}
}
}
],
"assist": {
"enabled": true,
"actions": {
"source": {
"organizeImports": {
"level": "on",
"options": {
"groups": [
[":NODE:", ":PACKAGE:", "!src/**", "!@fortawesome/**"],
":BLANK_LINE:",
[":PATH:", "src/components/**"],
":BLANK_LINE:",
[":PATH:", "src/stores/**"],
":BLANK_LINE:",
[":PATH:", "src/**", "src/stores/**", "src/components/**"],
":BLANK_LINE:",
"@fortawesome/fontawesome-svg-core",
"@fortawesome/*"
]
}
}
}
}
}
}

View file

@ -1,5 +1,5 @@
import semver from 'semver'
import chalk from 'chalk' import chalk from 'chalk'
import semver from 'semver'
import packageConfig from '../package.json' with { type: 'json' } import packageConfig from '../package.json' with { type: 'json' }
@ -7,8 +7,8 @@ var versionRequirements = [
{ {
name: 'node', name: 'node',
currentVersion: semver.clean(process.version), currentVersion: semver.clean(process.version),
versionRequirement: packageConfig.engines.node versionRequirement: packageConfig.engines.node,
} },
] ]
export default function () { export default function () {
@ -16,20 +16,26 @@ export default function () {
for (let i = 0; i < versionRequirements.length; i++) { for (let i = 0; i < versionRequirements.length; i++) {
const mod = versionRequirements[i] const mod = versionRequirements[i]
if (!semver.satisfies(mod.currentVersion, mod.versionRequirement)) { if (!semver.satisfies(mod.currentVersion, mod.versionRequirement)) {
warnings.push(mod.name + ': ' + warnings.push(
chalk.red(mod.currentVersion) + ' should be ' + mod.name +
chalk.green(mod.versionRequirement) ': ' +
chalk.red(mod.currentVersion) +
' should be ' +
chalk.green(mod.versionRequirement),
) )
} }
} }
if (warnings.length) { if (warnings.length) {
console.warn(chalk.yellow('\nTo use this template, you must update following to modules:\n')) console.warn(
chalk.yellow(
'\nTo use this template, you must update following to modules:\n',
),
)
for (let i = 0; i < warnings.length; i++) { for (let i = 0; i < warnings.length; i++) {
const warning = warnings[i] const warning = warnings[i]
console.warn(' ' + warning) console.warn(' ' + warning)
} }
console.warn()
process.exit(1) process.exit(1)
} }
} }

View file

@ -1,8 +1,8 @@
import childProcess from 'child_process' import childProcess from 'child_process'
export const getCommitHash = (() => { export const getCommitHash = () => {
const subst = "$Format:%h$" const subst = '$Format:%h$'
if(!subst.match(/Format:/)) { if (!subst.match(/Format:/)) {
return subst return subst
} else { } else {
try { try {
@ -15,4 +15,4 @@ export const getCommitHash = (() => {
return 'UNKNOWN' return 'UNKNOWN'
} }
} }
}) }

View file

@ -1,8 +1,8 @@
import serveStatic from 'serve-static'
import { resolve } from 'node:path'
import { cp } from 'node:fs/promises' import { cp } from 'node:fs/promises'
import { resolve } from 'node:path'
import serveStatic from 'serve-static'
const getPrefix = s => { const getPrefix = (s) => {
const padEnd = s.endsWith('/') ? s : s + '/' const padEnd = s.endsWith('/') ? s : s + '/'
return padEnd.startsWith('/') ? padEnd : '/' + padEnd return padEnd.startsWith('/') ? padEnd : '/' + padEnd
} }
@ -13,28 +13,31 @@ const copyPlugin = ({ inUrl, inFs }) => {
let copyTarget let copyTarget
const handler = serveStatic(inFs) const handler = serveStatic(inFs)
return [{ return [
name: 'copy-plugin-serve', {
apply: 'serve', name: 'copy-plugin-serve',
configureServer (server) { apply: 'serve',
server.middlewares.use(prefix, handler) configureServer(server) {
} server.middlewares.use(prefix, handler)
}, { },
name: 'copy-plugin-build',
apply: 'build',
configResolved (config) {
copyTarget = resolve(config.root, config.build.outDir, subdir)
}, },
closeBundle: { {
order: 'post', name: 'copy-plugin-build',
sequential: true, apply: 'build',
async handler () { configResolved(config) {
console.log(`Copying '${inFs}' to ${copyTarget}...`) copyTarget = resolve(config.root, config.build.outDir, subdir)
await cp(inFs, copyTarget, { recursive: true }) },
console.log('Done.') closeBundle: {
} order: 'post',
} sequential: true,
}] async handler() {
console.info(`Copying '${inFs}' to ${copyTarget}...`)
await cp(inFs, copyTarget, { recursive: true })
console.info('Done.')
},
},
},
]
} }
export default copyPlugin export default copyPlugin

View file

@ -1,21 +1,23 @@
import { resolve } from 'node:path'
import { access } from 'node:fs/promises' import { access } from 'node:fs/promises'
import { languages, langCodeToCldrName } from '../src/i18n/languages.js' import { resolve } from 'node:path'
import { languages } from '../src/i18n/languages.js'
const annotationsImportPrefix = '@kazvmoe-infra/unicode-emoji-json/annotations/' const annotationsImportPrefix = '@kazvmoe-infra/unicode-emoji-json/annotations/'
const specialAnnotationsLocale = { const specialAnnotationsLocale = {
ja_easy: 'ja' ja_easy: 'ja',
} }
const internalToAnnotationsLocale = (internal) => specialAnnotationsLocale[internal] || internal const internalToAnnotationsLocale = (internal) =>
specialAnnotationsLocale[internal] || internal
// This gets all the annotations that are accessible (whose language // This gets all the annotations that are accessible (whose language
// can be chosen in the settings). Data for other languages are // can be chosen in the settings). Data for other languages are
// discarded because there is no way for it to be fetched. // discarded because there is no way for it to be fetched.
const getAllAccessibleAnnotations = async (projectRoot) => { const getAllAccessibleAnnotations = async (projectRoot) => {
const imports = (await Promise.all( const imports = (
languages await Promise.all(
.map(async lang => { languages.map(async (lang) => {
const destLang = internalToAnnotationsLocale(lang) const destLang = internalToAnnotationsLocale(lang)
const importModule = `${annotationsImportPrefix}${destLang}.json` const importModule = `${annotationsImportPrefix}${destLang}.json`
const importFile = resolve(projectRoot, 'node_modules', importModule) const importFile = resolve(projectRoot, 'node_modules', importModule)
@ -23,11 +25,18 @@ const getAllAccessibleAnnotations = async (projectRoot) => {
await access(importFile) await access(importFile)
return `'${lang}': () => import('${importModule}')` return `'${lang}': () => import('${importModule}')`
} catch (e) { } catch (e) {
if (e.message.match(/ENOENT/)) {
console.warn(`Missing emoji annotations locale: ${destLang}`)
} else {
console.error('test', e.message)
}
return return
} }
}))) }),
.filter(k => k) )
.join(',\n') )
.filter((k) => k)
.join(',\n')
return ` return `
export const annotationsLoader = { export const annotationsLoader = {
@ -43,21 +52,21 @@ const emojisPlugin = () => {
let projectRoot let projectRoot
return { return {
name: 'emojis-plugin', name: 'emojis-plugin',
configResolved (conf) { configResolved(conf) {
projectRoot = conf.root projectRoot = conf.root
}, },
resolveId (id) { resolveId(id) {
if (id === emojiAnnotationsId) { if (id === emojiAnnotationsId) {
return emojiAnnotationsIdResolved return emojiAnnotationsIdResolved
} }
return null return null
}, },
async load (id) { async load(id) {
if (id === emojiAnnotationsIdResolved) { if (id === emojiAnnotationsIdResolved) {
return await getAllAccessibleAnnotations(projectRoot) return await getAllAccessibleAnnotations(projectRoot)
} }
return null return null
} },
} }
} }

View file

@ -1,5 +1,5 @@
import { resolve } from 'node:path'
import { readFile } from 'node:fs/promises' import { readFile } from 'node:fs/promises'
import { resolve } from 'node:path'
const target = 'node_modules/msw/lib/mockServiceWorker.js' const target = 'node_modules/msw/lib/mockServiceWorker.js'
@ -8,10 +8,10 @@ const mswPlugin = () => {
return { return {
name: 'msw-plugin', name: 'msw-plugin',
apply: 'serve', apply: 'serve',
configResolved (conf) { configResolved(conf) {
projectRoot = conf.root projectRoot = conf.root
}, },
configureServer (server) { configureServer(server) {
server.middlewares.use(async (req, res, next) => { server.middlewares.use(async (req, res, next) => {
if (req.path === '/mockServiceWorker.js') { if (req.path === '/mockServiceWorker.js') {
const file = await readFile(resolve(projectRoot, target)) const file = await readFile(resolve(projectRoot, target))
@ -21,7 +21,7 @@ const mswPlugin = () => {
next() next()
} }
}) })
} },
} }
} }

View file

@ -1,11 +1,12 @@
import { languages, langCodeToJsonName } from '../src/i18n/languages.js'
import { readFile } from 'node:fs/promises' import { readFile } from 'node:fs/promises'
import { dirname, resolve } from 'node:path' import { dirname, resolve } from 'node:path'
import { fileURLToPath } from 'node:url' import { fileURLToPath } from 'node:url'
import { langCodeToJsonName, languages } from '../src/i18n/languages.js'
const i18nDir = resolve( const i18nDir = resolve(
dirname(dirname(fileURLToPath(import.meta.url))), dirname(dirname(fileURLToPath(import.meta.url))),
'src/i18n' 'src/i18n',
) )
export const i18nFiles = languages.reduce((acc, lang) => { export const i18nFiles = languages.reduce((acc, lang) => {
@ -16,13 +17,15 @@ export const i18nFiles = languages.reduce((acc, lang) => {
}, {}) }, {})
export const generateServiceWorkerMessages = async () => { export const generateServiceWorkerMessages = async () => {
const msgArray = await Promise.all(Object.entries(i18nFiles).map(async ([lang, file]) => { const msgArray = await Promise.all(
const fileContent = await readFile(file, 'utf-8') Object.entries(i18nFiles).map(async ([lang, file]) => {
const msg = { const fileContent = await readFile(file, 'utf-8')
notifications: JSON.parse(fileContent).notifications || {} const msg = {
} notifications: JSON.parse(fileContent).notifications || {},
return [lang, msg] }
})) return [lang, msg]
}),
)
return msgArray.reduce((acc, [lang, msg]) => { return msgArray.reduce((acc, [lang, msg]) => {
acc[lang] = msg acc[lang] = msg
return acc return acc

View file

@ -1,9 +1,13 @@
import { fileURLToPath } from 'node:url'
import { dirname, resolve } from 'node:path'
import { readFile } from 'node:fs/promises' import { readFile } from 'node:fs/promises'
import { dirname, resolve } from 'node:path'
import { fileURLToPath } from 'node:url'
import { exactRegex } from '@rolldown/pluginutils'
import { build } from 'vite' import { build } from 'vite'
import * as esbuild from 'esbuild'
import { generateServiceWorkerMessages, i18nFiles } from './service_worker_messages.js' import {
generateServiceWorkerMessages,
i18nFiles,
} from './service_worker_messages.js'
const getSWMessagesAsText = async () => { const getSWMessagesAsText = async () => {
const messages = await generateServiceWorkerMessages() const messages = await generateServiceWorkerMessages()
@ -11,123 +15,24 @@ const getSWMessagesAsText = async () => {
} }
const projectRoot = dirname(dirname(fileURLToPath(import.meta.url))) const projectRoot = dirname(dirname(fileURLToPath(import.meta.url)))
const swEnvName = 'virtual:pleroma-fe/service_worker_env'
const swEnvNameResolved = '\0' + swEnvName
const getDevSwEnv = () => `self.serviceWorkerOption = { assets: [] };`
const getProdSwEnv = ({ assets }) => `self.serviceWorkerOption = { assets: ${JSON.stringify(assets)} };`
export const devSwPlugin = ({
swSrc,
swDest,
transformSW,
alias
}) => {
const swFullSrc = resolve(projectRoot, swSrc)
const esbuildAlias = {}
Object.entries(alias).forEach(([source, dest]) => {
esbuildAlias[source] = dest.startsWith('/') ? projectRoot + dest : dest
})
return {
name: 'dev-sw-plugin',
apply: 'serve',
configResolved (conf) {
},
resolveId (id) {
const name = id.startsWith('/') ? id.slice(1) : id
if (name === swDest) {
return swFullSrc
} else if (name === swEnvName) {
return swEnvNameResolved
}
return null
},
async load (id) {
if (id === swFullSrc) {
return readFile(swFullSrc, 'utf-8')
} else if (id === swEnvNameResolved) {
return getDevSwEnv()
}
return null
},
/**
* vite does not bundle the service worker
* during dev, and firefox does not support ESM as service worker
* https://bugzilla.mozilla.org/show_bug.cgi?id=1360870
*/
async transform (code, id) {
if (id === swFullSrc && transformSW) {
const res = await esbuild.build({
entryPoints: [swSrc],
bundle: true,
write: false,
outfile: 'sw-pleroma.js',
alias: esbuildAlias,
plugins: [{
name: 'vite-like-root-resolve',
setup (b) {
b.onResolve(
{ filter: new RegExp(/^\//) },
args => ({
path: resolve(projectRoot, args.path.slice(1))
})
)
}
}, {
name: 'sw-messages',
setup (b) {
b.onResolve(
{ filter: new RegExp('^' + swMessagesName + '$') },
args => ({
path: args.path,
namespace: 'sw-messages'
}))
b.onLoad(
{ filter: /.*/, namespace: 'sw-messages' },
async () => ({
contents: await getSWMessagesAsText()
}))
}
}, {
name: 'sw-env',
setup (b) {
b.onResolve(
{ filter: new RegExp('^' + swEnvName + '$') },
args => ({
path: args.path,
namespace: 'sw-env'
}))
b.onLoad(
{ filter: /.*/, namespace: 'sw-env' },
() => ({
contents: getDevSwEnv()
}))
}
}]
})
const text = res.outputFiles[0].text
return text
}
}
}
}
// Idea taken from // Idea taken from
// https://github.com/vite-pwa/vite-plugin-pwa/blob/main/src/plugins/build.ts // https://github.com/vite-pwa/vite-plugin-pwa/blob/main/src/plugins/build.ts
// rollup does not support compiling to iife if we want to code-split; // rollup does not support compiling to iife if we want to code-split;
// however, we must compile the service worker to iife because of browser support. // however, we must compile the service worker to iife because of browser support.
// Run another vite build just for the service worker targeting iife at // Run another vite build just for the service worker targeting iife at
// the end of the build. // the end of the build.
export const buildSwPlugin = ({ export const buildSwPlugin = ({ swSrc, swDest }) => {
swSrc, const swFullSrc = resolve(projectRoot, swSrc)
swDest, const swEnvName = 'virtual:pleroma-fe/service_worker_env'
}) => { const swEnvNameResolved = '\0' + swEnvName
let config let config
return { return {
name: 'build-sw-plugin', name: 'build-sw-plugin',
enforce: 'post', enforce: 'post',
apply: 'build', configResolved(resolvedConfig) {
configResolved (resolvedConfig) { resolvedConfig
config = { config = {
define: resolvedConfig.define, define: resolvedConfig.define,
resolve: resolvedConfig.resolve, resolve: resolvedConfig.resolve,
@ -135,53 +40,81 @@ export const buildSwPlugin = ({
publicDir: false, publicDir: false,
build: { build: {
...resolvedConfig.build, ...resolvedConfig.build,
lib: {
entry: swSrc,
formats: ['iife'],
name: 'sw_pleroma'
},
emptyOutDir: false, emptyOutDir: false,
rollupOptions: { rolldownOptions: {
input: {
main: swSrc,
},
context: 'self',
output: { output: {
entryFileNames: swDest entryFileNames: swDest,
} codeSplitting: false,
} format: 'iife',
},
},
}, },
configFile: false configFile: false,
} }
}, },
generateBundle: { generateBundle: {
order: 'post', order: 'post',
sequential: true, sequential: true,
async handler (_, bundle) { async handler(_, bundle) {
const assets = Object.keys(bundle) const assets = Object.keys(bundle)
.filter(name => !/\.map$/.test(name)) .filter((name) => !/\.map$/.test(name))
.map(name => '/' + name) .map((name) => '/' + name)
config.plugins.push({ config.plugins.push({
name: 'build-sw-env-plugin', name: 'build-sw-env-plugin',
resolveId (id) { mode: 'production',
if (id === swEnvName) { resolveId: {
return swEnvNameResolved filter: { id: exactRegex(swEnvName) },
} handler: () => swEnvNameResolved,
return null },
load: {
filter: { id: exactRegex(swEnvNameResolved) },
handler() {
return `self.serviceWorkerOption = { assets: ${JSON.stringify(assets)} };`
},
}, },
load (id) {
if (id === swEnvNameResolved) {
return getProdSwEnv({ assets })
}
return null
}
}) })
} },
},
resolveId: {
filter: { id: new RegExp(swDest) },
handler() {
return swFullSrc
},
},
load: {
filter: { id: new RegExp(swFullSrc) },
async handler() {
config.plugins.push({
name: 'dummy-sw-env',
mode: 'development',
resolveId: {
filter: { id: exactRegex(swEnvName) },
handler: () => swEnvNameResolved,
},
load: {
filter: { id: exactRegex(swEnvNameResolved) },
handler: () => 'self.serviceWorkerOption = { assets: [] }',
},
})
const swBundle = await build(config)
return swBundle.output[0]
},
}, },
closeBundle: { closeBundle: {
order: 'post', order: 'post',
sequential: true, sequential: true,
async handler () { async handler() {
console.log('Building service worker for production') if (process.env.VITEST) return
console.info('Building service worker for production')
await build(config) await build(config)
} },
} },
} }
} }
@ -191,9 +124,9 @@ const swMessagesNameResolved = '\0' + swMessagesName
export const swMessagesPlugin = () => { export const swMessagesPlugin = () => {
return { return {
name: 'sw-messages-plugin', name: 'sw-messages-plugin',
resolveId (id) { resolveId(id) {
if (id === swMessagesName) { if (id === swMessagesName) {
Object.values(i18nFiles).forEach(f => { Object.values(i18nFiles).forEach((f) => {
this.addWatchFile(f) this.addWatchFile(f)
}) })
return swMessagesNameResolved return swMessagesNameResolved
@ -201,11 +134,11 @@ export const swMessagesPlugin = () => {
return null return null
} }
}, },
async load (id) { async load(id) {
if (id === swMessagesNameResolved) { if (id === swMessagesNameResolved) {
return await getSWMessagesAsText() return await getSWMessagesAsText()
} }
return null return null
} },
} }
} }

View file

@ -1,22 +1,21 @@
import emojis from '@kazvmoe-infra/unicode-emoji-json/data-by-group.json' with {
import emojis from '@kazvmoe-infra/unicode-emoji-json/data-by-group.json' with { type: 'json' } type: 'json',
}
import fs from 'fs' import fs from 'fs'
Object.keys(emojis) Object.keys(emojis).map((k) => {
.map(k => { emojis[k].map((e) => {
emojis[k].map(e => { delete e.unicode_version
delete e.unicode_version delete e.emoji_version
delete e.emoji_version delete e.skin_tone_support_unicode_version
delete e.skin_tone_support_unicode_version
})
}) })
})
const res = {} const res = {}
Object.keys(emojis) Object.keys(emojis).map((k) => {
.map(k => { const groupId = k.replace('&', 'and').replace(/ /g, '-').toLowerCase()
const groupId = k.replace('&', 'and').replace(/ /g, '-').toLowerCase() res[groupId] = emojis[k]
res[groupId] = emojis[k] })
})
console.info('Updating emojis...') console.info('Updating emojis...')
fs.writeFileSync('src/assets/emoji.json', JSON.stringify(res)) fs.writeFileSync('src/assets/emoji.json', JSON.stringify(res))

View file

@ -0,0 +1 @@
Fix HTML attribute parsing for escaped quotes

1
changelog.d/fast.change Normal file
View file

@ -0,0 +1 @@
Migrated to Vite 8 and optimized our imports, more stuff is loaded on-demand, reducing the initial load time and transfer size

View file

@ -0,0 +1 @@
Fix emojis breaking user bio/description editing

6
changelog.d/minor.add Normal file
View file

@ -0,0 +1,6 @@
button to remove all drafts
option to remove forced aspect ratio for user profiles (requested)
showing user tags (mrf policies for user + custom if present)
version information now is also in about page
mention autosuggest now sorts by recent activity
non-square emoji support (toggleable by user)

7
changelog.d/minor.change Normal file
View file

@ -0,0 +1,7 @@
overall improved spacings in status action buttons and post form
logout confirm button is now dangerous
reply/quote now is a radio group and wraps, fixes overflow on languages where labels are too wide
personal note input is now bigger
moved "edit pinned" to the bottom for status action buttons.
dots status action button drops down instead of up to avoid hiding the action buttons
improved attachment description (alt text) input and display

12
changelog.d/minor.fix Normal file
View file

@ -0,0 +1,12 @@
navbar wide logo cropping search input
danger buttons being too bright
user background upload failure no longer breaks new uploads + displays an error
importing theme from old theme editor
removed duplicate federationpolicy entry in admin tab
repeater name overflowing content
reply popover is now shown if replied-to status is muted
second language input not having header
post form's bottom left buttons not showing their toggled state
some font overrides not working
popovers opening outside of window's boundaries
occasional blank page when showing new posts

View file

@ -0,0 +1 @@
Fixed status action mute hiding itself on click

View file

@ -0,0 +1 @@
displaying other user's backgrounds (if supported by BE)

View file

@ -0,0 +1 @@
Add quoting by URL and in replies

View file

@ -0,0 +1 @@
Fix reply form crash when quote-reply settings are unavailable

View file

@ -0,0 +1,2 @@
settings synchronization
user highlight synchronization

View file

57
docker-compose.e2e.yml Normal file
View file

@ -0,0 +1,57 @@
services:
db:
image: postgres:15-alpine
environment:
POSTGRES_USER: pleroma
POSTGRES_PASSWORD: pleroma
POSTGRES_DB: pleroma
healthcheck:
test: ["CMD-SHELL", "pg_isready -U pleroma -d pleroma"]
interval: 2s
timeout: 2s
retries: 30
pleroma:
image: ${PLEROMA_IMAGE:-git.pleroma.social:5050/pleroma/pleroma:stable}
environment:
DB_USER: pleroma
DB_PASS: pleroma
DB_NAME: pleroma
DB_HOST: db
DB_PORT: 5432
DOMAIN: localhost
INSTANCE_NAME: Pleroma E2E
ADMIN_EMAIL: ${E2E_ADMIN_EMAIL:-admin@example.com}
NOTIFY_EMAIL: ${E2E_ADMIN_EMAIL:-admin@example.com}
E2E_ADMIN_USERNAME: ${E2E_ADMIN_USERNAME:-admin}
E2E_ADMIN_PASSWORD: ${E2E_ADMIN_PASSWORD:-adminadmin}
E2E_ADMIN_EMAIL: ${E2E_ADMIN_EMAIL:-admin@example.com}
depends_on:
db:
condition: service_healthy
volumes:
- ./docker/pleroma/entrypoint.e2e.sh:/opt/pleroma/entrypoint.e2e.sh:ro
entrypoint: ["/bin/ash", "/opt/pleroma/entrypoint.e2e.sh"]
healthcheck:
# NOTE: "localhost" may resolve to ::1 in some images (IPv6) while Pleroma only
# listens on IPv4 in this container. Use 127.0.0.1 to avoid false negatives.
test: ["CMD-SHELL", "test -f /var/lib/pleroma/.e2e_seeded && wget -qO- http://127.0.0.1:4000/api/v1/instance >/dev/null || exit 1"]
interval: 5s
timeout: 3s
retries: 60
e2e:
build:
context: .
dockerfile: docker/e2e/Dockerfile.e2e
depends_on:
pleroma:
condition: service_healthy
environment:
CI: "1"
VITE_PROXY_TARGET: http://pleroma:4000
VITE_PROXY_ORIGIN: http://localhost:4000
E2E_BASE_URL: http://localhost:8080
E2E_ADMIN_USERNAME: ${E2E_ADMIN_USERNAME:-admin}
E2E_ADMIN_PASSWORD: ${E2E_ADMIN_PASSWORD:-adminadmin}
command: ["yarn", "e2e:pw"]

16
docker/e2e/Dockerfile.e2e Normal file
View file

@ -0,0 +1,16 @@
FROM mcr.microsoft.com/playwright:v1.57.0-jammy
WORKDIR /app
ENV PLAYWRIGHT_SKIP_BROWSER_DOWNLOAD=1
RUN npm install -g yarn@1.22.22
COPY package.json yarn.lock ./
RUN yarn --frozen-lockfile
COPY . .
ENV CI=1
CMD ["yarn", "e2e:pw"]

View file

@ -0,0 +1,71 @@
#!/bin/ash
set -eu
SEED_SENTINEL_PATH="/var/lib/pleroma/.e2e_seeded"
CONFIG_OVERRIDE_PATH="/var/lib/pleroma/config.exs"
echo "-- Waiting for database..."
while ! pg_isready -U "${DB_USER:-pleroma}" -d "postgres://${DB_HOST:-db}:${DB_PORT:-5432}/${DB_NAME:-pleroma}" -t 1; do
sleep 1s
done
echo "-- Writing E2E config overrides..."
cat > "$CONFIG_OVERRIDE_PATH" <<'EOF'
import Config
config :pleroma, Pleroma.Captcha,
enabled: false
config :pleroma, :instance,
registrations_open: true,
account_activation_required: false,
approval_required: false
EOF
echo "-- Running migrations..."
/opt/pleroma/bin/pleroma_ctl migrate
echo "-- Starting!"
/opt/pleroma/bin/pleroma start &
PLEROMA_PID="$!"
cleanup() {
if [ -n "${PLEROMA_PID:-}" ] && kill -0 "$PLEROMA_PID" 2>/dev/null; then
kill -TERM "$PLEROMA_PID"
wait "$PLEROMA_PID" || true
fi
}
trap cleanup INT TERM
echo "-- Waiting for API..."
api_ok="false"
for _i in $(seq 1 120); do
if wget -qO- http://127.0.0.1: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 [ ! -f "$SEED_SENTINEL_PATH" ]; then
if [ -n "${E2E_ADMIN_USERNAME:-}" ] && [ -n "${E2E_ADMIN_PASSWORD:-}" ] && [ -n "${E2E_ADMIN_EMAIL:-}" ]; then
echo "-- Seeding admin user (${E2E_ADMIN_USERNAME})..."
if ! /opt/pleroma/bin/pleroma_ctl user new "$E2E_ADMIN_USERNAME" "$E2E_ADMIN_EMAIL" --admin --password "$E2E_ADMIN_PASSWORD" -y; then
echo "-- User already exists (or creation failed), ensuring admin + confirmed..."
/opt/pleroma/bin/pleroma_ctl user set "$E2E_ADMIN_USERNAME" --admin --confirmed
fi
else
echo "-- Skipping admin seeding (missing E2E_ADMIN_* env)"
fi
touch "$SEED_SENTINEL_PATH"
fi
wait "$PLEROMA_PID"

View file

@ -7,9 +7,9 @@
PleromaFE gets its configuration from several sources, in order of preference (the one above overrides ones below it) PleromaFE gets its configuration from several sources, in order of preference (the one above overrides ones below it)
1. `/api/statusnet/config.json` - this is generated on Backend and contains multiple things including instance name, char limit etc. It also contains FE/Client-specific data, PleromaFE uses `pleromafe` field of it. For more info on changing config on BE, look [here](../backend/configuration/cheatsheet.md#frontend_configurations) 1. `/api/pleroma/frontend_configurations` - this is generated by backend and includes FE/Client-specific data. PleromaFE uses the `pleroma_fe` field of it. For more info on changing config on BE, look [here](../backend/configuration/cheatsheet.md#frontend_configurations)
2. `/static/config.json` - this is a static FE-provided file, containing only FE specific configuration. This file is completely optional and could be removed but is useful as a fallback if some configuration JSON property isn't present in BE-provided config. It's also a reference point to check what default configuration are and what JSON properties even exist. In local dev mode it could be used to override BE configuration, more about that in HACKING.md. File is located [here](https://git.pleroma.social/pleroma/pleroma-fe/blob/develop/static/config.json). 2. `/static/config.json` - this is a static FE-provided file, containing only FE specific configuration. This file is completely optional and could be removed but is useful as a fallback if some configuration JSON property isn't present in BE-provided config. It's also a reference point to check what default configuration are and what JSON properties even exist. In local dev mode it could be used to override BE configuration, more about that in HACKING.md. File is located [here](https://git.pleroma.social/pleroma/pleroma-fe/src/public/static/config.json).
3. Built-in defaults. Those are hard-coded defaults that are used when `/static/config.json` is not available and BE-provided configuration JSON is missing some JSON properties. ( [Code](https://git.pleroma.social/pleroma/pleroma-fe/blob/develop/src/modules/instance.js) ) 3. Built-in defaults. Those are hard-coded defaults that are used when `/static/config.json` is not available and BE-provided configuration JSON is missing some JSON properties. ( [Code](https://git.pleroma.social/pleroma/pleroma-fe/src/src/stores/instance.js) )
## Instance-defaults ## Instance-defaults

View file

@ -79,7 +79,7 @@ server {
In 99% cases PleromaFE uses [MastoAPI](https://docs.joinmastodon.org/api/) with [Pleroma Extensions](../backend/API/differences_in_mastoapi_responses.md) to fetch the data. The rest is either QvitterAPI leftovers or pleroma-exclusive APIs. QvitterAPI doesn't exactly have documentation and uses different JSON structure and sometimes different parameters and workflows, [this](https://twitter-api.readthedocs.io/en/latest/index.html) could be a good reference though. Some pleroma-exclusive API may still be using QvitterAPI JSON structure. In 99% cases PleromaFE uses [MastoAPI](https://docs.joinmastodon.org/api/) with [Pleroma Extensions](../backend/API/differences_in_mastoapi_responses.md) to fetch the data. The rest is either QvitterAPI leftovers or pleroma-exclusive APIs. QvitterAPI doesn't exactly have documentation and uses different JSON structure and sometimes different parameters and workflows, [this](https://twitter-api.readthedocs.io/en/latest/index.html) could be a good reference though. Some pleroma-exclusive API may still be using QvitterAPI JSON structure.
PleromaFE supports both formats by transforming them into internal format which is basically QvitterAPI one with some additions and renaming. All data is passed trough [Entity Normalizer](https://git.pleroma.social/pleroma/pleroma-fe/-/blob/develop/src/services/entity_normalizer/entity_normalizer.service.js) which can serve as a reference of API and what's actually used, it's also a host for all the hacks and data transformation. PleromaFE supports both formats by transforming them into internal format which is basically QvitterAPI one with some additions and renaming. All data is passed trough [Entity Normalizer](https://git.pleroma.social/pleroma/pleroma-fe/src/src/services/entity_normalizer/entity_normalizer.service.js) which can serve as a reference of API and what's actually used, it's also a host for all the hacks and data transformation.
For most part, PleromaFE tries to store all the info it can get in global vuex store - every user and post are passed trough updating mechanism where data is either added or merged with existing data, reactively updating the information throughout UI, so if in newest request user's post counter increased, it will be instantly updated in open user profile cards. This is also used to find users, posts and sometimes to build timelines and/or request parameters. For most part, PleromaFE tries to store all the info it can get in global vuex store - every user and post are passed trough updating mechanism where data is either added or merged with existing data, reactively updating the information throughout UI, so if in newest request user's post counter increased, it will be instantly updated in open user profile cards. This is also used to find users, posts and sometimes to build timelines and/or request parameters.

View file

@ -1,37 +1,34 @@
import vue from "eslint-plugin-vue"; import js from '@eslint/js'
import js from "@eslint/js"; import { defineConfig, globalIgnores } from 'eslint/config'
import globals from "globals"; import vue from 'eslint-plugin-vue'
import globals from 'globals'
export default defineConfig([
export default [
...vue.configs['flat/recommended'], ...vue.configs['flat/recommended'],
js.configs.recommended, globalIgnores(['**/*.js', 'build/', 'dist/', 'config/']),
{ {
files: ["**/*.js", "**/*.mjs", "**/*.vue"], files: ['src/**/*.vue'],
ignores: ["build/*.js", "config/*.js"], plugins: { js },
extends: ['js/recommended'],
languageOptions: { languageOptions: {
ecmaVersion: 2024, ecmaVersion: 2024,
sourceType: "module", sourceType: 'module',
parserOptions: { parserOptions: {
parser: "@babel/eslint-parser", parser: '@babel/eslint-parser',
}, },
globals: { globals: {
...globals.browser, ...globals.browser,
...globals.vitest, ...globals.vitest,
...globals.chai, ...globals.chai,
...globals.commonjs, ...globals.commonjs,
...globals.serviceworker ...globals.serviceworker,
} },
}, },
rules: { rules: {
'arrow-parens': 0,
'generator-star-spacing': 0,
'no-debugger': 0,
'vue/require-prop-types': 0, 'vue/require-prop-types': 0,
'vue/multi-word-component-names': 0, 'vue/multi-word-component-names': 0,
} },
} },
] ])

View file

@ -1,8 +1,8 @@
{ {
"name": "pleroma_fe", "name": "pleroma_fe",
"version": "2.9.2", "version": "2.10.1",
"description": "Pleroma frontend, the default frontend of Pleroma social network server", "description": "Pleroma frontend, the default frontend of Pleroma social network server",
"author": "Pleroma contributors <https://git.pleroma.social/pleroma/pleroma-fe/-/blob/develop/CONTRIBUTORS.md>", "author": "Pleroma contributors <https://git.pleroma.social/pleroma/pleroma-fe/src/CONTRIBUTORS.md>",
"private": false, "private": false,
"scripts": { "scripts": {
"dev": "node build/update-emoji.js && vite dev", "dev": "node build/update-emoji.js && vite dev",
@ -10,19 +10,22 @@
"unit": "node build/update-emoji.js && vitest --run", "unit": "node build/update-emoji.js && vitest --run",
"unit-ci": "node build/update-emoji.js && vitest --run --browser.headless", "unit-ci": "node build/update-emoji.js && vitest --run --browser.headless",
"unit:watch": "node build/update-emoji.js && vitest", "unit:watch": "node build/update-emoji.js && vitest",
"e2e": "node test/e2e/runner.js", "e2e:pw": "playwright test --config test/e2e-playwright/playwright.config.mjs",
"e2e": "sh ./tools/e2e/run.sh",
"test": "yarn run unit && yarn run e2e", "test": "yarn run unit && yarn run e2e",
"stylelint": "yarn exec stylelint '**/*.scss' '**/*.vue'", "ci-biome": "yarn exec biome check",
"lint": "eslint src test/unit/specs test/e2e/specs", "ci-eslint": "yarn exec eslint",
"lint-fix": "eslint --fix src test/unit/specs test/e2e/specs" "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": { "dependencies": {
"@babel/runtime": "7.28.3", "@babel/runtime": "7.28.4",
"@chenfengyuan/vue-qrcode": "2.0.0", "@chenfengyuan/vue-qrcode": "2.0.0",
"@fortawesome/fontawesome-svg-core": "6.7.2", "@fortawesome/fontawesome-svg-core": "7.1.0",
"@fortawesome/free-regular-svg-icons": "6.7.2", "@fortawesome/free-regular-svg-icons": "7.1.0",
"@fortawesome/free-solid-svg-icons": "6.7.2", "@fortawesome/free-solid-svg-icons": "7.1.0",
"@fortawesome/vue-fontawesome": "3.1.1", "@fortawesome/vue-fontawesome": "3.1.2",
"@kazvmoe-infra/pinch-zoom-element": "1.3.0", "@kazvmoe-infra/pinch-zoom-element": "1.3.0",
"@kazvmoe-infra/unicode-emoji-json": "0.4.0", "@kazvmoe-infra/unicode-emoji-json": "0.4.0",
"@ruffle-rs/ruffle": "0.1.0-nightly.2025.6.22", "@ruffle-rs/ruffle": "0.1.0-nightly.2025.6.22",
@ -39,51 +42,53 @@
"js-cookie": "3.0.5", "js-cookie": "3.0.5",
"localforage": "1.10.0", "localforage": "1.10.0",
"parse-link-header": "2.0.0", "parse-link-header": "2.0.0",
"phoenix": "1.8.0", "phoenix": "1.8.1",
"pinia": "^3.0.0", "pinia": "^3.0.4",
"punycode.js": "2.3.1", "punycode.js": "2.3.1",
"qrcode": "1.5.4", "qrcode": "1.5.4",
"querystring-es3": "0.2.1", "querystring-es3": "0.2.1",
"url": "0.11.4", "url": "0.11.4",
"utf8": "3.0.0", "utf8": "3.0.0",
"uuid": "11.1.0", "uuid": "11.1.0",
"vue": "3.5.19", "vue": "3.5.22",
"vue-i18n": "11", "vue-i18n": "11",
"vue-router": "4.5.1", "vue-router": "4.6.4",
"vue-virtual-scroller": "^2.0.0-beta.7", "vue-virtual-scroller": "^2.0.0-beta.7",
"vuex": "4.1.0" "vuex": "4.1.0"
}, },
"devDependencies": { "devDependencies": {
"@babel/core": "7.28.3", "@babel/core": "7.28.5",
"@babel/eslint-parser": "7.28.0", "@babel/eslint-parser": "7.28.5",
"@babel/plugin-transform-runtime": "7.28.3", "@babel/plugin-transform-runtime": "7.28.5",
"@babel/preset-env": "7.28.3", "@babel/preset-env": "7.28.5",
"@babel/register": "7.28.3", "@babel/register": "7.28.3",
"@biomejs/biome": "2.3.11",
"@pinia/testing": "1.0.3",
"@ungap/event-target": "0.2.4", "@ungap/event-target": "0.2.4",
"@vitejs/plugin-vue": "^5.2.1", "@vitejs/devtools": "^0.3.1",
"@vitejs/plugin-vue-jsx": "^4.1.1", "@vitejs/plugin-vue": "^6.0.7",
"@vitejs/plugin-vue-jsx": "^5.1.5",
"@vitest/browser": "^3.0.7", "@vitest/browser": "^3.0.7",
"@vitest/ui": "^3.0.7", "@vitest/ui": "^3.0.7",
"@vue/babel-helper-vue-jsx-merge-props": "1.4.0", "@vue/babel-helper-vue-jsx-merge-props": "1.4.0",
"@vue/babel-plugin-jsx": "1.5.0", "@vue/babel-plugin-jsx": "1.5.0",
"@vue/compiler-sfc": "3.5.19", "@vue/compiler-sfc": "3.5.22",
"@vue/test-utils": "2.4.6", "@vue/test-utils": "2.4.6",
"autoprefixer": "10.4.21", "autoprefixer": "10.4.21",
"babel-plugin-lodash": "3.3.4", "babel-plugin-lodash": "3.3.4",
"chai": "5.3.2", "chai": "5.3.3",
"chalk": "5.6.0", "chalk": "5.6.2",
"chromedriver": "135.0.4", "chromedriver": "135.0.4",
"connect-history-api-fallback": "2.0.0", "connect-history-api-fallback": "2.0.0",
"cross-spawn": "7.0.6", "cross-spawn": "7.0.6",
"custom-event-polyfill": "1.0.7", "custom-event-polyfill": "1.0.7",
"eslint": "9.33.0", "eslint": "9.39.2",
"vue-eslint-parser": "10.2.0",
"eslint-config-standard": "17.1.0", "eslint-config-standard": "17.1.0",
"eslint-formatter-friendly": "7.0.0", "eslint-formatter-friendly": "7.0.0",
"eslint-plugin-import": "2.32.0", "eslint-plugin-import": "2.32.0",
"eslint-plugin-n": "17.21.3", "eslint-plugin-n": "17.23.1",
"eslint-plugin-promise": "7.2.1", "eslint-plugin-promise": "7.2.1",
"eslint-plugin-vue": "10.4.0", "eslint-plugin-vue": "10.6.2",
"eventsource-polyfill": "0.9.6", "eventsource-polyfill": "0.9.6",
"express": "5.1.0", "express": "5.1.0",
"function-bind": "1.1.2", "function-bind": "1.1.2",
@ -92,27 +97,29 @@
"lodash": "4.17.21", "lodash": "4.17.21",
"msw": "2.10.5", "msw": "2.10.5",
"nightwatch": "3.12.2", "nightwatch": "3.12.2",
"playwright": "1.55.0", "oxc": "^1.0.1",
"playwright": "1.57.0",
"postcss": "8.5.6", "postcss": "8.5.6",
"postcss-html": "^1.5.0", "postcss-html": "^1.5.0",
"postcss-scss": "^4.0.6", "postcss-scss": "^4.0.6",
"sass": "1.89.2", "sass-embedded": "^1.100.0",
"selenium-server": "3.141.59", "selenium-server": "3.141.59",
"semver": "7.7.2", "semver": "7.7.3",
"serve-static": "2.2.0", "serve-static": "2.2.0",
"shelljs": "0.10.0", "shelljs": "0.10.0",
"sinon": "20.0.0", "sinon": "20.0.0",
"sinon-chai": "4.0.0", "sinon-chai": "4.0.1",
"stylelint": "16.19.1", "stylelint": "16.25.0",
"stylelint-config-html": "^1.1.0", "stylelint-config-html": "^1.1.0",
"stylelint-config-recommended": "^16.0.0", "stylelint-config-recommended": "^16.0.0",
"stylelint-config-recommended-scss": "^14.0.0", "stylelint-config-recommended-scss": "^14.0.0",
"stylelint-config-recommended-vue": "^1.6.0", "stylelint-config-recommended-vue": "^1.6.0",
"stylelint-config-standard": "38.0.0", "stylelint-config-standard": "38.0.0",
"vite": "^6.1.0", "vite": "^8.0.0",
"vite-plugin-eslint2": "^5.0.3", "vite-plugin-eslint2": "^5.1.0",
"vite-plugin-stylelint": "^6.0.0", "vite-plugin-stylelint": "^6.1.0",
"vitest": "^3.0.7" "vitest": "^3.0.7",
"vue-eslint-parser": "10.2.0"
}, },
"type": "module", "type": "module",
"engines": { "engines": {

View file

@ -1,7 +1,5 @@
import autoprefixer from 'autoprefixer' import autoprefixer from 'autoprefixer'
export default { export default {
plugins: [ plugins: [autoprefixer],
autoprefixer
]
} }

View file

@ -1,6 +1,26 @@
{ {
"pleroma-dark": [ "Pleroma Dark", "#121a24", "#182230", "#b9b9ba", "#d8a070", "#d31014", "#0fa00f", "#0095ff", "#ffa500" ], "pleroma-dark": [
"pleroma-light": [ "Pleroma Light", "#f2f4f6", "#dbe0e8", "#304055", "#f86f0f", "#d31014", "#0fa00f", "#0095ff", "#ffa500" ], "Pleroma Dark",
"#121a24",
"#182230",
"#b9b9ba",
"#d8a070",
"#d31014",
"#0fa00f",
"#0095ff",
"#ffa500"
],
"pleroma-light": [
"Pleroma Light",
"#f2f4f6",
"#dbe0e8",
"#304055",
"#f86f0f",
"#d31014",
"#0fa00f",
"#0095ff",
"#ffa500"
],
"classic-dark": { "classic-dark": {
"name": "Classic Dark", "name": "Classic Dark",
"bg": "#161c20", "bg": "#161c20",
@ -12,8 +32,28 @@
"cBlue": "#0095ff", "cBlue": "#0095ff",
"cOrange": "#ffa500" "cOrange": "#ffa500"
}, },
"bird": [ "Bird", "#f8fafd", "#e6ecf0", "#14171a", "#0084b8", "#e0245e", "#17bf63", "#1b95e0", "#fab81e"], "bird": [
"pleroma-amoled": [ "Pleroma Dark AMOLED", "#000000", "#111111", "#b0b0b1", "#d8a070", "#aa0000", "#0fa00f", "#0095ff", "#d59500"], "Bird",
"#f8fafd",
"#e6ecf0",
"#14171a",
"#0084b8",
"#e0245e",
"#17bf63",
"#1b95e0",
"#fab81e"
],
"pleroma-amoled": [
"Pleroma Dark AMOLED",
"#000000",
"#111111",
"#b0b0b1",
"#d8a070",
"#aa0000",
"#0fa00f",
"#0095ff",
"#d59500"
],
"tomorrow-night": { "tomorrow-night": {
"name": "Tomorrow Night", "name": "Tomorrow Night",
"bg": "#1d1f21", "bg": "#1d1f21",
@ -36,8 +76,28 @@
"cGreen": "#50FA7B", "cGreen": "#50FA7B",
"cOrange": "#FFB86C" "cOrange": "#FFB86C"
}, },
"ir-black": [ "Ir Black", "#000000", "#242422", "#b5b3aa", "#ff6c60", "#FF6C60", "#A8FF60", "#96CBFE", "#FFFFB6" ], "ir-black": [
"monokai": [ "Monokai", "#272822", "#383830", "#f8f8f2", "#f92672", "#F92672", "#a6e22e", "#66d9ef", "#f4bf75" ], "Ir Black",
"#000000",
"#242422",
"#b5b3aa",
"#ff6c60",
"#FF6C60",
"#A8FF60",
"#96CBFE",
"#FFFFB6"
],
"monokai": [
"Monokai",
"#272822",
"#383830",
"#f8f8f2",
"#f92672",
"#F92672",
"#a6e22e",
"#66d9ef",
"#f4bf75"
],
"purple-stream": { "purple-stream": {
"name": "Purple stream", "name": "Purple stream",
"bg": "#17171A", "bg": "#17171A",

View file

@ -65,16 +65,17 @@ body {
z-index: 2; z-index: 2;
grid-template-rows: repeat(8, 1fr); grid-template-rows: repeat(8, 1fr);
grid-template-columns: repeat(5, 1fr); grid-template-columns: repeat(5, 1fr);
grid-template-areas: "P P . L L" grid-template-areas:
"P P . L L" "P P . L L"
"P P . L L" "P P . L L"
"P P . L L" "P P . L L"
"P P . . ." "P P . L L"
"P P . . ." "P P . . ."
"P P . E E" "P P . . ."
"P P . E E"; "P P . E E"
"P P . E E";
--logoChunkSize: calc(2em * 0.5 * var(--scale)) --logoChunkSize: calc(2em * 0.5 * var(--scale));
} }
.chunk { .chunk {
@ -84,7 +85,7 @@ body {
#chunk-P { #chunk-P {
grid-area: P; grid-area: P;
border-top-left-radius: calc(var(--logoChunkSize) / 2); border-top-left-radius: calc(var(--logoChunkSize) / 2);
} }
#chunk-L { #chunk-L {

View file

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

View file

@ -1,187 +1,268 @@
import UserPanel from './components/user_panel/user_panel.vue'
import NavPanel from './components/nav_panel/nav_panel.vue'
import InstanceSpecificPanel from './components/instance_specific_panel/instance_specific_panel.vue'
import FeaturesPanel from './components/features_panel/features_panel.vue'
import WhoToFollowPanel from './components/who_to_follow_panel/who_to_follow_panel.vue'
import ShoutPanel from './components/shout_panel/shout_panel.vue'
import MediaModal from './components/media_modal/media_modal.vue'
import SideDrawer from './components/side_drawer/side_drawer.vue'
import MobilePostStatusButton from './components/mobile_post_status_button/mobile_post_status_button.vue'
import MobileNav from './components/mobile_nav/mobile_nav.vue'
import DesktopNav from './components/desktop_nav/desktop_nav.vue'
import UserReportingModal from './components/user_reporting_modal/user_reporting_modal.vue'
import EditStatusModal from './components/edit_status_modal/edit_status_modal.vue'
import PostStatusModal from './components/post_status_modal/post_status_modal.vue'
import StatusHistoryModal from './components/status_history_modal/status_history_modal.vue'
import GlobalNoticeList from './components/global_notice_list/global_notice_list.vue'
import { getOrCreateServiceWorker } from './services/sw/sw'
import { windowWidth, windowHeight } from './services/window_utils/window_utils'
import { mapGetters } from 'vuex'
import { defineAsyncComponent } from 'vue'
import { useShoutStore } from './stores/shout'
import { useInterfaceStore } from './stores/interface'
import { throttle } from 'lodash' import { throttle } from 'lodash'
import { mapState } from 'pinia'
import { defineAsyncComponent } from 'vue'
import DesktopNav from 'src/components/desktop_nav/desktop_nav.vue'
import FeaturesPanel from 'src/components/features_panel/features_panel.vue'
import GlobalNoticeList from 'src/components/global_notice_list/global_notice_list.vue'
import InstanceSpecificPanel from 'src/components/instance_specific_panel/instance_specific_panel.vue'
import MobileNav from 'src/components/mobile_nav/mobile_nav.vue'
import MobilePostStatusButton from 'src/components/mobile_post_status_button/mobile_post_status_button.vue'
import NavPanel from 'src/components/nav_panel/nav_panel.vue'
import UserPanel from 'src/components/user_panel/user_panel.vue'
import { getOrCreateServiceWorker } from './services/sw/sw'
import { windowHeight, windowWidth } from './services/window_utils/window_utils'
import { useEmojiStore } from 'src/stores/emoji.js'
import { useI18nStore } from 'src/stores/i18n.js'
import { useInstanceStore } from 'src/stores/instance.js'
import { useInstanceCapabilitiesStore } from 'src/stores/instance_capabilities.js'
import { useInterfaceStore } from 'src/stores/interface.js'
import { useMergedConfigStore } from 'src/stores/merged_config.js'
import { useShoutStore } from 'src/stores/shout.js'
import messages from 'src/i18n/messages'
import localeService from 'src/services/locale/locale.service.js'
// Helper to unwrap reactive proxies
window.toValue = (x) => JSON.parse(JSON.stringify(x))
export default { export default {
name: 'app', name: 'app',
components: { components: {
UserPanel, UserPanel,
NavPanel, NavPanel,
Notifications: defineAsyncComponent(() => import('./components/notifications/notifications.vue')), Notifications: defineAsyncComponent(
() => import('src/components/notifications/notifications.vue'),
),
InstanceSpecificPanel, InstanceSpecificPanel,
FeaturesPanel, FeaturesPanel,
WhoToFollowPanel, WhoToFollowPanel: defineAsyncComponent(
ShoutPanel, () =>
MediaModal, import('src/components/who_to_follow_panel/who_to_follow_panel.vue'),
SideDrawer, ),
ShoutPanel: defineAsyncComponent(
() => import('src/components/shout_panel/shout_panel.vue'),
),
MediaModal: defineAsyncComponent(
() => import('src/components/media_modal/media_modal.vue'),
),
MobilePostStatusButton, MobilePostStatusButton,
MobileNav, MobileNav,
DesktopNav, DesktopNav,
SettingsModal: defineAsyncComponent(() => import('./components/settings_modal/settings_modal.vue')), SettingsModal: defineAsyncComponent(
UpdateNotification: defineAsyncComponent(() => import('./components/update_notification/update_notification.vue')), () => import('src/components/settings_modal/settings_modal.vue'),
UserReportingModal, ),
PostStatusModal, UpdateNotification: defineAsyncComponent(
EditStatusModal, () =>
StatusHistoryModal, import('src/components/update_notification/update_notification.vue'),
GlobalNoticeList ),
PostStatusModal: defineAsyncComponent(
() => import('src/components/post_status_modal/post_status_modal.vue'),
),
UserReportingModal: defineAsyncComponent(
() =>
import('src/components/user_reporting_modal/user_reporting_modal.vue'),
),
EditStatusModal: defineAsyncComponent(
() => import('src/components/edit_status_modal/edit_status_modal.vue'),
),
StatusHistoryModal: defineAsyncComponent(
() =>
import('src/components/status_history_modal/status_history_modal.vue'),
),
GlobalNoticeList,
}, },
data: () => ({ data: () => ({
mobileActivePanel: 'timeline' mobileActivePanel: 'timeline',
}), }),
watch: { provide() {
themeApplied () { return {
this.removeSplash() allowNonSquareEmoji: useMergedConfigStore().mergedConfig.nonSquareEmoji,
},
currentTheme () {
this.setThemeBodyClass()
},
layoutType () {
document.getElementById('modal').classList = ['-' + this.layoutType]
} }
}, },
created () { watch: {
themeApplied() {
this.removeSplash()
},
currentTheme() {
this.setThemeBodyClass()
},
layoutType() {
document.getElementById('modal').classList = ['-' + this.layoutType]
},
},
created() {
// Load the locale from the storage // Load the locale from the storage
const val = this.$store.getters.mergedConfig.interfaceLanguage const value = useMergedConfigStore().mergedConfig.interfaceLanguage
this.$store.dispatch('setOption', { name: 'interfaceLanguage', value: val }) useI18nStore().setLanguage(value)
useEmojiStore().loadUnicodeEmojiData(value)
document.getElementById('modal').classList = ['-' + this.layoutType] document.getElementById('modal').classList = ['-' + this.layoutType]
// Create bound handlers // Create bound handlers
this.updateScrollState = throttle(this.scrollHandler, 200) this.updateScrollState = throttle(this.scrollHandler, 200)
this.updateMobileState = throttle(this.resizeHandler, 200) this.updateMobileState = throttle(this.resizeHandler, 200)
}, },
mounted () { mounted() {
window.addEventListener('resize', this.updateMobileState) window.addEventListener('resize', this.updateMobileState)
this.scrollParent.addEventListener('scroll', this.updateScrollState) this.scrollParent.addEventListener('scroll', this.updateScrollState)
if (useInterfaceStore().themeApplied) { if (this.themeApplied) {
this.setThemeBodyClass() this.setThemeBodyClass()
this.removeSplash() this.removeSplash()
} }
getOrCreateServiceWorker() getOrCreateServiceWorker()
}, },
unmounted () { unmounted() {
window.removeEventListener('resize', this.updateMobileState) window.removeEventListener('resize', this.updateMobileState)
this.scrollParent.removeEventListener('scroll', this.updateScrollState) this.scrollParent.removeEventListener('scroll', this.updateScrollState)
}, },
computed: { computed: {
themeApplied () { currentTheme() {
return useInterfaceStore().themeApplied if (this.styleDataUsed) {
}, const styleMeta = this.styleDataUsed.find(
currentTheme () { (x) => x.component === '@meta',
if (useInterfaceStore().styleDataUsed) { )
const styleMeta = useInterfaceStore().styleDataUsed.find(x => x.component === '@meta')
if (styleMeta !== undefined) { if (styleMeta !== undefined) {
return styleMeta.directives.name.replaceAll(" ", "-").toLowerCase() return styleMeta.directives.name.replaceAll(' ', '-').toLowerCase()
} }
} }
return 'stock' return 'stock'
}, },
layoutModalClass () { layoutModalClass() {
return '-' + this.layoutType return '-' + this.layoutType
}, },
classes () { classes() {
return [ return [
{ {
'-reverse': this.reverseLayout, '-reverse': this.reverseLayout,
'-no-sticky-headers': this.noSticky, '-no-sticky-headers': this.noSticky,
'-has-new-post-button': this.newPostButtonShown '-has-new-post-button': this.newPostButtonShown,
}, },
'-' + this.layoutType '-' + this.layoutType,
] ]
}, },
navClasses () { navClasses() {
const { navbarColumnStretch } = this.$store.getters.mergedConfig const { navbarColumnStretch } = useMergedConfigStore().mergedConfig
return [ return [
'-' + this.layoutType, '-' + this.layoutType,
...(navbarColumnStretch ? ['-column-stretch'] : []) ...(navbarColumnStretch ? ['-column-stretch'] : []),
] ]
}, },
currentUser () { return this.$store.state.users.currentUser }, currentUser() {
userBackground () { return this.currentUser.background_image }, return this.$store.state.users.currentUser
instanceBackground () {
return this.mergedConfig.hideInstanceWallpaper
? null
: this.$store.state.instance.background
}, },
background () { return this.userBackground || this.instanceBackground }, userBackground() {
bgStyle () { return this.currentUser.background_image
},
foreignProfileBackground() {
return (
useMergedConfigStore().mergedConfig.allowForeignUserBackground &&
useInterfaceStore().foreignProfileBackground
)
},
instanceBackground() {
return useMergedConfigStore().mergedConfig.hideInstanceWallpaper
? null
: this.instanceBackgroundUrl
},
background() {
return (
this.foreignProfileBackground ||
this.userBackground ||
this.instanceBackground
)
},
bgStyle() {
if (this.background) { if (this.background) {
return { return {
'--body-background-image': `url(${this.background})` '--body-background-image': `url(${this.background})`,
} }
} }
}, },
shout () { return useShoutStore().joined }, shoutJoined() {
suggestionsEnabled () { return this.$store.state.instance.suggestionsEnabled }, return useShoutStore().joined
showInstanceSpecificPanel () {
return this.$store.state.instance.showInstanceSpecificPanel &&
!this.$store.getters.mergedConfig.hideISP &&
this.$store.state.instance.instanceSpecificPanelContent
}, },
isChats () { isChats() {
return this.$route.name === 'chat' || this.$route.name === 'chats' return this.$route.name === 'chat' || this.$route.name === 'chats'
}, },
isListEdit () { isListEdit() {
return this.$route.name === 'lists-edit' return this.$route.name === 'lists-edit'
}, },
newPostButtonShown () { newPostButtonShown() {
if (this.isChats) return false if (this.isChats) return false
if (this.isListEdit) return false if (this.isListEdit) return false
return this.$store.getters.mergedConfig.alwaysShowNewPostButton || this.layoutType === 'mobile' return (
useMergedConfigStore().mergedConfig.alwaysShowNewPostButton ||
this.layoutType === 'mobile'
)
}, },
showFeaturesPanel () { return this.$store.state.instance.showFeaturesPanel }, shoutboxPosition() {
editingAvailable () { return this.$store.state.instance.editingAvailable }, return (
shoutboxPosition () { useMergedConfigStore().mergedConfig.alwaysShowNewPostButton || false
return this.$store.getters.mergedConfig.alwaysShowNewPostButton || false )
}, },
hideShoutbox () { hideShoutbox() {
return this.$store.getters.mergedConfig.hideShoutbox return useMergedConfigStore().mergedConfig.hideShoutbox
}, },
layoutType () { return useInterfaceStore().layoutType }, reverseLayout() {
privateMode () { return this.$store.state.instance.private }, const { thirdColumnMode, sidebarRight: reverseSetting } =
reverseLayout () { useMergedConfigStore().mergedConfig
const { thirdColumnMode, sidebarRight: reverseSetting } = this.$store.getters.mergedConfig
if (this.layoutType !== 'wide') { if (this.layoutType !== 'wide') {
return reverseSetting return reverseSetting
} else { } else {
return thirdColumnMode === 'notifications' ? reverseSetting : !reverseSetting return thirdColumnMode === 'notifications'
? reverseSetting
: !reverseSetting
} }
}, },
noSticky () { return this.$store.getters.mergedConfig.disableStickyHeaders }, noSticky() {
showScrollbars () { return this.$store.getters.mergedConfig.showScrollbars }, return useMergedConfigStore().mergedConfig.disableStickyHeaders
scrollParent () { return window; /* this.$refs.appContentRef */ }, },
...mapGetters(['mergedConfig']) showScrollbars() {
return useMergedConfigStore().mergedConfig.showScrollbars
},
scrollParent() {
return window /* this.$refs.appContentRef */
},
showInstanceSpecificPanel() {
return (
this.instanceSpecificPanelPresent &&
!useMergedConfigStore().mergedConfig.hideISP
)
},
...mapState(useMergedConfigStore, ['mergedConfig']),
...mapState(useInterfaceStore, [
'themeApplied',
'styleDataUsed',
'layoutType',
]),
...mapState(useInstanceStore, ['styleDataUsed']),
...mapState(useInstanceCapabilitiesStore, [
'suggestionsEnabled',
'editingAvailable',
]),
...mapState(useInstanceStore, {
instanceBackgroundUrl: (store) => store.instanceIdentity.background,
showFeaturesPanel: (store) => store.instanceIdentity.showFeaturesPanel,
instanceSpecificPanelPresent: (store) =>
store.instanceIdentity.showInstanceSpecificPanel &&
store.instanceIdentity.instanceSpecificPanelContent,
}),
}, },
methods: { methods: {
resizeHandler () { resizeHandler() {
useInterfaceStore().setLayoutWidth(windowWidth()) useInterfaceStore().setLayoutWidth(windowWidth())
useInterfaceStore().setLayoutHeight(windowHeight()) useInterfaceStore().setLayoutHeight(windowHeight())
}, },
scrollHandler () { scrollHandler() {
const scrollPosition = this.scrollParent === window ? window.scrollY : this.scrollParent.scrollTop const scrollPosition =
this.scrollParent === window
? window.scrollY
: this.scrollParent.scrollTop
if (scrollPosition != 0) { if (scrollPosition != 0) {
this.$refs.appContentRef.classList.add(['-scrolled']) this.$refs.appContentRef.classList.add(['-scrolled'])
@ -189,10 +270,10 @@ export default {
this.$refs.appContentRef.classList.remove(['-scrolled']) this.$refs.appContentRef.classList.remove(['-scrolled'])
} }
}, },
setThemeBodyClass () { setThemeBodyClass() {
const themeName = this.currentTheme const themeName = this.currentTheme
const classList = Array.from(document.body.classList) const classList = Array.from(document.body.classList)
const oldTheme = classList.filter(c => c.startsWith('theme-')) const oldTheme = classList.filter((c) => c.startsWith('theme-'))
if (themeName !== null && themeName !== '') { if (themeName !== null && themeName !== '') {
const newTheme = `theme-${themeName.toLowerCase()}` const newTheme = `theme-${themeName.toLowerCase()}`
@ -208,8 +289,10 @@ export default {
document.body.classList.remove(...oldTheme) document.body.classList.remove(...oldTheme)
} }
}, },
removeSplash () { removeSplash() {
document.querySelector('#status').textContent = this.$t('splash.fun_' + Math.ceil(Math.random() * 4)) document.querySelector('#status').textContent = this.$t(
'splash.fun_' + Math.ceil(Math.random() * 4),
)
const splashscreenRoot = document.querySelector('#splash') const splashscreenRoot = document.querySelector('#splash')
splashscreenRoot.addEventListener('transitionend', () => { splashscreenRoot.addEventListener('transitionend', () => {
splashscreenRoot.remove() splashscreenRoot.remove()
@ -219,6 +302,6 @@ export default {
}, 600) }, 600)
splashscreenRoot.classList.add('hidden') splashscreenRoot.classList.add('hidden')
document.querySelector('#app').classList.remove('hidden') document.querySelector('#app').classList.remove('hidden')
} },
} },
} }

View file

@ -50,7 +50,7 @@ body {
// have a cursor/pointer to operate them // have a cursor/pointer to operate them
@media (any-pointer: fine) { @media (any-pointer: fine) {
* { * {
scrollbar-color: var(--fg) transparent; scrollbar-color: var(--icon) transparent;
&::-webkit-scrollbar { &::-webkit-scrollbar {
background: transparent; background: transparent;
@ -130,7 +130,7 @@ body {
} }
// Body should have background to scrollbar otherwise it will use white (body color?) // Body should have background to scrollbar otherwise it will use white (body color?)
html { html {
scrollbar-color: var(--fg) var(--wallpaper); scrollbar-color: var(--icon) var(--wallpaper);
background: var(--wallpaper); background: var(--wallpaper);
} }
} }
@ -200,6 +200,7 @@ nav {
background-color: var(--wallpaper); background-color: var(--wallpaper);
background-image: var(--body-background-image); background-image: var(--body-background-image);
background-position: 50%; background-position: 50%;
transition: background-image 1s;
} }
.underlay { .underlay {
@ -513,6 +514,12 @@ nav {
} }
} }
label {
&.-disabled {
color: var(--textFaint);
}
}
input, input,
textarea { textarea {
border: none; border: none;
@ -553,6 +560,10 @@ textarea {
&[disabled="disabled"], &[disabled="disabled"],
&.disabled { &.disabled {
cursor: not-allowed; cursor: not-allowed;
color: var(--textFaint);
/* stylelint-disable-next-line declaration-no-important */
background-color: transparent !important;
} }
&[type="range"] { &[type="range"] {
@ -578,6 +589,8 @@ textarea {
& + label::before { & + label::before {
opacity: 0.5; opacity: 0.5;
} }
background-color: var(--background);
} }
+ label::before { + label::before {
@ -677,7 +690,8 @@ option {
list-style: none; list-style: none;
display: grid; display: grid;
grid-auto-flow: row dense; grid-auto-flow: row dense;
grid-template-columns: 1fr 1fr; grid-template-columns: repeat(auto-fit, minmax(20em, 1fr));
grid-gap: 0.5em;
li { li {
border: 1px solid var(--border); border: 1px solid var(--border);
@ -698,7 +712,6 @@ option {
--_roundness-right: 0; --_roundness-right: 0;
position: relative; position: relative;
flex: 1 1 auto;
} }
> *:first-child, > *:first-child,
@ -775,6 +788,19 @@ option {
padding: 0 0.25em; padding: 0 0.25em;
border-radius: var(--roundness); border-radius: var(--roundness);
border: 1px solid var(--border); border: 1px solid var(--border);
&.-dismissible {
display: flex;
padding-left: 0.5em;
margin: 0;
align-items: baseline;
line-height: 2;
span {
display: block;
flex: 1 0 auto;
}
}
} }
.faint { .faint {
@ -785,14 +811,19 @@ option {
} }
.notice-dismissible { .notice-dismissible {
padding-right: 4rem; display: flex;
position: relative; padding: 0.75em 1em;
align-items: baseline;
line-height: 1.5;
p,
span {
display: block;
flex: 1 1 auto;
margin: 0;
}
.dismiss { .dismiss {
position: absolute;
top: 0;
right: 0;
padding: 0.5em;
color: inherit; color: inherit;
} }
} }

View file

@ -28,10 +28,10 @@
> >
<user-panel /> <user-panel />
<template v-if="layoutType !== 'mobile'"> <template v-if="layoutType !== 'mobile'">
<nav-panel /> <NavPanel />
<instance-specific-panel v-if="showInstanceSpecificPanel" /> <InstanceSpecificPanel v-if="showInstanceSpecificPanel" />
<features-panel v-if="!currentUser && showFeaturesPanel" /> <FeaturesPanel v-if="!currentUser && showFeaturesPanel" />
<who-to-follow-panel v-if="currentUser && suggestionsEnabled" /> <WhoToFollowPanel v-if="currentUser && suggestionsEnabled" />
<div id="notifs-sidebar" /> <div id="notifs-sidebar" />
</template> </template>
</div> </div>
@ -60,8 +60,8 @@
/> />
</div> </div>
<MediaModal /> <MediaModal />
<shout-panel <ShoutPanel
v-if="currentUser && shout && !hideShoutbox" v-if="currentUser && !hideShoutbox && shoutJoined"
:floating="true" :floating="true"
class="floating-shout mobile-hidden" class="floating-shout mobile-hidden"
:class="{ '-left': shoutboxPosition }" :class="{ '-left': shoutboxPosition }"

View file

@ -1,29 +1,53 @@
/* global process */ /* global process */
import vClickOutside from 'click-outside-vue3'
import { createApp } from 'vue' import { createApp } from 'vue'
import { createRouter, createWebHistory } from 'vue-router' import { createRouter, createWebHistory } from 'vue-router'
import vClickOutside from 'click-outside-vue3'
import VueVirtualScroller from 'vue-virtual-scroller' import VueVirtualScroller from 'vue-virtual-scroller'
import 'vue-virtual-scroller/dist/vue-virtual-scroller.css' import 'vue-virtual-scroller/dist/vue-virtual-scroller.css'
import { FontAwesomeIcon, FontAwesomeLayers } from '@fortawesome/vue-fontawesome' import RichContent from 'src/components/rich_content/rich_content.jsx'
import { config } from '@fortawesome/fontawesome-svg-core'; import Status from 'src/components/status/status.vue'
import StillImage from 'src/components/still-image/still-image.vue'
import { config } from '@fortawesome/fontawesome-svg-core'
import {
FontAwesomeIcon,
FontAwesomeLayers,
} from '@fortawesome/vue-fontawesome'
config.autoAddCss = false config.autoAddCss = false
import App from '../App.vue' import App from '../App.vue'
import routes from './routes'
import VBodyScrollLock from 'src/directives/body_scroll_lock'
import { windowWidth, windowHeight } from '../services/window_utils/window_utils'
import backendInteractorService from '../services/backend_interactor_service/backend_interactor_service.js' import backendInteractorService from '../services/backend_interactor_service/backend_interactor_service.js'
import { applyConfig } from '../services/style_setter/style_setter.js'
import FaviconService from '../services/favicon_service/favicon_service.js' import FaviconService from '../services/favicon_service/favicon_service.js'
import { applyStyleConfig } from '../services/style_setter/style_setter.js'
import { initServiceWorker, updateFocus } from '../services/sw/sw.js' import { initServiceWorker, updateFocus } from '../services/sw/sw.js'
import {
windowHeight,
windowWidth,
} from '../services/window_utils/window_utils'
import routes from './routes'
import { useOAuthStore } from 'src/stores/oauth'
import { useI18nStore } from 'src/stores/i18n'
import { useInterfaceStore } from 'src/stores/interface'
import { useAnnouncementsStore } from 'src/stores/announcements' import { useAnnouncementsStore } from 'src/stores/announcements'
import { useAuthFlowStore } from 'src/stores/auth_flow' import { useAuthFlowStore } from 'src/stores/auth_flow'
import { useEmojiStore } from 'src/stores/emoji.js'
import { useI18nStore } from 'src/stores/i18n'
import { useInstanceStore } from 'src/stores/instance.js'
import { useInstanceCapabilitiesStore } from 'src/stores/instance_capabilities.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 { useOAuthStore } from 'src/stores/oauth'
import { useSyncConfigStore } from 'src/stores/sync_config.js'
import { useUserHighlightStore } from 'src/stores/user_highlight.js'
import VBodyScrollLock from 'src/directives/body_scroll_lock'
import {
INSTANCE_DEFAULT_CONFIG_DEFINITIONS,
INSTANCE_IDENTITY_DEFAULT_DEFINITIONS,
INSTANCE_IDENTIY_EXTERNAL,
} from 'src/modules/default_config_state.js'
let staticInitialResults = null let staticInitialResults = null
@ -32,7 +56,9 @@ const parsedInitialResults = () => {
return null return null
} }
if (!staticInitialResults) { if (!staticInitialResults) {
staticInitialResults = JSON.parse(document.getElementById('initial-results').textContent) staticInitialResults = JSON.parse(
document.getElementById('initial-results').textContent,
)
} }
return staticInitialResults return staticInitialResults
} }
@ -54,7 +80,7 @@ const preloadFetch = async (request) => {
return { return {
ok: true, ok: true,
json: () => requestData, json: () => requestData,
text: () => requestData text: () => requestData,
} }
} }
@ -63,20 +89,38 @@ const getInstanceConfig = async ({ store }) => {
const res = await preloadFetch('/api/v1/instance') const res = await preloadFetch('/api/v1/instance')
if (res.ok) { if (res.ok) {
const data = await res.json() const data = await res.json()
const textlimit = data.max_toot_chars const textLimit = data.max_toot_chars
const vapidPublicKey = data.pleroma.vapid_public_key const vapidPublicKey = data.pleroma.vapid_public_key
store.dispatch('setInstanceOption', { name: 'pleromaExtensionsAvailable', value: data.pleroma }) useInstanceCapabilitiesStore().set(
store.dispatch('setInstanceOption', { name: 'textlimit', value: textlimit }) 'pleromaExtensionsAvailable',
store.dispatch('setInstanceOption', { name: 'accountApprovalRequired', value: data.approval_required }) data.pleroma,
store.dispatch('setInstanceOption', { name: 'birthdayRequired', value: !!data.pleroma?.metadata.birthday_required }) )
store.dispatch('setInstanceOption', { name: 'birthdayMinAge', value: data.pleroma?.metadata.birthday_min_age || 0 }) useInstanceStore().set({
path: 'limits.textLimit',
value: textLimit,
})
useInstanceStore().set({
path: 'accountApprovalRequired',
value: data.approval_required,
})
useInstanceStore().set({
path: 'birthdayRequired',
value: !!data.pleroma?.metadata.birthday_required,
})
useInstanceStore().set({
path: 'birthdayMinAge',
value: data.pleroma?.metadata.birthday_min_age || 0,
})
if (vapidPublicKey) { if (vapidPublicKey) {
store.dispatch('setInstanceOption', { name: 'vapidPublicKey', value: vapidPublicKey }) useInstanceStore().set({
path: 'vapidPublicKey',
value: vapidPublicKey,
})
} }
} else { } else {
throw (res) throw res
} }
} catch (error) { } catch (error) {
console.error('Could not load instance config, potentially fatal') console.error('Could not load instance config, potentially fatal')
@ -93,10 +137,12 @@ const getBackendProvidedConfig = async () => {
const data = await res.json() const data = await res.json()
return data.pleroma_fe return data.pleroma_fe
} else { } else {
throw (res) throw res
} }
} catch (error) { } catch (error) {
console.error('Could not load backend-provided frontend config, potentially fatal') console.error(
'Could not load backend-provided frontend config, potentially fatal',
)
console.error(error) console.error(error)
} }
} }
@ -107,11 +153,13 @@ const getStaticConfig = async () => {
if (res.ok) { if (res.ok) {
return res.json() return res.json()
} else { } else {
throw (res) throw res
} }
} catch (error) { } catch (error) {
console.warn('Failed to load static/config.json, continuing without it.') console.warn(
console.warn(error) 'Failed to load static/config.json, continuing without it.',
error,
)
return {} return {}
} }
} }
@ -129,51 +177,25 @@ const setSettings = async ({ apiConfig, staticConfig, store }) => {
config = Object.assign({}, staticConfig, apiConfig) config = Object.assign({}, staticConfig, apiConfig)
} }
const copyInstanceOption = (name) => { Object.keys(INSTANCE_IDENTITY_DEFAULT_DEFINITIONS).forEach((source) => {
store.dispatch('setInstanceOption', { name, value: config[name] }) if (source === 'name') return
} if (INSTANCE_IDENTIY_EXTERNAL.has(source)) return
useInstanceStore().set({
copyInstanceOption('theme') value:
copyInstanceOption('style') config[source] ?? INSTANCE_IDENTITY_DEFAULT_DEFINITIONS[source].default,
copyInstanceOption('palette') path: `instanceIdentity.${source}`,
copyInstanceOption('embeddedToS') })
copyInstanceOption('nsfwCensorImage')
copyInstanceOption('background')
copyInstanceOption('hidePostStats')
copyInstanceOption('hideBotIndication')
copyInstanceOption('hideUserStats')
copyInstanceOption('hideFilteredStatuses')
copyInstanceOption('logo')
store.dispatch('setInstanceOption', {
name: 'logoMask',
value: typeof config.logoMask === 'undefined'
? true
: config.logoMask
}) })
store.dispatch('setInstanceOption', { Object.keys(INSTANCE_DEFAULT_CONFIG_DEFINITIONS).forEach((source) =>
name: 'logoMargin', useInstanceStore().set({
value: typeof config.logoMargin === 'undefined' value:
? 0 config[source] ?? INSTANCE_DEFAULT_CONFIG_DEFINITIONS[source].default,
: config.logoMargin path: `prefsStorage.${source}`,
}) }),
copyInstanceOption('logoLeft') )
useAuthFlowStore().setInitialStrategy(config.loginMethod) useAuthFlowStore().setInitialStrategy(config.loginMethod)
copyInstanceOption('redirectRootNoLogin')
copyInstanceOption('redirectRootLogin')
copyInstanceOption('showInstanceSpecificPanel')
copyInstanceOption('minimalScopesMode')
copyInstanceOption('hideMutedPosts')
copyInstanceOption('collapseMessageWithSubject')
copyInstanceOption('scopeCopy')
copyInstanceOption('subjectLineBehavior')
copyInstanceOption('postContentType')
copyInstanceOption('alwaysShowSubjectInput')
copyInstanceOption('showFeaturesPanel')
copyInstanceOption('hideSitename')
copyInstanceOption('sidebarRight')
} }
const getTOS = async ({ store }) => { const getTOS = async ({ store }) => {
@ -181,9 +203,9 @@ const getTOS = async ({ store }) => {
const res = await window.fetch('/static/terms-of-service.html') const res = await window.fetch('/static/terms-of-service.html')
if (res.ok) { if (res.ok) {
const html = await res.text() const html = await res.text()
store.dispatch('setInstanceOption', { name: 'tos', value: html }) useInstanceStore().set({ path: 'instanceIdentity.tos', value: html })
} else { } else {
throw (res) throw res
} }
} catch (e) { } catch (e) {
console.warn("Can't load TOS\n", e) console.warn("Can't load TOS\n", e)
@ -195,9 +217,12 @@ const getInstancePanel = async ({ store }) => {
const res = await preloadFetch('/instance/panel.html') const res = await preloadFetch('/instance/panel.html')
if (res.ok) { if (res.ok) {
const html = await res.text() const html = await res.text()
store.dispatch('setInstanceOption', { name: 'instanceSpecificPanelContent', value: html }) useInstanceStore().set({
path: 'instanceIdentity.instanceSpecificPanelContent',
value: html,
})
} else { } else {
throw (res) throw res
} }
} catch (e) { } catch (e) {
console.warn("Can't load instance panel\n", e) console.warn("Can't load instance panel\n", e)
@ -209,25 +234,27 @@ const getStickers = async ({ store }) => {
const res = await window.fetch('/static/stickers.json') const res = await window.fetch('/static/stickers.json')
if (res.ok) { if (res.ok) {
const values = await res.json() const values = await res.json()
const stickers = (await Promise.all( const stickers = (
Object.entries(values).map(async ([name, path]) => { await Promise.all(
const resPack = await window.fetch(path + 'pack.json') Object.entries(values).map(async ([name, path]) => {
let meta = {} const resPack = await window.fetch(path + 'pack.json')
if (resPack.ok) { let meta = {}
meta = await resPack.json() if (resPack.ok) {
} meta = await resPack.json()
return { }
pack: name, return {
path, pack: name,
meta path,
} meta,
}) }
)).sort((a, b) => { }),
)
).sort((a, b) => {
return a.meta.title.localeCompare(b.meta.title) return a.meta.title.localeCompare(b.meta.title)
}) })
store.dispatch('setInstanceOption', { name: 'stickers', value: stickers }) useEmojiStore().setStickers(stickers)
} else { } else {
throw (res) throw res
} }
} catch (e) { } catch (e) {
console.warn("Can't load stickers\n", e) console.warn("Can't load stickers\n", e)
@ -237,13 +264,19 @@ const getStickers = async ({ store }) => {
const getAppSecret = async ({ store }) => { const getAppSecret = async ({ store }) => {
const oauth = useOAuthStore() const oauth = useOAuthStore()
if (oauth.userToken) { if (oauth.userToken) {
store.commit('setBackendInteractor', backendInteractorService(oauth.getToken)) store.commit(
'setBackendInteractor',
backendInteractorService(oauth.getToken),
)
} }
} }
const resolveStaffAccounts = ({ store, accounts }) => { const resolveStaffAccounts = ({ store, accounts }) => {
const nicknames = accounts.map(uri => uri.split('/').pop()) const nicknames = accounts.map((uri) => uri.split('/').pop())
store.dispatch('setInstanceOption', { name: 'staffAccounts', value: nicknames }) useInstanceStore().set({
path: 'staffAccounts',
value: nicknames,
})
} }
const getNodeInfo = async ({ store }) => { const getNodeInfo = async ({ store }) => {
@ -254,87 +287,177 @@ const getNodeInfo = async ({ store }) => {
const data = await res.json() const data = await res.json()
const metadata = data.metadata const metadata = data.metadata
const features = metadata.features const features = metadata.features
store.dispatch('setInstanceOption', { name: 'name', value: metadata.nodeName }) useInstanceStore().set({
store.dispatch('setInstanceOption', { name: 'registrationOpen', value: data.openRegistrations }) path: 'instanceIdentity.name',
store.dispatch('setInstanceOption', { name: 'mediaProxyAvailable', value: features.includes('media_proxy') }) value: metadata.nodeName,
store.dispatch('setInstanceOption', { name: 'safeDM', value: features.includes('safe_dm_mentions') })
store.dispatch('setInstanceOption', { name: 'shoutAvailable', value: features.includes('chat') })
store.dispatch('setInstanceOption', { name: 'pleromaChatMessagesAvailable', value: features.includes('pleroma_chat_messages') })
store.dispatch('setInstanceOption', {
name: 'pleromaCustomEmojiReactionsAvailable',
value:
features.includes('pleroma_custom_emoji_reactions') ||
features.includes('custom_emoji_reactions')
}) })
store.dispatch('setInstanceOption', { name: 'pleromaBookmarkFoldersAvailable', value: features.includes('pleroma:bookmark_folders') }) useInstanceStore().set({
store.dispatch('setInstanceOption', { name: 'gopherAvailable', value: features.includes('gopher') }) path: 'registrationOpen',
store.dispatch('setInstanceOption', { name: 'pollsAvailable', value: features.includes('polls') }) value: data.openRegistrations,
store.dispatch('setInstanceOption', { name: 'editingAvailable', value: features.includes('editing') }) })
store.dispatch('setInstanceOption', { name: 'pollLimits', value: metadata.pollLimits }) useInstanceCapabilitiesStore().set(
store.dispatch('setInstanceOption', { name: 'mailerEnabled', value: metadata.mailerEnabled }) 'mediaProxyAvailable',
store.dispatch('setInstanceOption', { name: 'quotingAvailable', value: features.includes('quote_posting') }) features.includes('media_proxy'),
store.dispatch('setInstanceOption', { name: 'groupActorAvailable', value: features.includes('pleroma:group_actors') }) )
store.dispatch('setInstanceOption', { name: 'blockExpiration', value: features.includes('pleroma:block_expiration') }) useInstanceCapabilitiesStore().set(
store.dispatch('setInstanceOption', { name: 'localBubbleInstances', value: metadata.localBubbleInstances ?? [] }) 'safeDM',
features.includes('safe_dm_mentions'),
)
useInstanceCapabilitiesStore().set(
'shoutAvailable',
features.includes('chat'),
)
useInstanceCapabilitiesStore().set(
'pleromaChatMessagesAvailable',
features.includes('pleroma_chat_messages'),
)
useInstanceCapabilitiesStore().set(
'pleromaCustomEmojiReactionsAvailable',
features.includes('pleroma_custom_emoji_reactions') ||
features.includes('custom_emoji_reactions'),
)
useInstanceCapabilitiesStore().set(
'pleromaBookmarkFoldersAvailable',
features.includes('pleroma:bookmark_folders'),
)
useInstanceCapabilitiesStore().set(
'gopherAvailable',
features.includes('gopher'),
)
useInstanceCapabilitiesStore().set(
'pollsAvailable',
features.includes('polls'),
)
useInstanceCapabilitiesStore().set(
'editingAvailable',
features.includes('editing'),
)
useInstanceCapabilitiesStore().set(
'mailerEnabled',
metadata.mailerEnabled,
)
useInstanceCapabilitiesStore().set(
'quotingAvailable',
features.includes('quote_posting'),
)
useInstanceCapabilitiesStore().set(
'groupActorAvailable',
features.includes('pleroma:group_actors'),
)
useInstanceCapabilitiesStore().set(
'blockExpiration',
features.includes('pleroma:block_expiration'),
)
useInstanceStore().set({
path: 'localBubbleInstances',
value: metadata.localBubbleInstances ?? [],
})
useInstanceCapabilitiesStore().set(
'localBubble',
(metadata.localBubbleInstances ?? []).length > 0,
)
useInstanceStore().set({
path: 'limits.pollLimits',
value: metadata.pollLimits,
})
const uploadLimits = metadata.uploadLimits const uploadLimits = metadata.uploadLimits
store.dispatch('setInstanceOption', { name: 'uploadlimit', value: parseInt(uploadLimits.general) }) useInstanceStore().set({
store.dispatch('setInstanceOption', { name: 'avatarlimit', value: parseInt(uploadLimits.avatar) }) path: 'limits.uploadlimit',
store.dispatch('setInstanceOption', { name: 'backgroundlimit', value: parseInt(uploadLimits.background) }) value: parseInt(uploadLimits.general),
store.dispatch('setInstanceOption', { name: 'bannerlimit', value: parseInt(uploadLimits.banner) }) })
store.dispatch('setInstanceOption', { name: 'fieldsLimits', value: metadata.fieldsLimits }) useInstanceStore().set({
path: 'limits.avatarlimit',
value: parseInt(uploadLimits.avatar),
})
useInstanceStore().set({
path: 'limits.backgroundlimit',
value: parseInt(uploadLimits.background),
})
useInstanceStore().set({
path: 'limits.bannerlimit',
value: parseInt(uploadLimits.banner),
})
useInstanceStore().set({
path: 'limits.fieldsLimits',
value: metadata.fieldsLimits,
})
store.dispatch('setInstanceOption', { name: 'restrictedNicknames', value: metadata.restrictedNicknames }) useInstanceStore().set({
store.dispatch('setInstanceOption', { name: 'postFormats', value: metadata.postFormats }) path: 'restrictedNicknames',
value: metadata.restrictedNicknames,
})
useInstanceCapabilitiesStore().set('postFormats', metadata.postFormats)
const suggestions = metadata.suggestions const suggestions = metadata.suggestions
store.dispatch('setInstanceOption', { name: 'suggestionsEnabled', value: suggestions.enabled }) useInstanceCapabilitiesStore().set(
store.dispatch('setInstanceOption', { name: 'suggestionsWeb', value: suggestions.web }) 'suggestionsEnabled',
suggestions.enabled,
)
// this is unused, why?
useInstanceCapabilitiesStore().set('suggestionsWeb', suggestions.web)
const software = data.software const software = data.software
store.dispatch('setInstanceOption', { name: 'backendVersion', value: software.version }) useInstanceStore().set({
store.dispatch('setInstanceOption', { name: 'backendRepository', value: software.repository }) path: 'backendVersion',
value: software.version,
})
useInstanceStore().set({
path: 'backendRepository',
value: software.repository,
})
const priv = metadata.private const priv = metadata.private
store.dispatch('setInstanceOption', { name: 'private', value: priv }) useInstanceStore().set({ path: 'privateMode', value: priv })
const frontendVersion = window.___pleromafe_commit_hash const frontendVersion = window.___pleromafe_commit_hash
store.dispatch('setInstanceOption', { name: 'frontendVersion', value: frontendVersion }) useInstanceStore().set({
path: 'frontendVersion',
value: frontendVersion,
})
const federation = metadata.federation const federation = metadata.federation
store.dispatch('setInstanceOption', { useInstanceCapabilitiesStore().set(
name: 'tagPolicyAvailable', 'tagPolicyAvailable',
value: typeof federation.mrf_policies === 'undefined' typeof federation.mrf_policies === 'undefined'
? false ? false
: metadata.federation.mrf_policies.includes('TagPolicy') : metadata.federation.mrf_policies.includes('TagPolicy'),
}) )
store.dispatch('setInstanceOption', { name: 'federationPolicy', value: federation }) useInstanceStore().set({
store.dispatch('setInstanceOption', { path: 'federationPolicy',
name: 'federating', value: federation,
value: typeof federation.enabled === 'undefined' })
? true useInstanceStore().set({
: federation.enabled path: 'federating',
value:
typeof federation.enabled === 'undefined' ? true : federation.enabled,
}) })
const accountActivationRequired = metadata.accountActivationRequired const accountActivationRequired = metadata.accountActivationRequired
store.dispatch('setInstanceOption', { name: 'accountActivationRequired', value: accountActivationRequired }) useInstanceStore().set({
path: 'accountActivationRequired',
value: accountActivationRequired,
})
const accounts = metadata.staffAccounts const accounts = metadata.staffAccounts
resolveStaffAccounts({ store, accounts }) resolveStaffAccounts({ store, accounts })
} else { } else {
throw (res) throw res
} }
} catch (e) { } catch (e) {
console.warn('Could not load nodeinfo') console.warn('Could not load nodeinfo', e)
console.warn(e)
} }
} }
const setConfig = async ({ store }) => { const setConfig = async ({ store }) => {
// apiConfig, staticConfig // apiConfig, staticConfig
const configInfos = await Promise.all([getBackendProvidedConfig({ store }), getStaticConfig()]) const configInfos = await Promise.all([
getBackendProvidedConfig({ store }),
getStaticConfig(),
])
const apiConfig = configInfos[0] const apiConfig = configInfos[0]
const staticConfig = configInfos[1] const staticConfig = configInfos[1]
@ -365,29 +488,37 @@ const afterStoreSetup = async ({ pinia, store, storageError, i18n }) => {
if (process.env.NODE_ENV === 'development') { if (process.env.NODE_ENV === 'development') {
// do some checks to avoid common errors // do some checks to avoid common errors
if (!Object.keys(allStores).length) { if (!Object.keys(allStores).length) {
throw new Error('No stores are available. Check the code in src/boot/after_store.js') throw new Error(
'No stores are available. Check the code in src/boot/after_store.js',
)
} }
} }
await Promise.all( await Promise.all(
Object.entries(allStores) Object.entries(allStores).map(async ([name, mod]) => {
.map(async ([name, mod]) => { const isStoreName = (name) => name.startsWith('use')
const isStoreName = name => name.startsWith('use') if (process.env.NODE_ENV === 'development') {
if (process.env.NODE_ENV === 'development') { if (Object.keys(mod).filter(isStoreName).length !== 1) {
if (Object.keys(mod).filter(isStoreName).length !== 1) { throw new Error(
throw new Error('Each store file must export exactly one store as a named export. Check your code in src/stores/') 'Each store file must export exactly one store as a named export. Check your code in src/stores/',
} )
} }
const storeFuncName = Object.keys(mod).find(isStoreName) }
if (storeFuncName && typeof mod[storeFuncName] === 'function') { const storeFuncName = Object.keys(mod).find(isStoreName)
const p = mod[storeFuncName]().$persistLoaded if (storeFuncName && typeof mod[storeFuncName] === 'function') {
if (!(p instanceof Promise)) { const p = mod[storeFuncName]().$persistLoaded
throw new Error(`${name} store's $persistLoaded is not a Promise. The persist plugin is not applied.`) if (!(p instanceof Promise)) {
} throw new Error(
await p `${name} store's $persistLoaded is not a Promise. The persist plugin is not applied.`,
} else { )
throw new Error(`Store module ${name} does not export a 'use...' function`)
} }
})) await p
} else {
throw new Error(
`Store module ${name} does not export a 'use...' function`,
)
}
}),
)
} }
try { try {
@ -398,30 +529,45 @@ const afterStoreSetup = async ({ pinia, store, storageError, i18n }) => {
} }
if (storageError) { if (storageError) {
useInterfaceStore().pushGlobalNotice({ messageKey: 'errors.storage_unavailable', level: 'error' }) useInterfaceStore().pushGlobalNotice({
messageKey: 'errors.storage_unavailable',
level: 'error',
})
} }
useInterfaceStore().setLayoutWidth(windowWidth()) useInterfaceStore().setLayoutWidth(windowWidth())
useInterfaceStore().setLayoutHeight(windowHeight()) useInterfaceStore().setLayoutHeight(windowHeight())
window.syncConfig = useSyncConfigStore()
window.mergedConfig = useMergedConfigStore()
window.localConfig = useLocalConfigStore()
window.highlightConfig = useUserHighlightStore()
FaviconService.initFaviconService() FaviconService.initFaviconService()
initServiceWorker(store) initServiceWorker(store)
window.addEventListener('focus', () => updateFocus()) window.addEventListener('focus', () => updateFocus())
const overrides = window.___pleromafe_dev_overrides || {} const overrides = window.___pleromafe_dev_overrides || {}
const server = (typeof overrides.target !== 'undefined') ? overrides.target : window.location.origin const server =
store.dispatch('setInstanceOption', { name: 'server', value: server }) typeof overrides.target !== 'undefined'
? overrides.target
: window.location.origin
useInstanceStore().set({ path: 'server', value: server })
await setConfig({ store }) await setConfig({ store })
try { try {
await useInterfaceStore().applyTheme().catch((e) => { console.error('Error setting theme', e) }) await useInterfaceStore()
.applyTheme()
.catch((e) => {
console.error('Error setting theme', e)
})
} catch (e) { } catch (e) {
window.splashError(e) window.splashError(e)
return Promise.reject(e) return Promise.reject(e)
} }
applyConfig(store.state.config, i18n.global) applyStyleConfig(useMergedConfigStore().mergedConfig, i18n.global)
// Now we can try getting the server settings and logging in // Now we can try getting the server settings and logging in
// Most of these are preloaded into the index.html so blocking is minimized // Most of these are preloaded into the index.html so blocking is minimized
@ -429,8 +575,8 @@ const afterStoreSetup = async ({ pinia, store, storageError, i18n }) => {
checkOAuthToken({ store }), checkOAuthToken({ store }),
getInstancePanel({ store }), getInstancePanel({ store }),
getNodeInfo({ store }), getNodeInfo({ store }),
getInstanceConfig({ store }) getInstanceConfig({ store }),
]).catch(e => Promise.reject(e)) ]).catch((e) => Promise.reject(e))
// Start fetching things that don't need to block the UI // Start fetching things that don't need to block the UI
store.dispatch('fetchMutes') store.dispatch('fetchMutes')
@ -443,11 +589,11 @@ const afterStoreSetup = async ({ pinia, store, storageError, i18n }) => {
history: createWebHistory(), history: createWebHistory(),
routes: routes(store), routes: routes(store),
scrollBehavior: (to, _from, savedPosition) => { scrollBehavior: (to, _from, savedPosition) => {
if (to.matched.some(m => m.meta.dontScroll)) { if (to.matched.some((m) => m.meta.dontScroll)) {
return false return false
} }
return savedPosition || { left: 0, top: 0 } return savedPosition || { left: 0, top: 0 }
} },
}) })
useI18nStore().setI18n(i18n) useI18nStore().setI18n(i18n)
@ -469,6 +615,9 @@ const afterStoreSetup = async ({ pinia, store, storageError, i18n }) => {
app.component('FAIcon', FontAwesomeIcon) app.component('FAIcon', FontAwesomeIcon)
app.component('FALayers', FontAwesomeLayers) app.component('FALayers', FontAwesomeLayers)
app.component('Status', Status)
app.component('RichContent', RichContent)
app.component('StillImage', StillImage)
// remove after vue 3.3 // remove after vue 3.3
app.config.unwrapInjectedRef = true app.config.unwrapInjectedRef = true

View file

@ -1,42 +1,28 @@
import PublicTimeline from 'components/public_timeline/public_timeline.vue' import { defineAsyncComponent } from 'vue'
import BubbleTimeline from 'components/bubble_timeline/bubble_timeline.vue'
import PublicAndExternalTimeline from 'components/public_and_external_timeline/public_and_external_timeline.vue' import BookmarkTimeline from 'src/components/bookmark_timeline/bookmark_timeline.vue'
import FriendsTimeline from 'components/friends_timeline/friends_timeline.vue' import BubbleTimeline from 'src/components/bubble_timeline/bubble_timeline.vue'
import TagTimeline from 'components/tag_timeline/tag_timeline.vue' import ConversationPage from 'src/components/conversation-page/conversation-page.vue'
import BookmarkTimeline from 'components/bookmark_timeline/bookmark_timeline.vue' import DMs from 'src/components/dm_timeline/dm_timeline.vue'
import ConversationPage from 'components/conversation-page/conversation-page.vue' import FriendsTimeline from 'src/components/friends_timeline/friends_timeline.vue'
import Interactions from 'components/interactions/interactions.vue'
import DMs from 'components/dm_timeline/dm_timeline.vue'
import ChatList from 'components/chat_list/chat_list.vue'
import Chat from 'components/chat/chat.vue'
import UserProfile from 'components/user_profile/user_profile.vue'
import Search from 'components/search/search.vue'
import Registration from 'components/registration/registration.vue'
import PasswordReset from 'components/password_reset/password_reset.vue'
import FollowRequests from 'components/follow_requests/follow_requests.vue'
import OAuthCallback from 'components/oauth_callback/oauth_callback.vue'
import Notifications from 'components/notifications/notifications.vue'
import AuthForm from 'components/auth_form/auth_form.js'
import ShoutPanel from 'components/shout_panel/shout_panel.vue'
import WhoToFollow from 'components/who_to_follow/who_to_follow.vue'
import About from 'components/about/about.vue'
import RemoteUserResolver from 'components/remote_user_resolver/remote_user_resolver.vue'
import Lists from 'components/lists/lists.vue'
import ListsTimeline from 'components/lists_timeline/lists_timeline.vue'
import ListsEdit from 'components/lists_edit/lists_edit.vue'
import NavPanel from 'src/components/nav_panel/nav_panel.vue' import NavPanel from 'src/components/nav_panel/nav_panel.vue'
import AnnouncementsPage from 'components/announcements_page/announcements_page.vue' import PublicAndExternalTimeline from 'src/components/public_and_external_timeline/public_and_external_timeline.vue'
import QuotesTimeline from '../components/quotes_timeline/quotes_timeline.vue' import PublicTimeline from 'src/components/public_timeline/public_timeline.vue'
import Drafts from 'components/drafts/drafts.vue' import QuotesTimeline from 'src/components/quotes_timeline/quotes_timeline.vue'
import BookmarkFolders from '../components/bookmark_folders/bookmark_folders.vue' import RemoteUserResolver from 'src/components/remote_user_resolver/remote_user_resolver.vue'
import BookmarkFolderEdit from '../components/bookmark_folder_edit/bookmark_folder_edit.vue' import TagTimeline from 'src/components/tag_timeline/tag_timeline.vue'
import { useInstanceStore } from 'src/stores/instance.js'
import { useInstanceCapabilitiesStore } from 'src/stores/instance_capabilities.js'
export default (store) => { export default (store) => {
const validateAuthenticatedRoute = (to, from, next) => { const validateAuthenticatedRoute = (to, from, next) => {
if (store.state.users.currentUser) { if (store.state.users.currentUser) {
next() next()
} else { } else {
next(store.state.instance.redirectRootNoLogin || '/main/all') next(
useInstanceStore().instanceIdentity.redirectRootNoLogin || '/main/all',
)
} }
} }
@ -45,64 +31,276 @@ export default (store) => {
name: 'root', name: 'root',
path: '/', path: '/',
redirect: () => { redirect: () => {
return (store.state.users.currentUser return (
? store.state.instance.redirectRootLogin (store.state.users.currentUser
: store.state.instance.redirectRootNoLogin) || '/main/all' ? useInstanceStore().instanceIdentity.redirectRootLogin
} : useInstanceStore().instanceIdentity.redirectRootNoLogin) ||
'/main/all'
)
},
},
{
name: 'public-external-timeline',
path: '/main/all',
component: PublicAndExternalTimeline,
},
{
name: 'public-timeline',
path: '/main/public',
component: PublicTimeline,
},
{
name: 'friends',
path: '/main/friends',
component: FriendsTimeline,
beforeEnter: validateAuthenticatedRoute,
}, },
{ name: 'public-external-timeline', path: '/main/all', component: PublicAndExternalTimeline },
{ name: 'public-timeline', path: '/main/public', component: PublicTimeline },
{ name: 'friends', path: '/main/friends', component: FriendsTimeline, beforeEnter: validateAuthenticatedRoute },
{ name: 'tag-timeline', path: '/tag/:tag', component: TagTimeline }, { name: 'tag-timeline', path: '/tag/:tag', component: TagTimeline },
{ name: 'bookmarks', path: '/bookmarks', component: BookmarkTimeline }, { name: 'bookmarks', path: '/bookmarks', component: BookmarkTimeline },
{ name: 'bubble', path: '/bubble', component: BubbleTimeline }, { name: 'bubble', path: '/bubble', component: BubbleTimeline },
{ name: 'conversation', path: '/notice/:id', component: ConversationPage, meta: { dontScroll: true } }, {
name: 'conversation',
path: '/notice/:id',
component: ConversationPage,
meta: { dontScroll: true },
},
{ name: 'quotes', path: '/notice/:id/quotes', component: QuotesTimeline }, { name: 'quotes', path: '/notice/:id/quotes', component: QuotesTimeline },
{ {
name: 'remote-user-profile-acct', name: 'remote-user-profile-acct',
path: '/remote-users/:_(@)?:username([^/@]+)@:hostname([^/@]+)', path: '/remote-users/:_(@)?:username([^/@]+)@:hostname([^/@]+)',
component: RemoteUserResolver, component: RemoteUserResolver,
beforeEnter: validateAuthenticatedRoute beforeEnter: validateAuthenticatedRoute,
}, },
{ {
name: 'remote-user-profile', name: 'remote-user-profile',
path: '/remote-users/:hostname/:username', path: '/remote-users/:hostname/:username',
component: RemoteUserResolver, component: RemoteUserResolver,
beforeEnter: validateAuthenticatedRoute beforeEnter: validateAuthenticatedRoute,
},
{
name: 'external-user-profile',
path: '/users/$:id',
component: defineAsyncComponent(
() => import('src/components/user_profile/user_profile.vue'),
),
},
{
name: 'interactions',
path: '/users/:username/interactions',
component: defineAsyncComponent(
() => import('src/components/interactions/interactions.vue'),
),
beforeEnter: validateAuthenticatedRoute,
},
{
name: 'dms',
path: '/users/:username/dms',
component: DMs,
beforeEnter: validateAuthenticatedRoute,
},
{
name: 'registration',
path: '/registration',
component: defineAsyncComponent(
() => import('src/components/registration/registration.vue'),
),
},
{
name: 'password-reset',
path: '/password-reset',
component: defineAsyncComponent(
() => import('src/components/password_reset/password_reset.vue'),
),
props: true,
},
{
name: 'registration-token',
path: '/registration/:token',
component: defineAsyncComponent(
() => import('src/components/registration/registration.vue'),
),
},
{
name: 'friend-requests',
path: '/friend-requests',
component: defineAsyncComponent(
() => import('src/components/follow_requests/follow_requests.vue'),
),
beforeEnter: validateAuthenticatedRoute,
},
{
name: 'notifications',
path: '/:username/notifications',
component: defineAsyncComponent(
() => import('src/components/notifications/notifications.vue'),
),
props: () => ({ disableTeleport: true }),
beforeEnter: validateAuthenticatedRoute,
},
{
name: 'login',
path: '/login',
component: defineAsyncComponent(
() => import('src/components/auth_form/auth_form.js'),
),
},
{
name: 'shout-panel',
path: '/shout-panel',
component: defineAsyncComponent(
() => import('src/components/shout_panel/shout_panel.vue'),
),
props: () => ({ floating: false }),
},
{
name: 'oauth-callback',
path: '/oauth-callback',
component: defineAsyncComponent(
() => import('src/components/oauth_callback/oauth_callback.vue'),
),
props: (route) => ({ code: route.query.code }),
},
{
name: 'search',
path: '/search',
component: defineAsyncComponent(
() => import('src/components/search/search.vue'),
),
props: (route) => ({ query: route.query.query }),
},
{
name: 'who-to-follow',
path: '/who-to-follow',
component: defineAsyncComponent(
() => import('src/components/who_to_follow/who_to_follow.vue'),
),
beforeEnter: validateAuthenticatedRoute,
},
{
name: 'about',
path: '/about',
component: defineAsyncComponent(
() => import('src/components/about/about.vue'),
),
},
{
name: 'announcements',
path: '/announcements',
component: defineAsyncComponent(
() =>
import('src/components/announcements_page/announcements_page.vue'),
),
},
{
name: 'drafts',
path: '/drafts',
component: defineAsyncComponent(
() => import('src/components/drafts/drafts.vue'),
),
},
{
name: 'user-profile',
path: '/users/:name',
component: defineAsyncComponent(
() => import('src/components/user_profile/user_profile.vue'),
),
},
{
name: 'legacy-user-profile',
path: '/:name',
component: defineAsyncComponent(
() => import('src/components/user_profile/user_profile.vue'),
),
},
{
name: 'lists',
path: '/lists',
component: defineAsyncComponent(
() => import('src/components/lists/lists.vue'),
),
},
{
name: 'lists-timeline',
path: '/lists/:id',
component: defineAsyncComponent(
() => import('src/components/lists_timeline/lists_timeline.vue'),
),
},
{
name: 'lists-edit',
path: '/lists/:id/edit',
component: defineAsyncComponent(
() => import('src/components/lists_edit/lists_edit.vue'),
),
},
{
name: 'lists-new',
path: '/lists/new',
component: defineAsyncComponent(
() => import('src/components/lists_edit/lists_edit.vue'),
),
},
{
name: 'edit-navigation',
path: '/nav-edit',
component: NavPanel,
props: () => ({ forceExpand: true, forceEditMode: true }),
beforeEnter: validateAuthenticatedRoute,
},
{
name: 'bookmark-folders',
path: '/bookmark_folders',
component: defineAsyncComponent(
() => import('src/components/bookmark_folders/bookmark_folders.vue'),
),
},
{
name: 'bookmark-folder-new',
path: '/bookmarks/new-folder',
component: defineAsyncComponent(
() =>
import(
'src/components/bookmark_folder_edit/bookmark_folder_edit.vue'
),
),
},
{
name: 'bookmark-folder',
path: '/bookmarks/:id',
component: BookmarkTimeline,
},
{
name: 'bookmark-folder-edit',
path: '/bookmarks/:id/edit',
component: defineAsyncComponent(
() =>
import(
'src/components/bookmark_folder_edit/bookmark_folder_edit.vue'
),
),
}, },
{ name: 'external-user-profile', path: '/users/$:id', component: UserProfile },
{ name: 'interactions', path: '/users/:username/interactions', component: Interactions, beforeEnter: validateAuthenticatedRoute },
{ name: 'dms', path: '/users/:username/dms', component: DMs, beforeEnter: validateAuthenticatedRoute },
{ name: 'registration', path: '/registration', component: Registration },
{ name: 'password-reset', path: '/password-reset', component: PasswordReset, props: true },
{ name: 'registration-token', path: '/registration/:token', component: Registration },
{ name: 'friend-requests', path: '/friend-requests', component: FollowRequests, beforeEnter: validateAuthenticatedRoute },
{ name: 'notifications', path: '/:username/notifications', component: Notifications, props: () => ({ disableTeleport: true }), beforeEnter: validateAuthenticatedRoute },
{ name: 'login', path: '/login', component: AuthForm },
{ name: 'shout-panel', path: '/shout-panel', component: ShoutPanel, props: () => ({ floating: false }) },
{ name: 'oauth-callback', path: '/oauth-callback', component: OAuthCallback, props: (route) => ({ code: route.query.code }) },
{ name: 'search', path: '/search', component: Search, props: (route) => ({ query: route.query.query }) },
{ name: 'who-to-follow', path: '/who-to-follow', component: WhoToFollow, beforeEnter: validateAuthenticatedRoute },
{ name: 'about', path: '/about', component: About },
{ name: 'announcements', path: '/announcements', component: AnnouncementsPage },
{ name: 'drafts', path: '/drafts', component: Drafts },
{ name: 'user-profile', path: '/users/:name', component: UserProfile },
{ name: 'legacy-user-profile', path: '/:name', component: UserProfile },
{ name: 'lists', path: '/lists', component: Lists },
{ name: 'lists-timeline', path: '/lists/:id', component: ListsTimeline },
{ name: 'lists-edit', path: '/lists/:id/edit', component: ListsEdit },
{ name: 'lists-new', path: '/lists/new', component: ListsEdit },
{ name: 'edit-navigation', path: '/nav-edit', component: NavPanel, props: () => ({ forceExpand: true, forceEditMode: true }), beforeEnter: validateAuthenticatedRoute },
{ name: 'bookmark-folders', path: '/bookmark_folders', component: BookmarkFolders },
{ name: 'bookmark-folder-new', path: '/bookmarks/new-folder', component: BookmarkFolderEdit },
{ name: 'bookmark-folder', path: '/bookmarks/:id', component: BookmarkTimeline },
{ name: 'bookmark-folder-edit', path: '/bookmarks/:id/edit', component: BookmarkFolderEdit }
] ]
if (store.state.instance.pleromaChatMessagesAvailable) { if (useInstanceCapabilitiesStore().pleromaChatMessagesAvailable) {
routes = routes.concat([ routes = routes.concat([
{ name: 'chat', path: '/users/:username/chats/:recipient_id', component: Chat, meta: { dontScroll: false }, beforeEnter: validateAuthenticatedRoute }, {
{ name: 'chats', path: '/users/:username/chats', component: ChatList, meta: { dontScroll: false }, beforeEnter: validateAuthenticatedRoute } name: 'chat',
path: '/users/:username/chats/:recipient_id',
component: defineAsyncComponent(
() => import('src/components/chat/chat.vue'),
),
meta: { dontScroll: false },
beforeEnter: validateAuthenticatedRoute,
},
{
name: 'chats',
path: '/users/:username/chats',
component: defineAsyncComponent(
() => import('src/components/chat_list/chat_list.vue'),
),
meta: { dontScroll: false },
beforeEnter: validateAuthenticatedRoute,
},
]) ])
} }

View file

@ -1,8 +1,16 @@
import InstanceSpecificPanel from '../instance_specific_panel/instance_specific_panel.vue' import { mapState } from 'pinia'
import FeaturesPanel from '../features_panel/features_panel.vue'
import TermsOfServicePanel from '../terms_of_service_panel/terms_of_service_panel.vue' import FeaturesPanel from 'src/components/features_panel/features_panel.vue'
import StaffPanel from '../staff_panel/staff_panel.vue' import InstanceSpecificPanel from 'src/components/instance_specific_panel/instance_specific_panel.vue'
import MRFTransparencyPanel from '../mrf_transparency_panel/mrf_transparency_panel.vue' import MRFTransparencyPanel from 'src/components/mrf_transparency_panel/mrf_transparency_panel.vue'
import StaffPanel from 'src/components/staff_panel/staff_panel.vue'
import TermsOfServicePanel from 'src/components/terms_of_service_panel/terms_of_service_panel.vue'
import { useInstanceStore } from 'src/stores/instance.js'
import { useMergedConfigStore } from 'src/stores/merged_config.js'
const pleromaFeCommitUrl =
'https://git.pleroma.social/pleroma/pleroma-fe/commit/'
const About = { const About = {
components: { components: {
@ -10,16 +18,28 @@ const About = {
FeaturesPanel, FeaturesPanel,
TermsOfServicePanel, TermsOfServicePanel,
StaffPanel, StaffPanel,
MRFTransparencyPanel MRFTransparencyPanel,
}, },
computed: { computed: {
showFeaturesPanel () { return this.$store.state.instance.showFeaturesPanel }, showFeaturesPanel() {
showInstanceSpecificPanel () { return useInstanceStore().instanceIdentity.showFeaturesPanel
return this.$store.state.instance.showInstanceSpecificPanel && },
!this.$store.getters.mergedConfig.hideISP && frontendVersionLink() {
this.$store.state.instance.instanceSpecificPanelContent return pleromaFeCommitUrl + this.frontendVersion
} },
} ...mapState(useInstanceStore, [
'backendVersion',
'backendRepository',
'frontendVersion',
]),
showInstanceSpecificPanel() {
return (
useInstanceStore().instanceIdentity.showInstanceSpecificPanel &&
!useMergedConfigStore().mergedConfig.hideISP &&
useInstanceStore().instanceIdentity.instanceSpecificPanelContent
)
},
},
} }
export default About export default About

View file

@ -1,11 +1,47 @@
<template> <template>
<div class="column-inner"> <div class="About column-inner">
<instance-specific-panel v-if="showInstanceSpecificPanel" /> <instance-specific-panel v-if="showInstanceSpecificPanel" />
<staff-panel /> <staff-panel />
<terms-of-service-panel /> <terms-of-service-panel />
<MRFTransparencyPanel /> <MRFTransparencyPanel />
<features-panel v-if="showFeaturesPanel" /> <features-panel v-if="showFeaturesPanel" />
<div class="panel panel-default">
<div class="panel-heading">
<div class="title">
{{ $t('settings.version.title') }}
</div>
</div>
<div class="panel-body">
<dl>
<dt>{{ $t('settings.version.backend_version') }}</dt>
<dd>
<a
:href="backendRepository"
target="_blank"
>
{{ backendVersion }}
</a>
</dd>
<dt>{{ $t('settings.version.frontend_version') }}</dt>
<dd>
<a
:href="frontendVersionLink"
target="_blank"
>
{{ frontendVersion }}
</a>
</dd>
</dl>
</div>
</div>
</div> </div>
</template> </template>
<script src="./about.js"></script> <script src="./about.js"></script>
<style>
.About {
dl {
padding-left: 1em;
}
}
</style>

View file

@ -1,53 +1,58 @@
import { mapState } from 'vuex' import { mapState } from 'pinia'
import ProgressButton from '../progress_button/progress_button.vue' import { defineAsyncComponent } from 'vue'
import Popover from '../popover/popover.vue'
import Popover from 'src/components/popover/popover.vue'
import ProgressButton from 'src/components/progress_button/progress_button.vue'
import UserListMenu from 'src/components/user_list_menu/user_list_menu.vue' import UserListMenu from 'src/components/user_list_menu/user_list_menu.vue'
import ConfirmModal from '../confirm_modal/confirm_modal.vue'
import UserTimedFilterModal from 'src/components/user_timed_filter_modal/user_timed_filter_modal.vue' import { useInstanceCapabilitiesStore } from 'src/stores/instance_capabilities.js'
import { library } from '@fortawesome/fontawesome-svg-core' import { useMergedConfigStore } from 'src/stores/merged_config.js'
import {
faEllipsisV
} from '@fortawesome/free-solid-svg-icons'
import { useReportsStore } from 'src/stores/reports' import { useReportsStore } from 'src/stores/reports'
library.add( import { library } from '@fortawesome/fontawesome-svg-core'
faEllipsisV import { faEllipsisV } from '@fortawesome/free-solid-svg-icons'
)
library.add(faEllipsisV)
const AccountActions = { const AccountActions = {
props: [ props: ['user', 'relationship'],
'user', 'relationship' data() {
],
data () {
return { return {
showingConfirmBlock: false, showingConfirmBlock: false,
showingConfirmRemoveFollower: false showingConfirmRemoveFollower: false,
} }
}, },
components: { components: {
ProgressButton, ProgressButton,
Popover, Popover,
UserListMenu, UserListMenu,
ConfirmModal, ConfirmModal: defineAsyncComponent(
UserTimedFilterModal () => import('src/components/confirm_modal/confirm_modal.vue'),
),
UserTimedFilterModal: defineAsyncComponent(
() =>
import(
'src/components/user_timed_filter_modal/user_timed_filter_modal.vue'
),
),
}, },
methods: { methods: {
showConfirmRemoveUserFromFollowers () { showConfirmRemoveUserFromFollowers() {
this.showingConfirmRemoveFollower = true this.showingConfirmRemoveFollower = true
}, },
hideConfirmRemoveUserFromFollowers () { hideConfirmRemoveUserFromFollowers() {
this.showingConfirmRemoveFollower = false this.showingConfirmRemoveFollower = false
}, },
hideConfirmBlock () { hideConfirmBlock() {
this.showingConfirmBlock = false this.showingConfirmBlock = false
}, },
showRepeats () { showRepeats() {
this.$store.dispatch('showReblogs', this.user.id) this.$store.dispatch('showReblogs', this.user.id)
}, },
hideRepeats () { hideRepeats() {
this.$store.dispatch('hideReblogs', this.user.id) this.$store.dispatch('hideReblogs', this.user.id)
}, },
blockUser () { blockUser() {
if (this.$refs.timedBlockDialog) { if (this.$refs.timedBlockDialog) {
this.$refs.timedBlockDialog.optionallyPrompt() this.$refs.timedBlockDialog.optionallyPrompt()
} else { } else {
@ -58,46 +63,49 @@ const AccountActions = {
} }
} }
}, },
doBlockUser () { doBlockUser() {
this.$store.dispatch('blockUser', { id: this.user.id }) this.$store.dispatch('blockUser', { id: this.user.id })
this.hideConfirmBlock() this.hideConfirmBlock()
}, },
unblockUser () { unblockUser() {
this.$store.dispatch('unblockUser', this.user.id) this.$store.dispatch('unblockUser', this.user.id)
}, },
removeUserFromFollowers () { removeUserFromFollowers() {
if (!this.shouldConfirmRemoveUserFromFollowers) { if (!this.shouldConfirmRemoveUserFromFollowers) {
this.doRemoveUserFromFollowers() this.doRemoveUserFromFollowers()
} else { } else {
this.showConfirmRemoveUserFromFollowers() this.showConfirmRemoveUserFromFollowers()
} }
}, },
doRemoveUserFromFollowers () { doRemoveUserFromFollowers() {
this.$store.dispatch('removeUserFromFollowers', this.user.id) this.$store.dispatch('removeUserFromFollowers', this.user.id)
this.hideConfirmRemoveUserFromFollowers() this.hideConfirmRemoveUserFromFollowers()
}, },
reportUser () { reportUser() {
useReportsStore().openUserReportingModal({ userId: this.user.id }) useReportsStore().openUserReportingModal({ userId: this.user.id })
}, },
openChat () { openChat() {
this.$router.push({ this.$router.push({
name: 'chat', name: 'chat',
params: { username: this.$store.state.users.currentUser.screen_name, recipient_id: this.user.id } params: {
username: this.$store.state.users.currentUser.screen_name,
recipient_id: this.user.id,
},
}) })
} },
}, },
computed: { computed: {
shouldConfirmBlock () { shouldConfirmBlock() {
return this.$store.getters.mergedConfig.modalOnBlock return useMergedConfigStore().mergedConfig.modalOnBlock
}, },
shouldConfirmRemoveUserFromFollowers () { shouldConfirmRemoveUserFromFollowers() {
return this.$store.getters.mergedConfig.modalOnRemoveUserFromFollowers return useMergedConfigStore().mergedConfig.modalOnRemoveUserFromFollowers
}, },
...mapState({ ...mapState(useInstanceCapabilitiesStore, [
blockExpirationSupported: state => state.instance.blockExpiration, 'blockExpiration',
pleromaChatMessagesAvailable: state => state.instance.pleromaChatMessagesAvailable 'pleromaChatMessagesAvailable',
}) ]),
} },
} }
export default AccountActions export default AccountActions

View file

@ -94,8 +94,8 @@
</template> </template>
</Popover> </Popover>
<teleport to="#modal"> <teleport to="#modal">
<confirm-modal <ConfirmModal
v-if="showingConfirmBlock && !blockExpirationSupported" v-if="showingConfirmBlock && !blockExpiration"
ref="blockDialog" ref="blockDialog"
:title="$t('user_card.block_confirm_title')" :title="$t('user_card.block_confirm_title')"
:confirm-text="$t('user_card.block_confirm_accept_button')" :confirm-text="$t('user_card.block_confirm_accept_button')"
@ -114,10 +114,10 @@
/> />
</template> </template>
</i18n-t> </i18n-t>
</confirm-modal> </ConfirmModal>
</teleport> </teleport>
<teleport to="#modal"> <teleport to="#modal">
<confirm-modal <ConfirmModal
v-if="showingConfirmRemoveFollower" v-if="showingConfirmRemoveFollower"
:title="$t('user_card.remove_follower_confirm_title')" :title="$t('user_card.remove_follower_confirm_title')"
:confirm-text="$t('user_card.remove_follower_confirm_accept_button')" :confirm-text="$t('user_card.remove_follower_confirm_accept_button')"
@ -136,9 +136,9 @@
/> />
</template> </template>
</i18n-t> </i18n-t>
</confirm-modal> </ConfirmModal>
<UserTimedFilterModal <UserTimedFilterModal
v-if="blockExpirationSupported" v-if="blockExpiration"
ref="timedBlockDialog" ref="timedBlockDialog"
:is-mute="false" :is-mute="false"
:user="user" :user="user"

View file

@ -1,57 +1,51 @@
export default { export default {
name: 'Alert', name: 'Alert',
selector: '.alert', selector: '.alert',
validInnerComponents: [ validInnerComponents: ['Text', 'Icon', 'Link', 'Border', 'ButtonUnstyled'],
'Text',
'Icon',
'Link',
'Border',
'ButtonUnstyled'
],
variants: { variants: {
normal: '.neutral', normal: '.neutral',
error: '.error', error: '.error',
warning: '.warning', warning: '.warning',
success: '.success' success: '.success',
}, },
editor: { editor: {
border: 1, border: 1,
aspect: '3 / 1' aspect: '3 / 1',
}, },
defaultRules: [ defaultRules: [
{ {
directives: { directives: {
background: '--text', background: '--text',
opacity: 0.5, opacity: 0.5,
blur: '9px' blur: '9px',
} },
}, },
{ {
parent: { parent: {
component: 'Alert' component: 'Alert',
}, },
component: 'Border', component: 'Border',
directives: { directives: {
textColor: '--parent' textColor: '--parent',
} },
}, },
{ {
variant: 'error', variant: 'error',
directives: { directives: {
background: '--cRed' background: '--cRed',
} },
}, },
{ {
variant: 'warning', variant: 'warning',
directives: { directives: {
background: '--cOrange' background: '--cOrange',
} },
}, },
{ {
variant: 'success', variant: 'success',
directives: { directives: {
background: '--cGreen' background: '--cGreen',
} },
} },
] ],
} }

View file

@ -1,109 +1,128 @@
import { mapState } from 'vuex' import { mapState } from 'vuex'
import AnnouncementEditor from '../announcement_editor/announcement_editor.vue'
import RichContent from '../rich_content/rich_content.jsx' import AnnouncementEditor from 'src/components/announcement_editor/announcement_editor.vue'
import localeService from '../../services/locale/locale.service.js' import localeService from '../../services/locale/locale.service.js'
import { useAnnouncementsStore } from 'src/stores/announcements'
import { useAnnouncementsStore } from 'src/stores/announcements.js'
const Announcement = { const Announcement = {
components: { components: {
AnnouncementEditor, AnnouncementEditor,
RichContent
}, },
data () { data() {
return { return {
editing: false, editing: false,
editedAnnouncement: { editedAnnouncement: {
content: '', content: '',
startsAt: undefined, startsAt: undefined,
endsAt: undefined, endsAt: undefined,
allDay: undefined allDay: undefined,
}, },
editError: '' editError: '',
} }
}, },
props: { props: {
announcement: Object announcement: Object,
}, },
computed: { computed: {
...mapState({ ...mapState({
currentUser: state => state.users.currentUser currentUser: (state) => state.users.currentUser,
}), }),
canEditAnnouncement () { canEditAnnouncement() {
return this.currentUser && this.currentUser.privileges.includes('announcements_manage_announcements') return (
this.currentUser &&
this.currentUser.privileges.includes(
'announcements_manage_announcements',
)
)
}, },
content () { content() {
return this.announcement.content return this.announcement.content
}, },
isRead () { isRead() {
return this.announcement.read return this.announcement.read
}, },
publishedAt () { publishedAt() {
const time = this.announcement.published_at const time = this.announcement.published_at
if (!time) { if (!time) {
return return
} }
return this.formatTimeOrDate(time, localeService.internalToBrowserLocale(this.$i18n.locale)) return this.formatTimeOrDate(
time,
localeService.internalToBrowserLocale(this.$i18n.locale),
)
}, },
startsAt () { startsAt() {
const time = this.announcement.starts_at const time = this.announcement.starts_at
if (!time) { if (!time) {
return return
} }
return this.formatTimeOrDate(time, localeService.internalToBrowserLocale(this.$i18n.locale)) return this.formatTimeOrDate(
time,
localeService.internalToBrowserLocale(this.$i18n.locale),
)
}, },
endsAt () { endsAt() {
const time = this.announcement.ends_at const time = this.announcement.ends_at
if (!time) { if (!time) {
return return
} }
return this.formatTimeOrDate(time, localeService.internalToBrowserLocale(this.$i18n.locale)) return this.formatTimeOrDate(
time,
localeService.internalToBrowserLocale(this.$i18n.locale),
)
}, },
inactive () { inactive() {
return this.announcement.inactive return this.announcement.inactive
} },
}, },
methods: { methods: {
markAsRead () { markAsRead() {
if (!this.isRead) { if (!this.isRead) {
return useAnnouncementsStore().markAnnouncementAsRead(this.announcement.id) return useAnnouncementsStore().markAnnouncementAsRead(
this.announcement.id,
)
} }
}, },
deleteAnnouncement () { deleteAnnouncement() {
return useAnnouncementsStore().deleteAnnouncement(this.announcement.id) return useAnnouncementsStore().deleteAnnouncement(this.announcement.id)
}, },
formatTimeOrDate (time, locale) { formatTimeOrDate(time, locale) {
const d = new Date(time) const d = new Date(time)
return this.announcement.all_day ? d.toLocaleDateString(locale) : d.toLocaleString(locale) return this.announcement.all_day
? d.toLocaleDateString(locale)
: d.toLocaleString(locale)
}, },
enterEditMode () { enterEditMode() {
this.editedAnnouncement.content = this.announcement.pleroma.raw_content this.editedAnnouncement.content = this.announcement.pleroma.raw_content
this.editedAnnouncement.startsAt = this.announcement.starts_at this.editedAnnouncement.startsAt = this.announcement.starts_at
this.editedAnnouncement.endsAt = this.announcement.ends_at this.editedAnnouncement.endsAt = this.announcement.ends_at
this.editedAnnouncement.allDay = this.announcement.all_day this.editedAnnouncement.allDay = this.announcement.all_day
this.editing = true this.editing = true
}, },
submitEdit () { submitEdit() {
useAnnouncementsStore().editAnnouncement({ useAnnouncementsStore()
id: this.announcement.id, .editAnnouncement({
...this.editedAnnouncement id: this.announcement.id,
}) ...this.editedAnnouncement,
})
.then(() => { .then(() => {
this.editing = false this.editing = false
}) })
.catch(error => { .catch((error) => {
this.editError = error.error this.editError = error.error
}) })
}, },
cancelEdit () { cancelEdit() {
this.editing = false this.editing = false
}, },
clearError () { clearError() {
this.editError = undefined this.editError = undefined
} },
} },
} }
export default Announcement export default Announcement

View file

@ -1,13 +1,13 @@
import Checkbox from '../checkbox/checkbox.vue' import Checkbox from 'src/components/checkbox/checkbox.vue'
const AnnouncementEditor = { const AnnouncementEditor = {
components: { components: {
Checkbox Checkbox,
}, },
props: { props: {
announcement: Object, announcement: Object,
disabled: Boolean disabled: Boolean,
} },
} }
export default AnnouncementEditor export default AnnouncementEditor

View file

@ -1,59 +1,67 @@
import { mapState } from 'vuex' import { mapState } from 'vuex'
import Announcement from '../announcement/announcement.vue'
import AnnouncementEditor from '../announcement_editor/announcement_editor.vue' import Announcement from 'src/components/announcement/announcement.vue'
import { useAnnouncementsStore } from 'src/stores/announcements' import AnnouncementEditor from 'src/components/announcement_editor/announcement_editor.vue'
import { useAnnouncementsStore } from 'src/stores/announcements.js'
const AnnouncementsPage = { const AnnouncementsPage = {
components: { components: {
Announcement, Announcement,
AnnouncementEditor AnnouncementEditor,
}, },
data () { data() {
return { return {
newAnnouncement: { newAnnouncement: {
content: '', content: '',
startsAt: undefined, startsAt: undefined,
endsAt: undefined, endsAt: undefined,
allDay: false allDay: false,
}, },
posting: false, posting: false,
error: undefined error: undefined,
} }
}, },
mounted () { mounted() {
useAnnouncementsStore().fetchAnnouncements() useAnnouncementsStore().fetchAnnouncements()
}, },
computed: { computed: {
...mapState({ ...mapState({
currentUser: state => state.users.currentUser currentUser: (state) => state.users.currentUser,
}), }),
announcements () { announcements() {
return useAnnouncementsStore().announcements return useAnnouncementsStore().announcements
}, },
canPostAnnouncement () { canPostAnnouncement() {
return this.currentUser && this.currentUser.privileges.includes('announcements_manage_announcements') return (
} this.currentUser &&
this.currentUser.privileges.includes(
'announcements_manage_announcements',
)
)
},
}, },
methods: { methods: {
postAnnouncement () { postAnnouncement() {
this.posting = true this.posting = true
useAnnouncementsStore().postAnnouncement(this.newAnnouncement) useAnnouncementsStore()
.postAnnouncement(this.newAnnouncement)
.then(() => { .then(() => {
this.newAnnouncement.content = '' this.newAnnouncement.content = ''
this.startsAt = undefined this.startsAt = undefined
this.endsAt = undefined this.endsAt = undefined
}) })
.catch(error => { .catch((error) => {
this.error = error.error this.error = error.error
}) })
.finally(() => { .finally(() => {
this.posting = false this.posting = false
}) })
}, },
clearError () { clearError() {
this.error = undefined this.error = undefined
} },
} },
} }
export default AnnouncementsPage export default AnnouncementsPage

View file

@ -21,10 +21,10 @@
export default { export default {
emits: ['resetAsyncComponent'], emits: ['resetAsyncComponent'],
methods: { methods: {
retry () { retry() {
this.$emit('resetAsyncComponent') this.$emit('resetAsyncComponent')
} },
} },
} }
</script> </script>

View file

@ -1,24 +1,29 @@
import StillImage from '../still-image/still-image.vue' import { mapState } from 'pinia'
import Flash from '../flash/flash.vue' import { defineAsyncComponent } from 'vue'
import VideoAttachment from '../video_attachment/video_attachment.vue'
import Popover from 'src/components/popover/popover.vue'
import VideoAttachment from 'src/components/video_attachment/video_attachment.vue'
import nsfwImage from '../../assets/nsfw.png' import nsfwImage from '../../assets/nsfw.png'
import fileTypeService from '../../services/file_type/file_type.service.js'
import { mapGetters } from 'vuex' 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 { library } from '@fortawesome/fontawesome-svg-core'
import { import {
faAlignRight,
faFile, faFile,
faMusic,
faImage, faImage,
faVideo, faMusic,
faPlayCircle,
faTimes,
faStop,
faSearchPlus,
faTrashAlt,
faPencilAlt, faPencilAlt,
faAlignRight faPlayCircle,
faSearchPlus,
faStop,
faTimes,
faTrashAlt,
faVideo,
} from '@fortawesome/free-solid-svg-icons' } from '@fortawesome/free-solid-svg-icons'
import { useMediaViewerStore } from 'src/stores/media_viewer'
library.add( library.add(
faFile, faFile,
@ -31,7 +36,7 @@ library.add(
faSearchPlus, faSearchPlus,
faTrashAlt, faTrashAlt,
faPencilAlt, faPencilAlt,
faAlignRight faAlignRight,
) )
const Attachment = { const Attachment = {
@ -46,72 +51,74 @@ const Attachment = {
'remove', 'remove',
'shiftUp', 'shiftUp',
'shiftDn', 'shiftDn',
'edit' 'edit',
], ],
data () { data() {
return { return {
localDescription: this.description || this.attachment.description, localDescription: this.description || this.attachment.description,
nsfwImage: this.$store.state.instance.nsfwCensorImage || nsfwImage, nsfwImage:
hideNsfwLocal: this.$store.getters.mergedConfig.hideNsfw, useInstanceStore().instanceIdentity.nsfwCensorImage || nsfwImage,
preloadImage: this.$store.getters.mergedConfig.preloadImage, hideNsfwLocal: useMergedConfigStore().mergedConfig.hideNsfw,
preloadImage: useMergedConfigStore().mergedConfig.preloadImage,
loading: false, loading: false,
img: fileTypeService.fileType(this.attachment.mimetype) === 'image' && document.createElement('img'), img: this.attachment.type === 'image' && document.createElement('img'),
modalOpen: false, modalOpen: false,
showHidden: false, showHidden: false,
flashLoaded: false, flashLoaded: false,
showDescription: false
} }
}, },
components: { components: {
Flash, Flash: defineAsyncComponent(() => import('src/components/flash/flash.vue')),
StillImage,
VideoAttachment VideoAttachment: defineAsyncComponent(
() => import('src/components/video_attachment/video_attachment.vue'),
),
Popover,
}, },
computed: { computed: {
classNames () { classNames() {
return [ return [
{ {
'-loading': this.loading, '-loading': this.loading,
'-nsfw-placeholder': this.hidden, '-nsfw-placeholder': this.hidden,
'-editable': this.edit !== undefined, '-editable': this.edit !== undefined,
'-compact': this.compact '-compact': this.compact,
}, },
'-type-' + this.type, '-type-' + this.attachment.type,
this.size && '-size-' + this.size, this.size && '-size-' + this.size,
`-${this.useContainFit ? 'contain' : 'cover'}-fit` `-${this.useContainFit ? 'contain' : 'cover'}-fit`,
] ]
}, },
usePlaceholder () { usePlaceholder() {
return this.size === 'hide' return this.size === 'hide'
}, },
useContainFit () { useContainFit() {
return this.$store.getters.mergedConfig.useContainFit return this.mergedConfig.useContainFit
}, },
placeholderName () { placeholderName() {
if (this.attachment.description === '' || !this.attachment.description) { if (this.attachment.description === '' || !this.attachment.description) {
return this.type.toUpperCase() return this.attachment.type.toUpperCase()
} }
return this.attachment.description return this.attachment.description
}, },
placeholderIconClass () { placeholderIconClass() {
if (this.type === 'image') return 'image' if (this.attachment.type === 'image') return 'image'
if (this.type === 'video') return 'video' if (this.attachment.type === 'video') return 'video'
if (this.type === 'audio') return 'music' if (this.attachment.type === 'audio') return 'music'
return 'file' return 'file'
}, },
referrerpolicy () { referrerpolicy() {
return this.$store.state.instance.mediaProxyAvailable ? '' : 'no-referrer' return useInstanceCapabilitiesStore().mediaProxyAvailable
? ''
: 'no-referrer'
}, },
type () { hidden() {
return fileTypeService.fileType(this.attachment.mimetype)
},
hidden () {
return this.nsfw && this.hideNsfwLocal && !this.showHidden return this.nsfw && this.hideNsfwLocal && !this.showHidden
}, },
isEmpty () { isEmpty() {
return (this.type === 'html' && !this.attachment.oembed) return this.attachment.type === 'html' && !this.attachment.oembed
}, },
useModal () { useModal() {
let modalTypes = [] let modalTypes = []
switch (this.size) { switch (this.size) {
case 'hide': case 'hide':
@ -124,64 +131,63 @@ const Attachment = {
: ['image'] : ['image']
break break
} }
return modalTypes.includes(this.type) return modalTypes.includes(this.attachment.type)
}, },
videoTag () { videoTag() {
return this.useModal ? 'button' : 'span' return this.useModal ? 'button' : 'span'
}, },
...mapGetters(['mergedConfig']) ...mapState(useMergedConfigStore, ['mergedConfig']),
}, },
watch: { watch: {
'attachment.description' (newVal) { 'attachment.description'(newVal) {
this.localDescription = newVal this.localDescription = newVal
}, },
localDescription (newVal) { localDescription(newVal) {
this.onEdit(newVal) this.onEdit(newVal)
} },
}, },
methods: { methods: {
linkClicked ({ target }) { linkClicked({ target }) {
if (target.tagName === 'A') { if (target.tagName === 'A') {
window.open(target.href, '_blank') window.open(target.href, '_blank')
} }
}, },
openModal () { openModal() {
if (this.useModal) { if (this.useModal) {
this.$emit('setMedia') this.$emit('setMedia')
useMediaViewerStore().setCurrentMedia(this.attachment) useMediaViewerStore().setCurrentMedia(this.attachment)
} else if (this.type === 'unknown') { } else if (this.attachment.type === 'unknown') {
window.open(this.attachment.url) window.open(this.attachment.url)
} }
}, },
openModalForce () { openModalForce() {
this.$emit('setMedia') this.$emit('setMedia')
useMediaViewerStore().setCurrentMedia(this.attachment) useMediaViewerStore().setCurrentMedia(this.attachment)
}, },
onEdit (event) { onEdit(event) {
this.edit && this.edit(this.attachment, event) this.edit && this.edit(this.attachment, event)
}, },
onRemove () { onRemove() {
this.remove && this.remove(this.attachment) this.remove && this.remove(this.attachment)
}, },
onShiftUp () { onShiftUp() {
this.shiftUp && this.shiftUp(this.attachment) this.shiftUp && this.shiftUp(this.attachment)
}, },
onShiftDn () { onShiftDn() {
this.shiftDn && this.shiftDn(this.attachment) this.shiftDn && this.shiftDn(this.attachment)
}, },
stopFlash () { stopFlash() {
this.$refs.flash.closePlayer() this.$refs.flash.closePlayer()
}, },
setFlashLoaded (event) { setFlashLoaded(event) {
this.flashLoaded = event this.flashLoaded = event
}, },
toggleDescription () { toggleHidden(event) {
this.showDescription = !this.showDescription
},
toggleHidden (event) {
if ( if (
(this.mergedConfig.useOneClickNsfw && !this.showHidden) && this.mergedConfig.useOneClickNsfw &&
(this.type !== 'video' || this.mergedConfig.playVideosInModal) !this.showHidden &&
(this.attachment.type !== 'video' ||
this.mergedConfig.playVideosInModal)
) { ) {
this.openModal(event) this.openModal(event)
return return
@ -201,12 +207,12 @@ const Attachment = {
this.showHidden = !this.showHidden this.showHidden = !this.showHidden
} }
}, },
onImageLoad (image) { onImageLoad(image) {
const width = image.naturalWidth const width = image.naturalWidth
const height = image.naturalHeight const height = image.naturalHeight
this.$emit('naturalSizeLoad', { id: this.attachment.id, width, height }) this.$emit('naturalSizeLoad', { id: this.attachment.id, width, height })
} },
} },
} }
export default Attachment export default Attachment

View file

@ -134,7 +134,7 @@
width: 2em; width: 2em;
height: 2em; height: 2em;
margin-left: 0.5em; margin-left: 0.5em;
font-size: 1.25em; font-size: 1em;
} }
} }
@ -265,3 +265,27 @@
} }
} }
} }
.description-popover {
padding: 1em;
width: 50ch;
max-width: 90vw;
overflow: hidden;
box-sizing: border-box;
summary {
display: inline-block;
margin-bottom: 0.5em;
font-weight: bold;
pointer-events: none;
}
span {
display: block;
overflow-y: auto;
max-height: 10.5em;
text-wrap: pretty;
line-height: 1.5;
white-space: pre-wrap;
}
}

View file

@ -6,7 +6,7 @@
@click="openModal" @click="openModal"
> >
<a <a
v-if="type !== 'html'" v-if="attachment.type !== 'html'"
class="placeholder" class="placeholder"
target="_blank" target="_blank"
:href="attachment.url" :href="attachment.url"
@ -30,21 +30,16 @@
</button> </button>
</div> </div>
<div <div
v-if="size !== 'hide' && !hideDescription && (edit || localDescription || showDescription)" v-if="size !== 'hide' && !hideDescription && edit"
class="description-container" class="description-container"
:class="{ '-static': !edit }" :class="{ '-static': !edit }"
> >
<input <textarea
v-if="edit"
v-model="localDescription" v-model="localDescription"
type="text" type="text"
class="input description-field" class="input description-field"
:placeholder="$t('post_status.media_description')" :placeholder="$t('post_status.media_description')"
@keydown.enter.prevent="" />
>
<p v-else>
{{ localDescription }}
</p>
</div> </div>
</button> </button>
<div <div
@ -70,7 +65,7 @@
:src="nsfwImage" :src="nsfwImage"
> >
<FAIcon <FAIcon
v-if="type === 'video'" v-if="attachment.type === 'video'"
class="play-icon" class="play-icon"
icon="play-circle" icon="play-circle"
/> />
@ -80,23 +75,31 @@
class="attachment-buttons" class="attachment-buttons"
> >
<button <button
v-if="type === 'flash' && flashLoaded" v-if="attachment.type === 'flash' && flashLoaded"
class="button-default attachment-button -transparent" class="button-default attachment-button -transparent"
:title="$t('status.attachment_stop_flash')" :title="$t('status.attachment_stop_flash')"
@click.prevent="stopFlash" @click.prevent="stopFlash"
> >
<FAIcon icon="stop" /> <FAIcon icon="stop" />
</button> </button>
<button <Popover
v-if="attachment.description && size !== 'small' && !edit && type !== 'unknown'" v-if="attachment.description && size !== 'small' && !edit && attachment.type !== 'unknown'"
class="button-default attachment-button -transparent" trigger="click"
:title="$t('status.show_attachment_description')" popover-class="popover popover-default description-popover"
@click.prevent="toggleDescription" :trigger-attrs="{ 'class': 'button-default attachment-button -transparent', 'title': $t('status.attachment_description') }"
> >
<FAIcon icon="align-right" /> <template #trigger>
</button> <FAIcon icon="align-right" />
</template>
<template #content>
<details open>
<summary>{{ $t('status.attachment_description') }}</summary>
<span>{{ localDescription }}</span>
</details>
</template>
</Popover>
<button <button
v-if="!useModal && type !== 'unknown'" v-if="!useModal && attachment.type !== 'unknown'"
class="button-default attachment-button -transparent" class="button-default attachment-button -transparent"
:title="$t('status.show_attachment_in_modal')" :title="$t('status.show_attachment_in_modal')"
@click.prevent="openModalForce" @click.prevent="openModalForce"
@ -138,7 +141,7 @@
</div> </div>
<a <a
v-if="type === 'image' && (!hidden || preloadImage)" v-if="attachment.type === 'image' && (!hidden || preloadImage)"
class="image-container" class="image-container"
:class="{'-hidden': hidden && preloadImage }" :class="{'-hidden': hidden && preloadImage }"
:href="attachment.url" :href="attachment.url"
@ -156,7 +159,7 @@
</a> </a>
<a <a
v-if="type === 'unknown' && !hidden" v-if="attachment.type === 'unknown' && !hidden"
class="placeholder-container" class="placeholder-container"
:href="attachment.url" :href="attachment.url"
target="_blank" target="_blank"
@ -173,7 +176,7 @@
<component <component
:is="videoTag" :is="videoTag"
v-if="type === 'video' && !hidden" v-if="attachment.type === 'video' && !hidden"
class="video-container" class="video-container"
:href="attachment.url" :href="attachment.url"
@click.stop.prevent="openModal" @click.stop.prevent="openModal"
@ -193,13 +196,13 @@
</component> </component>
<span <span
v-if="type === 'audio' && !hidden" v-if="attachment.type === 'audio' && !hidden"
class="audio-container" class="audio-container"
:href="attachment.url" :href="attachment.url"
@click.stop.prevent="openModal" @click.stop.prevent="openModal"
> >
<audio <audio
v-if="type === 'audio'" v-if="attachment.type === 'audio'"
:src="attachment.url" :src="attachment.url"
:alt="attachment.description" :alt="attachment.description"
:title="attachment.description" :title="attachment.description"
@ -210,7 +213,7 @@
</span> </span>
<div <div
v-if="type === 'html' && attachment.oembed" v-if="attachment.type === 'html' && attachment.oembed"
class="oembed-container" class="oembed-container"
@click.prevent="linkClicked" @click.prevent="linkClicked"
> >
@ -229,7 +232,7 @@
</div> </div>
<span <span
v-if="type === 'flash' && !hidden" v-if="attachment.type === 'flash' && !hidden"
class="flash-container" class="flash-container"
:href="attachment.url" :href="attachment.url"
@click.stop.prevent="openModal" @click.stop.prevent="openModal"
@ -244,21 +247,16 @@
</span> </span>
</div> </div>
<div <div
v-if="size !== 'hide' && !hideDescription && (edit || (localDescription && showDescription))" v-if="size !== 'hide' && !hideDescription && edit"
class="description-container" class="description-container"
:class="{ '-static': !edit }" :class="{ '-static': !edit }"
> >
<input <textarea
v-if="edit"
v-model="localDescription" v-model="localDescription"
type="text" type="text"
class="input description-field" class="input description-field"
:placeholder="$t('post_status.media_description')" :placeholder="$t('post_status.media_description')"
@keydown.enter.prevent="" />
>
<p v-else>
{{ localDescription }}
</p>
</div> </div>
</div> </div>
</template> </template>

View file

@ -1,28 +1,34 @@
import { h, resolveComponent } from 'vue'
import LoginForm from '../login_form/login_form.vue'
import MFARecoveryForm from '../mfa_form/recovery_form.vue'
import MFATOTPForm from '../mfa_form/totp_form.vue'
import { mapState } from 'pinia' import { mapState } from 'pinia'
import { useAuthFlowStore } from 'src/stores/auth_flow' import { h, resolveComponent } from 'vue'
import LoginForm from 'src/components/login_form/login_form.vue'
import MFARecoveryForm from 'src/components/mfa_form/recovery_form.vue'
import MFATOTPForm from 'src/components/mfa_form/totp_form.vue'
import { useAuthFlowStore } from 'src/stores/auth_flow.js'
const AuthForm = { const AuthForm = {
name: 'AuthForm', name: 'AuthForm',
render () { render() {
return h(resolveComponent(this.authForm)) return h(resolveComponent(this.authForm))
}, },
computed: { computed: {
authForm () { authForm() {
if (this.requiredTOTP) { return 'MFATOTPForm' } if (this.requiredTOTP) {
if (this.requiredRecovery) { return 'MFARecoveryForm' } return 'MFATOTPForm'
}
if (this.requiredRecovery) {
return 'MFARecoveryForm'
}
return 'LoginForm' return 'LoginForm'
}, },
...mapState(useAuthFlowStore, ['requiredTOTP', 'requiredRecovery']) ...mapState(useAuthFlowStore, ['requiredTOTP', 'requiredRecovery']),
}, },
components: { components: {
MFARecoveryForm, MFARecoveryForm,
MFATOTPForm, MFATOTPForm,
LoginForm LoginForm,
} },
} }
export default AuthForm export default AuthForm

View file

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

View file

@ -1,21 +1,28 @@
import UserAvatar from '../user_avatar/user_avatar.vue' import UserAvatar from 'src/components/user_avatar/user_avatar.vue'
import { useInstanceStore } from 'src/stores/instance.js'
import generateProfileLink from 'src/services/user_profile_link_generator/user_profile_link_generator' import generateProfileLink from 'src/services/user_profile_link_generator/user_profile_link_generator'
const AvatarList = { const AvatarList = {
props: ['users'], props: ['users'],
computed: { computed: {
slicedUsers () { slicedUsers() {
return this.users ? this.users.slice(0, 15) : [] return this.users ? this.users.slice(0, 15) : []
} },
}, },
components: { components: {
UserAvatar UserAvatar,
}, },
methods: { methods: {
userProfileLink (user) { userProfileLink(user) {
return generateProfileLink(user.id, user.screen_name, this.$store.state.instance.restrictedNicknames) return generateProfileLink(
} user.id,
} user.screen_name,
useInstanceStore().restrictedNicknames,
)
},
},
} }
export default AvatarList export default AvatarList

View file

@ -1,30 +1,27 @@
export default { export default {
name: 'Badge', name: 'Badge',
selector: '.badge', selector: '.badge',
validInnerComponents: [ validInnerComponents: ['Text', 'Icon'],
'Text',
'Icon'
],
variants: { variants: {
notification: '.-notification' notification: '.-notification',
}, },
defaultRules: [ defaultRules: [
{ {
component: 'Root', component: 'Root',
directives: { directives: {
'--badgeNotification': 'color | --cRed' '--badgeNotification': 'color | --cRed',
} },
}, },
{ {
directives: { directives: {
background: '--cGreen' background: '--cGreen',
} },
}, },
{ {
variant: 'notification', variant: 'notification',
directives: { directives: {
background: '--cRed' background: '--cRed',
} },
} },
] ],
} }

View file

@ -1,24 +1,34 @@
import UserPopover from '../user_popover/user_popover.vue' import UserAvatar from 'src/components/user_avatar/user_avatar.vue'
import UserAvatar from '../user_avatar/user_avatar.vue' import UserLink from 'src/components/user_link/user_link.vue'
import UserLink from '../user_link/user_link.vue' import UserPopover from 'src/components/user_popover/user_popover.vue'
import RichContent from 'src/components/rich_content/rich_content.jsx'
import { useInstanceStore } from 'src/stores/instance.js'
import { useMergedConfigStore } from 'src/stores/merged_config.js'
import generateProfileLink from 'src/services/user_profile_link_generator/user_profile_link_generator' import generateProfileLink from 'src/services/user_profile_link_generator/user_profile_link_generator'
const BasicUserCard = { const BasicUserCard = {
props: [ props: ['user'],
'user'
],
components: { components: {
UserPopover, UserPopover,
UserAvatar, UserAvatar,
RichContent,
UserLink UserLink,
}, },
methods: { methods: {
userProfileLink (user) { userProfileLink(user) {
return generateProfileLink(user.id, user.screen_name, this.$store.state.instance.restrictedNicknames) return generateProfileLink(
} user.id,
} user.screen_name,
useInstanceStore().restrictedNicknames,
)
},
},
computed: {
allowNonSquareEmoji() {
return useMergedConfigStore().mergedConfig.nonSquareEmoji
},
},
} }
export default BasicUserCard export default BasicUserCard

View file

@ -27,6 +27,7 @@
class="basic-user-card-user-name-value" class="basic-user-card-user-name-value"
:html="user.name" :html="user.name"
:emoji="user.emoji" :emoji="user.emoji"
:allow-non-square-emoji="allowNonSquareEmoji"
/> />
</div> </div>
<div> <div>

View file

@ -1,46 +1,50 @@
import { mapState } from 'vuex' import { mapState } from 'pinia'
import BasicUserCard from '../basic_user_card/basic_user_card.vue' import BasicUserCard from 'src/components/basic_user_card/basic_user_card.vue'
import UserTimedFilterModal from 'src/components/user_timed_filter_modal/user_timed_filter_modal.vue'
import { useInstanceCapabilitiesStore } from 'src/stores/instance_capabilities.js'
const BlockCard = { const BlockCard = {
props: ['userId'], props: ['userId'],
computed: { computed: {
user () { user() {
return this.$store.getters.findUser(this.userId) return this.$store.getters.findUser(this.userId)
}, },
relationship () { relationship() {
return this.$store.getters.relationship(this.userId) return this.$store.getters.relationship(this.userId)
}, },
blocked () { blocked() {
return this.relationship.blocking return this.relationship.blocking
}, },
blockExpiryAvailable () { blockExpiryAvailable() {
return this.user.block_expires_at !== undefined return Object.hasOwn(this.user, 'block_expires_at')
}, },
blockExpiry () { blockExpiry() {
return this.user.block_expires_at == null return this.user.block_expires_at === false
? this.$t('user_card.block_expires_forever') ? this.$t('user_card.block_expires_forever')
: this.$t('user_card.block_expires_at', [new Date(this.user.mute_expires_at).toLocaleString()]) : this.$t('user_card.block_expires_at', [
new Date(this.user.mute_expires_at).toLocaleString(),
])
}, },
...mapState({ ...mapState(useInstanceCapabilitiesStore, ['blockExpiration']),
blockExpirationSupported: state => state.instance.blockExpiration,
})
}, },
components: { components: {
BasicUserCard BasicUserCard,
UserTimedFilterModal,
}, },
methods: { methods: {
unblockUser () { unblockUser() {
this.$store.dispatch('unblockUser', this.user.id) this.$store.dispatch('unblockUser', this.user.id)
}, },
blockUser () { blockUser() {
if (this.blockExpirationSupported) { if (this.blockExpiration) {
this.$refs.timedBlockDialog.optionallyPrompt() this.$refs.timedBlockDialog.optionallyPrompt()
} else { } else {
this.$store.dispatch('blockUser', { id: this.user.id }) this.$store.dispatch('blockUser', { id: this.user.id })
} }
} },
} },
} }
export default BlockCard export default BlockCard

View file

@ -1,22 +1,15 @@
import { library } from '@fortawesome/fontawesome-svg-core' import { library } from '@fortawesome/fontawesome-svg-core'
import { import { faEllipsisH } from '@fortawesome/free-solid-svg-icons'
faEllipsisH
} from '@fortawesome/free-solid-svg-icons'
library.add( library.add(faEllipsisH)
faEllipsisH
)
const BookmarkFolderCard = { const BookmarkFolderCard = {
props: [ props: ['folder', 'allBookmarks'],
'folder',
'allBookmarks'
],
computed: { computed: {
firstLetter () { firstLetter() {
return this.folder ? this.folder.name[0] : null return this.folder ? this.folder.name[0] : null
} },
} },
} }
export default BookmarkFolderCard export default BookmarkFolderCard

View file

@ -1,10 +1,11 @@
import EmojiPicker from '../emoji_picker/emoji_picker.vue' import EmojiPicker from 'src/components/emoji_picker/emoji_picker.vue'
import apiService from '../../services/api/api.service' import apiService from '../../services/api/api.service'
import { useInterfaceStore } from 'src/stores/interface'
import { useBookmarkFoldersStore } from 'src/stores/bookmark_folders' import { useBookmarkFoldersStore } from 'src/stores/bookmark_folders.js'
import { useInterfaceStore } from 'src/stores/interface.js'
const BookmarkFolderEdit = { const BookmarkFolderEdit = {
data () { data() {
return { return {
name: '', name: '',
nameDraft: '', nameDraft: '',
@ -13,54 +14,59 @@ const BookmarkFolderEdit = {
emojiDraft: '', emojiDraft: '',
emojiUrlDraft: null, emojiUrlDraft: null,
emojiPickerExpanded: false, emojiPickerExpanded: false,
reallyDelete: false reallyDelete: false,
} }
}, },
components: { components: {
EmojiPicker EmojiPicker,
}, },
created () { created() {
if (!this.id) return if (!this.id) return
const credentials = this.$store.state.users.currentUser.credentials const credentials = this.$store.state.users.currentUser.credentials
apiService.fetchBookmarkFolders({ credentials }) apiService.fetchBookmarkFolders({ credentials }).then((folders) => {
.then((folders) => { const folder = folders.find((folder) => folder.id === this.id)
const folder = folders.find(folder => folder.id === this.id) if (!folder) return
if (!folder) return
this.nameDraft = this.name = folder.name this.nameDraft = this.name = folder.name
this.emojiDraft = this.emoji = folder.emoji this.emojiDraft = this.emoji = folder.emoji
this.emojiUrlDraft = this.emojiUrl = folder.emoji_url this.emojiUrlDraft = this.emojiUrl = folder.emoji_url
}) })
}, },
computed: { computed: {
id () { id() {
return this.$route.params.id return this.$route.params.id
} },
}, },
methods: { methods: {
selectEmoji (event) { selectEmoji(event) {
this.emojiDraft = event.insertion this.emojiDraft = event.insertion
this.emojiUrlDraft = event.insertionUrl this.emojiUrlDraft = event.insertionUrl
}, },
showEmojiPicker () { showEmojiPicker() {
if (!this.emojiPickerExpanded) { if (!this.emojiPickerExpanded) {
this.$refs.picker.showPicker() this.$refs.picker.showPicker()
} }
}, },
onShowPicker () { onShowPicker() {
this.emojiPickerExpanded = true this.emojiPickerExpanded = true
}, },
onClosePicker () { onClosePicker() {
this.emojiPickerExpanded = false this.emojiPickerExpanded = false
}, },
updateFolder () { updateFolder() {
useBookmarkFoldersStore().updateBookmarkFolder({ folderId: this.id, name: this.nameDraft, emoji: this.emojiDraft }) useBookmarkFoldersStore()
.updateBookmarkFolder({
folderId: this.id,
name: this.nameDraft,
emoji: this.emojiDraft,
})
.then(() => { .then(() => {
this.$router.push({ name: 'bookmark-folders' }) this.$router.push({ name: 'bookmark-folders' })
}) })
}, },
createFolder () { createFolder() {
useBookmarkFoldersStore().createBookmarkFolder({ name: this.nameDraft, emoji: this.emojiDraft }) useBookmarkFoldersStore()
.createBookmarkFolder({ name: this.nameDraft, emoji: this.emojiDraft })
.then(() => { .then(() => {
this.$router.push({ name: 'bookmark-folders' }) this.$router.push({ name: 'bookmark-folders' })
}) })
@ -68,15 +74,15 @@ const BookmarkFolderEdit = {
useInterfaceStore().pushGlobalNotice({ useInterfaceStore().pushGlobalNotice({
messageKey: 'bookmark_folders.error', messageKey: 'bookmark_folders.error',
messageArgs: [e.message], messageArgs: [e.message],
level: 'error' level: 'error',
}) })
}) })
}, },
deleteFolder () { deleteFolder() {
useBookmarkFoldersStore().deleteBookmarkFolder({ folderId: this.id }) useBookmarkFoldersStore().deleteBookmarkFolder({ folderId: this.id })
this.$router.push({ name: 'bookmark-folders' }) this.$router.push({ name: 'bookmark-folders' })
} },
} },
} }
export default BookmarkFolderEdit export default BookmarkFolderEdit

View file

@ -1,28 +1,29 @@
import BookmarkFolderCard from '../bookmark_folder_card/bookmark_folder_card.vue' import BookmarkFolderCard from 'src/components/bookmark_folder_card/bookmark_folder_card.vue'
import { useBookmarkFoldersStore } from 'src/stores/bookmark_folders'
import { useBookmarkFoldersStore } from 'src/stores/bookmark_folders.js'
const BookmarkFolders = { const BookmarkFolders = {
data () { data() {
return { return {
isNew: false isNew: false,
} }
}, },
components: { components: {
BookmarkFolderCard BookmarkFolderCard,
}, },
computed: { computed: {
bookmarkFolders () { bookmarkFolders() {
return useBookmarkFoldersStore().allFolders return useBookmarkFoldersStore().allFolders
} },
}, },
methods: { methods: {
cancelNewFolder () { cancelNewFolder() {
this.isNew = false this.isNew = false
}, },
newFolder () { newFolder() {
this.isNew = true this.isNew = true
} },
} },
} }
export default BookmarkFolders export default BookmarkFolders

View file

@ -1,20 +1,20 @@
import { mapState } from 'pinia' import { mapState } from 'pinia'
import NavigationEntry from 'src/components/navigation/navigation_entry.vue'
import { getBookmarkFolderEntries } from 'src/components/navigation/filter.js' import { getBookmarkFolderEntries } from 'src/components/navigation/filter.js'
import { useBookmarkFoldersStore } from 'src/stores/bookmark_folders' import NavigationEntry from 'src/components/navigation/navigation_entry.vue'
import { useBookmarkFoldersStore } from 'src/stores/bookmark_folders.js'
export const BookmarkFoldersMenuContent = { export const BookmarkFoldersMenuContent = {
props: [ props: ['showPin'],
'showPin'
],
components: { components: {
NavigationEntry NavigationEntry,
}, },
computed: { computed: {
...mapState(useBookmarkFoldersStore, { ...mapState(useBookmarkFoldersStore, {
folders: getBookmarkFolderEntries folders: getBookmarkFolderEntries,
}) }),
} },
} }
export default BookmarkFoldersMenuContent export default BookmarkFoldersMenuContent

View file

@ -1,32 +1,38 @@
import Timeline from '../timeline/timeline.vue' import Timeline from 'src/components/timeline/timeline.vue'
const Bookmarks = { const Bookmarks = {
created () { created() {
this.$store.commit('clearTimeline', { timeline: 'bookmarks' }) this.$store.commit('clearTimeline', { timeline: 'bookmarks' })
this.$store.dispatch('startFetchingTimeline', { timeline: 'bookmarks', bookmarkFolderId: this.folderId || null }) this.$store.dispatch('startFetchingTimeline', {
timeline: 'bookmarks',
bookmarkFolderId: this.folderId || null,
})
}, },
components: { components: {
Timeline Timeline,
}, },
computed: { computed: {
folderId () { folderId() {
return this.$route.params.id return this.$route.params.id
}, },
timeline () { timeline() {
return this.$store.state.statuses.timelines.bookmarks return this.$store.state.statuses.timelines.bookmarks
} },
}, },
watch: { watch: {
folderId () { folderId() {
this.$store.commit('clearTimeline', { timeline: 'bookmarks' }) this.$store.commit('clearTimeline', { timeline: 'bookmarks' })
this.$store.dispatch('stopFetchingTimeline', 'bookmarks') this.$store.dispatch('stopFetchingTimeline', 'bookmarks')
this.$store.dispatch('startFetchingTimeline', { timeline: 'bookmarks', bookmarkFolderId: this.folderId || null }) this.$store.dispatch('startFetchingTimeline', {
} timeline: 'bookmarks',
bookmarkFolderId: this.folderId || null,
})
},
}, },
unmounted () { unmounted() {
this.$store.commit('clearTimeline', { timeline: 'bookmarks' }) this.$store.commit('clearTimeline', { timeline: 'bookmarks' })
this.$store.dispatch('stopFetchingTimeline', 'bookmarks') this.$store.dispatch('stopFetchingTimeline', 'bookmarks')
} },
} }
export default Bookmarks export default Bookmarks

View file

@ -6,8 +6,8 @@ export default {
{ {
directives: { directives: {
textColor: '$mod(--parent 10)', textColor: '$mod(--parent 10)',
textAuto: 'no-auto' textAuto: 'no-auto',
} },
} },
] ],
} }

View file

@ -1,18 +1,20 @@
import Timeline from '../timeline/timeline.vue' import Timeline from 'src/components/timeline/timeline.vue'
const BubbleTimeline = { const BubbleTimeline = {
components: { components: {
Timeline Timeline,
}, },
computed: { computed: {
timeline () { return this.$store.state.statuses.timelines.bubble } timeline() {
return this.$store.state.statuses.timelines.bubble
},
}, },
created () { created() {
this.$store.dispatch('startFetchingTimeline', { timeline: 'bubble' }) this.$store.dispatch('startFetchingTimeline', { timeline: 'bubble' })
}, },
unmounted () { unmounted() {
this.$store.dispatch('stopFetchingTimeline', 'bubble') this.$store.dispatch('stopFetchingTimeline', 'bubble')
} },
} }
export default BubbleTimeline export default BubbleTimeline

View file

@ -12,25 +12,22 @@ export default {
focused: ':focus-within', focused: ':focus-within',
pressed: ':active', pressed: ':active',
hover: ':is(:hover, :focus-visible):not(:disabled)', hover: ':is(:hover, :focus-visible):not(:disabled)',
disabled: ':disabled' disabled: ':disabled',
}, },
// Variants are mutually exclusive, each component implicitly has "normal" variant, and all other variants inherit from it. // Variants are mutually exclusive, each component implicitly has "normal" variant, and all other variants inherit from it.
variants: { variants: {
// Variants save on computation time since adding new variant just adds one more "set". // Variants save on computation time since adding new variant just adds one more "set".
// normal: '', // you can override normal variant, it will be appenended to the main class // normal: '', // you can override normal variant, it will be appenended to the main class
danger: '.-danger', danger: '.-danger',
transparent: '.-transparent' transparent: '.-transparent',
// Overall the compuation difficulty is N*((1/6)M^3+M) where M is number of distinct states and N is number of variants. // Overall the compuation difficulty is N*((1/6)M^3+M) where M is number of distinct states and N is number of variants.
// This (currently) is further multipled by number of places where component can exist. // This (currently) is further multipled by number of places where component can exist.
}, },
editor: { editor: {
aspect: '2 / 1' aspect: '2 / 1',
}, },
// This lists all other components that can possibly exist within one. Recursion is currently not supported (and probably won't be supported ever). // This lists all other components that can possibly exist within one. Recursion is currently not supported (and probably won't be supported ever).
validInnerComponents: [ validInnerComponents: ['Text', 'Icon'],
'Text',
'Icon'
],
// Default rules, used as "default theme", essentially. // Default rules, used as "default theme", essentially.
defaultRules: [ defaultRules: [
{ {
@ -39,9 +36,11 @@ export default {
'--buttonDefaultHoverGlow': 'shadow | 0 0 1 2 --text / 0.4', '--buttonDefaultHoverGlow': 'shadow | 0 0 1 2 --text / 0.4',
'--buttonDefaultFocusGlow': 'shadow | 0 0 1 2 --link / 0.5', '--buttonDefaultFocusGlow': 'shadow | 0 0 1 2 --link / 0.5',
'--buttonDefaultShadow': 'shadow | 0 0 2 #000000', '--buttonDefaultShadow': 'shadow | 0 0 2 #000000',
'--buttonDefaultBevel': 'shadow | $borderSide(#FFFFFF top 0.2 1), $borderSide(#000000 bottom 0.2 1)', '--buttonDefaultBevel':
'--buttonPressedBevel': 'shadow | inset 0 0 4 #000000, $borderSide(#FFFFFF bottom 0.2 1), $borderSide(#000000 top 0.2 1)' 'shadow | $borderSide(#FFFFFF top 0.2 1), $borderSide(#000000 bottom 0.2 1)',
} '--buttonPressedBevel':
'shadow | inset 0 0 4 #000000, $borderSide(#FFFFFF bottom 0.2 1), $borderSide(#000000 top 0.2 1)',
},
}, },
{ {
// component: 'Button', // no need to specify components every time unless you're specifying how other component should look // component: 'Button', // no need to specify components every time unless you're specifying how other component should look
@ -49,128 +48,128 @@ export default {
directives: { directives: {
background: '--fg', background: '--fg',
shadow: ['--buttonDefaultShadow', '--buttonDefaultBevel'], shadow: ['--buttonDefaultShadow', '--buttonDefaultBevel'],
roundness: 3 roundness: 3,
} },
}, },
{ {
variant: 'danger', variant: 'danger',
directives: { directives: {
background: '--cRed' background: '$blend(--cRed 0.25 --inheritedBackground)',
} },
}, },
{ {
variant: 'transparent', variant: 'transparent',
directives: { directives: {
opacity: 0.5 opacity: 0.5,
} },
}, },
{ {
component: 'Text', component: 'Text',
parent: { parent: {
component: 'Button', component: 'Button',
variant: 'transparent' variant: 'transparent',
}, },
directives: { directives: {
textColor: '--text' textColor: '--text',
} },
}, },
{ {
component: 'Icon', component: 'Icon',
parent: { parent: {
component: 'Button', component: 'Button',
variant: 'transparent' variant: 'transparent',
}, },
directives: { directives: {
textColor: '--text' textColor: '--text',
} },
}, },
{ {
state: ['hover'], state: ['hover'],
directives: { directives: {
shadow: ['--buttonDefaultHoverGlow', '--buttonDefaultBevel'] shadow: ['--buttonDefaultHoverGlow', '--buttonDefaultBevel'],
} },
}, },
{ {
state: ['focused'], state: ['focused'],
directives: { directives: {
shadow: ['--buttonDefaultFocusGlow', '--buttonDefaultBevel'] shadow: ['--buttonDefaultFocusGlow', '--buttonDefaultBevel'],
} },
}, },
{ {
state: ['pressed'], state: ['pressed'],
directives: { directives: {
shadow: ['--buttonDefaultShadow', '--buttonPressedBevel'] shadow: ['--buttonDefaultShadow', '--buttonPressedBevel'],
} },
}, },
{ {
state: ['pressed', 'hover'], state: ['pressed', 'hover'],
directives: { directives: {
shadow: ['--buttonPressedBevel', '--buttonDefaultHoverGlow'] shadow: ['--buttonPressedBevel', '--buttonDefaultHoverGlow'],
} },
}, },
{ {
state: ['toggled'], state: ['toggled'],
directives: { directives: {
background: '--accent,-24.2', background: '--accent,-24.2',
shadow: ['--buttonDefaultShadow', '--buttonPressedBevel'] shadow: ['--buttonDefaultShadow', '--buttonPressedBevel'],
} },
}, },
{ {
state: ['toggled', 'hover'], state: ['toggled', 'hover'],
directives: { directives: {
background: '--accent,-24.2', background: '--accent,-24.2',
shadow: ['--buttonDefaultHoverGlow', '--buttonPressedBevel'] shadow: ['--buttonDefaultHoverGlow', '--buttonPressedBevel'],
} },
}, },
{ {
state: ['toggled', 'focused'], state: ['toggled', 'focused'],
directives: { directives: {
background: '--accent,-24.2', background: '--accent,-24.2',
shadow: ['--buttonDefaultHoverGlow', '--buttonPressedBevel'] shadow: ['--buttonDefaultHoverGlow', '--buttonPressedBevel'],
} },
}, },
{ {
state: ['toggled', 'hover', 'focused'], state: ['toggled', 'hover', 'focused'],
directives: { directives: {
background: '--accent,-24.2', background: '--accent,-24.2',
shadow: ['--buttonDefaultHoverGlow', '--buttonPressedBevel'] shadow: ['--buttonDefaultHoverGlow', '--buttonPressedBevel'],
} },
}, },
{ {
state: ['toggled', 'disabled'], state: ['toggled', 'disabled'],
directives: { directives: {
background: '$blend(--accent 0.25 --parent)', background: '$blend(--accent 0.25 --parent)',
shadow: ['--buttonPressedBevel'] shadow: ['--buttonPressedBevel'],
} },
}, },
{ {
state: ['disabled'], state: ['disabled'],
directives: { directives: {
background: '$blend(--inheritedBackground 0.25 --parent)', background: '$blend(--inheritedBackground 0.25 --parent)',
shadow: ['--buttonDefaultBevel'] shadow: ['--buttonDefaultBevel'],
} },
}, },
{ {
component: 'Text', component: 'Text',
parent: { parent: {
component: 'Button', component: 'Button',
state: ['disabled'] state: ['disabled'],
}, },
directives: { directives: {
textOpacity: 0.25, textOpacity: 0.25,
textOpacityMode: 'blend' textOpacityMode: 'blend',
} },
}, },
{ {
component: 'Icon', component: 'Icon',
parent: { parent: {
component: 'Button', component: 'Button',
state: ['disabled'] state: ['disabled'],
}, },
directives: { directives: {
textOpacity: 0.25, textOpacity: 0.25,
textOpacityMode: 'blend' textOpacityMode: 'blend',
} },
} },
] ],
} }

View file

@ -7,91 +7,86 @@ export default {
toggled: '.toggled', toggled: '.toggled',
disabled: ':disabled', disabled: ':disabled',
hover: ':is(:hover, :focus-visible):not(:disabled)', hover: ':is(:hover, :focus-visible):not(:disabled)',
focused: ':focus-within:not(:is(:focus-visible))' focused: ':focus-within:not(:is(:focus-visible))',
}, },
validInnerComponents: [ validInnerComponents: ['Text', 'Link', 'Icon', 'Badge'],
'Text',
'Link',
'Icon',
'Badge'
],
defaultRules: [ defaultRules: [
{ {
directives: { directives: {
shadow: [] shadow: [],
} },
}, },
{ {
component: 'Icon', component: 'Icon',
parent: { parent: {
component: 'ButtonUnstyled', component: 'ButtonUnstyled',
state: ['hover'] state: ['hover'],
}, },
directives: { directives: {
textColor: '--parent--text' textColor: '--parent--text',
} },
}, },
{ {
component: 'Icon', component: 'Icon',
parent: { parent: {
component: 'ButtonUnstyled', component: 'ButtonUnstyled',
state: ['toggled'] state: ['toggled'],
}, },
directives: { directives: {
textColor: '--parent--text' textColor: '--parent--text',
} },
}, },
{ {
component: 'Icon', component: 'Icon',
parent: { parent: {
component: 'ButtonUnstyled', component: 'ButtonUnstyled',
state: ['toggled', 'hover'] state: ['toggled', 'hover'],
}, },
directives: { directives: {
textColor: '--parent--text' textColor: '--parent--text',
} },
}, },
{ {
component: 'Icon', component: 'Icon',
parent: { parent: {
component: 'ButtonUnstyled', component: 'ButtonUnstyled',
state: ['toggled', 'focused'] state: ['toggled', 'focused'],
}, },
directives: { directives: {
textColor: '--parent--text' textColor: '--parent--text',
} },
}, },
{ {
component: 'Icon', component: 'Icon',
parent: { parent: {
component: 'ButtonUnstyled', component: 'ButtonUnstyled',
state: ['toggled', 'focused', 'hover'] state: ['toggled', 'focused', 'hover'],
}, },
directives: { directives: {
textColor: '--parent--text' textColor: '--parent--text',
} },
}, },
{ {
component: 'Text', component: 'Text',
parent: { parent: {
component: 'ButtonUnstyled', component: 'ButtonUnstyled',
state: ['disabled'] state: ['disabled'],
}, },
directives: { directives: {
textOpacity: 0.25, textOpacity: 0.25,
textOpacityMode: 'blend' textOpacityMode: 'blend',
} },
}, },
{ {
component: 'Icon', component: 'Icon',
parent: { parent: {
component: 'ButtonUnstyled', component: 'ButtonUnstyled',
state: ['disabled'] state: ['disabled'],
}, },
directives: { directives: {
textOpacity: 0.25, textOpacity: 0.25,
textOpacityMode: 'blend' textOpacityMode: 'blend',
} },
} },
] ],
} }

View file

@ -1,25 +1,27 @@
import _ from 'lodash' import { throttle } from 'lodash'
import { WSConnectionStatus } from '../../services/api/api.service.js'
import { mapGetters, mapState } from 'vuex'
import { mapState as mapPiniaState } from 'pinia' import { mapState as mapPiniaState } from 'pinia'
import ChatMessage from '../chat_message/chat_message.vue' import { mapGetters, mapState } from 'vuex'
import PostStatusForm from '../post_status_form/post_status_form.vue'
import ChatTitle from '../chat_title/chat_title.vue' import ChatMessage from 'src/components/chat_message/chat_message.vue'
import ChatTitle from 'src/components/chat_title/chat_title.vue'
import PostStatusForm from 'src/components/post_status_form/post_status_form.vue'
import { WSConnectionStatus } from '../../services/api/api.service.js'
import chatService from '../../services/chat_service/chat_service.js' import chatService from '../../services/chat_service/chat_service.js'
import { promiseInterval } from '../../services/promise_interval/promise_interval.js'
import { getScrollPosition, getNewTopPosition, isBottomedOut, isScrollable } from './chat_layout_utils.js'
import { library } from '@fortawesome/fontawesome-svg-core'
import {
faChevronDown,
faChevronLeft
} from '@fortawesome/free-solid-svg-icons'
import { buildFakeMessage } from '../../services/chat_utils/chat_utils.js' 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 { useInterfaceStore } from 'src/stores/interface.js' import { useInterfaceStore } from 'src/stores/interface.js'
library.add( import { library } from '@fortawesome/fontawesome-svg-core'
faChevronDown, import { faChevronDown, faChevronLeft } from '@fortawesome/free-solid-svg-icons'
faChevronLeft
) library.add(faChevronDown, faChevronLeft)
const BOTTOMED_OUT_OFFSET = 10 const BOTTOMED_OUT_OFFSET = 10
const JUMP_TO_BOTTOM_BUTTON_VISIBILITY_OFFSET = 10 const JUMP_TO_BOTTOM_BUTTON_VISIBILITY_OFFSET = 10
@ -31,78 +33,95 @@ const Chat = {
components: { components: {
ChatMessage, ChatMessage,
ChatTitle, ChatTitle,
PostStatusForm PostStatusForm,
}, },
data () { data() {
return { return {
jumpToBottomButtonVisible: false, jumpToBottomButtonVisible: false,
hoveredMessageChainId: undefined, hoveredMessageChainId: undefined,
lastScrollPosition: {}, lastScrollPosition: {},
scrollableContainerHeight: '100%', scrollableContainerHeight: '100%',
errorLoadingChat: false, errorLoadingChat: false,
messageRetriers: {} messageRetriers: {},
} }
}, },
created () { created() {
this.startFetching() this.startFetching()
window.addEventListener('resize', this.handleResize) window.addEventListener('resize', this.handleResize)
}, },
mounted () { mounted() {
window.addEventListener('scroll', this.handleScroll) window.addEventListener('scroll', this.handleScroll)
if (typeof document.hidden !== 'undefined') { if (typeof document.hidden !== 'undefined') {
document.addEventListener('visibilitychange', this.handleVisibilityChange, false) document.addEventListener(
'visibilitychange',
this.handleVisibilityChange,
false,
)
} }
this.$nextTick(() => { this.$nextTick(() => {
this.handleResize() this.handleResize()
}) })
}, },
unmounted () { unmounted() {
window.removeEventListener('scroll', this.handleScroll) window.removeEventListener('scroll', this.handleScroll)
window.removeEventListener('resize', this.handleResize) window.removeEventListener('resize', this.handleResize)
if (typeof document.hidden !== 'undefined') document.removeEventListener('visibilitychange', this.handleVisibilityChange, false) if (typeof document.hidden !== 'undefined')
document.removeEventListener(
'visibilitychange',
this.handleVisibilityChange,
false,
)
this.$store.dispatch('clearCurrentChat') this.$store.dispatch('clearCurrentChat')
}, },
computed: { computed: {
recipient () { recipient() {
return this.currentChat && this.currentChat.account return this.currentChat && this.currentChat.account
}, },
recipientId () { recipientId() {
return this.$route.params.recipient_id return this.$route.params.recipient_id
}, },
formPlaceholder () { formPlaceholder() {
if (this.recipient) { if (this.recipient) {
return this.$t('chats.message_user', { nickname: this.recipient.screen_name_ui }) return this.$t('chats.message_user', {
nickname: this.recipient.screen_name_ui,
})
} else { } else {
return '' return ''
} }
}, },
chatViewItems () { chatViewItems() {
return chatService.getView(this.currentChatMessageService) return chatService.getView(this.currentChatMessageService)
}, },
newMessageCount () { newMessageCount() {
return this.currentChatMessageService && this.currentChatMessageService.newMessageCount return (
this.currentChatMessageService &&
this.currentChatMessageService.newMessageCount
)
}, },
streamingEnabled () { streamingEnabled() {
return this.mergedConfig.useStreamingApi && this.mastoUserSocketStatus === WSConnectionStatus.JOINED return (
this.mergedConfig.useStreamingApi &&
this.mastoUserSocketStatus === WSConnectionStatus.JOINED
)
}, },
...mapGetters([ ...mapGetters([
'currentChat', 'currentChat',
'currentChatMessageService', 'currentChatMessageService',
'findOpenedChatByRecipientId', 'findOpenedChatByRecipientId',
'mergedConfig' 'mergedConfig',
]), ]),
...mapPiniaState(useInterfaceStore, { ...mapPiniaState(useInterfaceStore, {
mobileLayout: store => store.layoutType === 'mobile' mobileLayout: (store) => store.layoutType === 'mobile',
}), }),
...mapState({ ...mapState({
backendInteractor: state => state.api.backendInteractor, backendInteractor: (state) => state.api.backendInteractor,
mastoUserSocketStatus: state => state.api.mastoUserSocketStatus, mastoUserSocketStatus: (state) => state.api.mastoUserSocketStatus,
currentUser: state => state.users.currentUser currentUser: (state) => state.users.currentUser,
}) }),
}, },
watch: { watch: {
chatViewItems () { chatViewItems() {
// We don't want to scroll to the bottom on a new message when the user is viewing older messages. // 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. // Therefore we need to know whether the scroll position was at the bottom before the DOM update.
const bottomedOutBeforeUpdate = this.bottomedOut(BOTTOMED_OUT_OFFSET) const bottomedOutBeforeUpdate = this.bottomedOut(BOTTOMED_OUT_OFFSET)
@ -115,23 +134,23 @@ const Chat = {
$route: function () { $route: function () {
this.startFetching() this.startFetching()
}, },
mastoUserSocketStatus (newValue) { mastoUserSocketStatus(newValue) {
if (newValue === WSConnectionStatus.JOINED) { if (newValue === WSConnectionStatus.JOINED) {
this.fetchChat({ isFirstFetch: true }) this.fetchChat({ isFirstFetch: true })
} }
} },
}, },
methods: { methods: {
// Used to animate the avatar near the first message of the message chain when any message belonging to the chain is hovered // Used to animate the avatar near the first message of the message chain when any message belonging to the chain is hovered
onMessageHover ({ isHovered, messageChainId }) { onMessageHover({ isHovered, messageChainId }) {
this.hoveredMessageChainId = isHovered ? messageChainId : undefined this.hoveredMessageChainId = isHovered ? messageChainId : undefined
}, },
onFilesDropped () { onFilesDropped() {
this.$nextTick(() => { this.$nextTick(() => {
this.handleResize() this.handleResize()
}) })
}, },
handleVisibilityChange () { handleVisibilityChange() {
this.$nextTick(() => { this.$nextTick(() => {
if (!document.hidden && this.bottomedOut(BOTTOMED_OUT_OFFSET)) { if (!document.hidden && this.bottomedOut(BOTTOMED_OUT_OFFSET)) {
this.scrollDown({ forceRead: true }) this.scrollDown({ forceRead: true })
@ -139,7 +158,7 @@ const Chat = {
}) })
}, },
// "Sticks" scroll to bottom instead of top, helps with OSK resizing the viewport // "Sticks" scroll to bottom instead of top, helps with OSK resizing the viewport
handleResize (opts = {}) { handleResize(opts = {}) {
const { delayed = false } = opts const { delayed = false } = opts
if (delayed) { if (delayed) {
@ -160,40 +179,56 @@ const Chat = {
this.lastScrollPosition = getScrollPosition() this.lastScrollPosition = getScrollPosition()
}) })
}, },
scrollDown (options = {}) { scrollDown(options = {}) {
const { behavior = 'auto', forceRead = false } = options const { behavior = 'auto', forceRead = false } = options
this.$nextTick(() => { this.$nextTick(() => {
window.scrollTo({ top: document.documentElement.scrollHeight, behavior }) window.scrollTo({
top: document.documentElement.scrollHeight,
behavior,
})
}) })
if (forceRead) { if (forceRead) {
this.readChat() this.readChat()
} }
}, },
readChat () { readChat() {
if (!(this.currentChatMessageService && this.currentChatMessageService.maxId)) { return } if (
if (document.hidden) { return } !(
this.currentChatMessageService && this.currentChatMessageService.maxId
)
) {
return
}
if (document.hidden) {
return
}
const lastReadId = this.currentChatMessageService.maxId const lastReadId = this.currentChatMessageService.maxId
this.$store.dispatch('readChat', { this.$store.dispatch('readChat', {
id: this.currentChat.id, id: this.currentChat.id,
lastReadId lastReadId,
}) })
}, },
bottomedOut (offset) { bottomedOut(offset) {
return isBottomedOut(offset) return isBottomedOut(offset)
}, },
reachedTop () { reachedTop() {
return window.scrollY <= 0 return window.scrollY <= 0
}, },
cullOlderCheck () { cullOlderCheck() {
window.setTimeout(() => { window.setTimeout(() => {
if (this.bottomedOut(JUMP_TO_BOTTOM_BUTTON_VISIBILITY_OFFSET)) { if (this.bottomedOut(JUMP_TO_BOTTOM_BUTTON_VISIBILITY_OFFSET)) {
this.$store.dispatch('cullOlderMessages', this.currentChatMessageService.chatId) this.$store.dispatch(
'cullOlderMessages',
this.currentChatMessageService.chatId,
)
} }
}, 5000) }, 5000)
}, },
handleScroll: _.throttle(function () { handleScroll: throttle(function () {
this.lastScrollPosition = getScrollPosition() this.lastScrollPosition = getScrollPosition()
if (!this.currentChat) { return } if (!this.currentChat) {
return
}
if (this.reachedTop()) { if (this.reachedTop()) {
this.fetchChat({ maxId: this.currentChatMessageService.minId }) this.fetchChat({ maxId: this.currentChatMessageService.minId })
@ -213,22 +248,27 @@ const Chat = {
this.jumpToBottomButtonVisible = true this.jumpToBottomButtonVisible = true
} }
}, 200), }, 200),
handleScrollUp (positionBeforeLoading) { handleScrollUp(positionBeforeLoading) {
const positionAfterLoading = getScrollPosition() const positionAfterLoading = getScrollPosition()
window.scrollTo({ window.scrollTo({
top: getNewTopPosition(positionBeforeLoading, positionAfterLoading) top: getNewTopPosition(positionBeforeLoading, positionAfterLoading),
}) })
}, },
fetchChat ({ isFirstFetch = false, fetchLatest = false, maxId }) { fetchChat({ isFirstFetch = false, fetchLatest = false, maxId }) {
const chatMessageService = this.currentChatMessageService const chatMessageService = this.currentChatMessageService
if (!chatMessageService) { return } if (!chatMessageService) {
if (fetchLatest && this.streamingEnabled) { return } return
}
if (fetchLatest && this.streamingEnabled) {
return
}
const chatId = chatMessageService.chatId const chatId = chatMessageService.chatId
const fetchOlderMessages = !!maxId const fetchOlderMessages = !!maxId
const sinceId = fetchLatest && chatMessageService.maxId const sinceId = fetchLatest && chatMessageService.maxId
return this.backendInteractor.chatMessages({ id: chatId, maxId, sinceId }) return this.backendInteractor
.chatMessages({ id: chatId, maxId, sinceId })
.then((messages) => { .then((messages) => {
// Clear the current chat in case we're recovering from a ws connection loss. // Clear the current chat in case we're recovering from a ws connection loss.
if (isFirstFetch) { if (isFirstFetch) {
@ -236,28 +276,34 @@ const Chat = {
} }
const positionBeforeUpdate = getScrollPosition() const positionBeforeUpdate = getScrollPosition()
this.$store.dispatch('addChatMessages', { chatId, messages }).then(() => { this.$store
this.$nextTick(() => { .dispatch('addChatMessages', { chatId, messages })
if (fetchOlderMessages) { .then(() => {
this.handleScrollUp(positionBeforeUpdate) this.$nextTick(() => {
} if (fetchOlderMessages) {
this.handleScrollUp(positionBeforeUpdate)
}
// In vertical screens, the first batch of fetched messages may not always take the // In vertical screens, the first batch of fetched messages may not always take the
// full height of the scrollable container. // full height of the scrollable container.
// If this is the case, we want to fetch the messages until 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. // is fully populated so that the user has the ability to scroll up and load the history.
if (!isScrollable() && messages.length > 0) { if (!isScrollable() && messages.length > 0) {
this.fetchChat({ maxId: this.currentChatMessageService.minId }) this.fetchChat({
} maxId: this.currentChatMessageService.minId,
})
}
})
}) })
})
}) })
}, },
async startFetching () { async startFetching() {
let chat = this.findOpenedChatByRecipientId(this.recipientId) let chat = this.findOpenedChatByRecipientId(this.recipientId)
if (!chat) { if (!chat) {
try { try {
chat = await this.backendInteractor.getOrCreateChat({ accountId: this.recipientId }) chat = await this.backendInteractor.getOrCreateChat({
accountId: this.recipientId,
})
} catch (e) { } catch (e) {
console.error('Error creating or getting a chat', e) console.error('Error creating or getting a chat', e)
this.errorLoadingChat = true this.errorLoadingChat = true
@ -271,13 +317,14 @@ const Chat = {
this.doStartFetching() this.doStartFetching()
} }
}, },
doStartFetching () { doStartFetching() {
this.$store.dispatch('startFetchingCurrentChat', { this.$store.dispatch('startFetchingCurrentChat', {
fetcher: () => promiseInterval(() => this.fetchChat({ fetchLatest: true }), 5000) fetcher: () =>
promiseInterval(() => this.fetchChat({ fetchLatest: true }), 5000),
}) })
this.fetchChat({ isFirstFetch: true }) this.fetchChat({ isFirstFetch: true })
}, },
handleAttachmentPosting () { handleAttachmentPosting() {
this.$nextTick(() => { this.$nextTick(() => {
this.handleResize() this.handleResize()
// When the posting form size changes because of a media attachment, we need an extra resize // When the posting form size changes because of a media attachment, we need an extra resize
@ -285,11 +332,11 @@ const Chat = {
this.scrollDown({ forceRead: true }) this.scrollDown({ forceRead: true })
}) })
}, },
sendMessage ({ status, media, idempotencyKey }) { sendMessage({ status, media, idempotencyKey }) {
const params = { const params = {
id: this.currentChat.id, id: this.currentChat.id,
content: status, content: status,
idempotencyKey idempotencyKey,
} }
if (media[0]) { if (media[0]) {
@ -301,52 +348,72 @@ const Chat = {
chatId: this.currentChat.id, chatId: this.currentChat.id,
content: status, content: status,
userId: this.currentUser.id, userId: this.currentUser.id,
idempotencyKey idempotencyKey,
}) })
this.$store.dispatch('addChatMessages', { this.$store
chatId: this.currentChat.id, .dispatch('addChatMessages', {
messages: [fakeMessage] chatId: this.currentChat.id,
}).then(() => { messages: [fakeMessage],
this.handleAttachmentPosting() })
}) .then(() => {
this.handleAttachmentPosting()
})
return this.doSendMessage({ params, fakeMessage, retriesLeft: MAX_RETRIES }) return this.doSendMessage({
params,
fakeMessage,
retriesLeft: MAX_RETRIES,
})
}, },
doSendMessage ({ params, fakeMessage, retriesLeft = MAX_RETRIES }) { doSendMessage({ params, fakeMessage, retriesLeft = MAX_RETRIES }) {
if (retriesLeft <= 0) return if (retriesLeft <= 0) return
this.backendInteractor.sendChatMessage(params) this.backendInteractor
.then(data => { .sendChatMessage(params)
.then((data) => {
this.$store.dispatch('addChatMessages', { this.$store.dispatch('addChatMessages', {
chatId: this.currentChat.id, chatId: this.currentChat.id,
updateMaxId: false, updateMaxId: false,
messages: [{ ...data, fakeId: fakeMessage.id }] messages: [{ ...data, fakeId: fakeMessage.id }],
}) })
return data return data
}) })
.catch(error => { .catch((error) => {
console.error('Error sending message', error) console.error('Error sending message', error)
this.$store.dispatch('handleMessageError', { this.$store.dispatch('handleMessageError', {
chatId: this.currentChat.id, chatId: this.currentChat.id,
fakeId: fakeMessage.id, fakeId: fakeMessage.id,
isRetry: retriesLeft !== MAX_RETRIES isRetry: retriesLeft !== MAX_RETRIES,
}) })
if ((error.statusCode >= 500 && error.statusCode < 600) || error.message === 'Failed to fetch') { if (
this.messageRetriers[fakeMessage.id] = setTimeout(() => { (error.statusCode >= 500 && error.statusCode < 600) ||
this.doSendMessage({ params, fakeMessage, retriesLeft: retriesLeft - 1 }) error.message === 'Failed to fetch'
}, 1000 * (2 ** (MAX_RETRIES - retriesLeft))) ) {
this.messageRetriers[fakeMessage.id] = setTimeout(
() => {
this.doSendMessage({
params,
fakeMessage,
retriesLeft: retriesLeft - 1,
})
},
1000 * 2 ** (MAX_RETRIES - retriesLeft),
)
} }
return {} return {}
}) })
return Promise.resolve(fakeMessage) return Promise.resolve(fakeMessage)
}, },
goBack () { goBack() {
this.$router.push({ name: 'chats', params: { username: this.currentUser.screen_name } }) this.$router.push({
} name: 'chats',
} params: { username: this.currentUser.screen_name },
})
},
},
} }
export default Chat export default Chat

View file

@ -1,19 +1,13 @@
export default { export default {
name: 'Chat', name: 'Chat',
selector: '.chat-message-list', selector: '.chat-message-list',
validInnerComponents: [ validInnerComponents: ['Text', 'Link', 'Icon', 'Avatar', 'ChatMessage'],
'Text',
'Link',
'Icon',
'Avatar',
'ChatMessage'
],
defaultRules: [ defaultRules: [
{ {
directives: { directives: {
background: '--bg', background: '--bg',
blur: '5px' blur: '5px',
} },
} },
] ],
} }

View file

@ -73,6 +73,7 @@
:disable-notice="true" :disable-notice="true"
:disable-lock-warning="true" :disable-lock-warning="true"
:disable-polls="true" :disable-polls="true"
:disable-quotes="true"
:disable-sensitivity-checkbox="true" :disable-sensitivity-checkbox="true"
:disable-submit="errorLoadingChat || !currentChat" :disable-submit="errorLoadingChat || !currentChat"
:disable-preview="true" :disable-preview="true"

View file

@ -3,14 +3,17 @@ export const getScrollPosition = () => {
return { return {
scrollTop: window.scrollY, scrollTop: window.scrollY,
scrollHeight: document.documentElement.scrollHeight, scrollHeight: document.documentElement.scrollHeight,
offsetHeight: window.innerHeight offsetHeight: window.innerHeight,
} }
} }
// A helper function that is used to keep the scroll position fixed as the new elements are added to the top // A helper function that is used to keep the scroll position fixed as the new elements are added to the top
// Takes two scroll positions, before and after the update. // Takes two scroll positions, before and after the update.
export const getNewTopPosition = (previousPosition, newPosition) => { export const getNewTopPosition = (previousPosition, newPosition) => {
return previousPosition.scrollTop + (newPosition.scrollHeight - previousPosition.scrollHeight) return (
previousPosition.scrollTop +
(newPosition.scrollHeight - previousPosition.scrollHeight)
)
} }
export const isBottomedOut = (offset = 0) => { export const isBottomedOut = (offset = 0) => {

View file

@ -1,37 +1,38 @@
import { mapState, mapGetters } from 'vuex' import { mapGetters, mapState } from 'vuex'
import ChatListItem from '../chat_list_item/chat_list_item.vue'
import ChatNew from '../chat_new/chat_new.vue' import ChatListItem from 'src/components/chat_list_item/chat_list_item.vue'
import List from '../list/list.vue' import ChatNew from 'src/components/chat_new/chat_new.vue'
import List from 'src/components/list/list.vue'
const ChatList = { const ChatList = {
components: { components: {
ChatListItem, ChatListItem,
List, List,
ChatNew ChatNew,
}, },
computed: { computed: {
...mapState({ ...mapState({
currentUser: state => state.users.currentUser currentUser: (state) => state.users.currentUser,
}), }),
...mapGetters(['sortedChatList']) ...mapGetters(['sortedChatList']),
}, },
data () { data() {
return { return {
isNew: false isNew: false,
} }
}, },
created () { created() {
this.$store.dispatch('fetchChats', { latest: true }) this.$store.dispatch('fetchChats', { latest: true })
}, },
methods: { methods: {
cancelNewChat () { cancelNewChat() {
this.isNew = false this.isNew = false
this.$store.dispatch('fetchChats', { latest: true }) this.$store.dispatch('fetchChats', { latest: true })
}, },
newChat () { newChat() {
this.isNew = true this.isNew = true
} },
} },
} }
export default ChatList export default ChatList

View file

@ -1,31 +1,31 @@
import { mapState } from 'vuex' import { mapState } from 'vuex'
import StatusBody from '../status_content/status_content.vue'
import fileType from 'src/services/file_type/file_type.service' import AvatarList from 'src/components/avatar_list/avatar_list.vue'
import UserAvatar from '../user_avatar/user_avatar.vue' import ChatTitle from 'src/components/chat_title/chat_title.vue'
import AvatarList from '../avatar_list/avatar_list.vue' import StatusBody from 'src/components/status_content/status_content.vue'
import Timeago from '../timeago/timeago.vue' import Timeago from 'src/components/timeago/timeago.vue'
import ChatTitle from '../chat_title/chat_title.vue' import UserAvatar from 'src/components/user_avatar/user_avatar.vue'
const ChatListItem = { const ChatListItem = {
name: 'ChatListItem', name: 'ChatListItem',
props: [ props: ['chat'],
'chat'
],
components: { components: {
UserAvatar, UserAvatar,
AvatarList, AvatarList,
Timeago, Timeago,
ChatTitle, ChatTitle,
StatusBody StatusBody,
}, },
computed: { computed: {
...mapState({ ...mapState({
currentUser: state => state.users.currentUser currentUser: (state) => state.users.currentUser,
}), }),
attachmentInfo () { attachmentInfo() {
if (this.chat.lastMessage.attachments.length === 0) { return } if (this.chat.lastMessage.attachments.length === 0) {
return
}
const types = this.chat.lastMessage.attachments.map(file => fileType.fileType(file.mimetype)) const types = this.chat.lastMessage.attachments.map((file) => file.type)
if (types.includes('video')) { if (types.includes('video')) {
return this.$t('file_type.video') return this.$t('file_type.video')
} else if (types.includes('audio')) { } else if (types.includes('audio')) {
@ -36,34 +36,36 @@ const ChatListItem = {
return this.$t('file_type.file') return this.$t('file_type.file')
} }
}, },
messageForStatusContent () { messageForStatusContent() {
const message = this.chat.lastMessage const message = this.chat.lastMessage
const messageEmojis = message ? message.emojis : [] const messageEmojis = message ? message.emojis : []
const isYou = message && message.account_id === this.currentUser.id const isYou = message && message.account_id === this.currentUser.id
const content = message ? (this.attachmentInfo || message.content) : '' const content = message ? this.attachmentInfo || message.content : ''
const messagePreview = isYou ? `<i>${this.$t('chats.you')}</i> ${content}` : content const messagePreview = isYou
? `<i>${this.$t('chats.you')}</i> ${content}`
: content
return { return {
summary: '', summary: '',
emojis: messageEmojis, emojis: messageEmojis,
raw_html: messagePreview, raw_html: messagePreview,
text: messagePreview, text: messagePreview,
attachments: [] attachments: [],
} }
} },
}, },
methods: { methods: {
openChat () { openChat() {
if (this.chat.id) { if (this.chat.id) {
this.$router.push({ this.$router.push({
name: 'chat', name: 'chat',
params: { params: {
username: this.currentUser.screen_name, username: this.currentUser.screen_name,
recipient_id: this.chat.account.id recipient_id: this.chat.account.id,
} },
}) })
} }
} },
} },
} }
export default ChatListItem export default ChatListItem

View file

@ -1,24 +1,24 @@
import { mapState, mapGetters } from 'vuex'
import { mapState as mapPiniaState } from 'pinia' import { mapState as mapPiniaState } from 'pinia'
import Popover from '../popover/popover.vue'
import Attachment from '../attachment/attachment.vue'
import UserAvatar from '../user_avatar/user_avatar.vue'
import Gallery from '../gallery/gallery.vue'
import LinkPreview from '../link-preview/link-preview.vue'
import StatusContent from '../status_content/status_content.vue'
import ChatMessageDate from '../chat_message_date/chat_message_date.vue'
import { defineAsyncComponent } from 'vue' import { defineAsyncComponent } from 'vue'
import { library } from '@fortawesome/fontawesome-svg-core' import { mapGetters, mapState } from 'vuex'
import {
faTimes,
faEllipsisH
} from '@fortawesome/free-solid-svg-icons'
import { useInterfaceStore } from 'src/stores/interface'
library.add( import Attachment from 'src/components/attachment/attachment.vue'
faTimes, import ChatMessageDate from 'src/components/chat_message_date/chat_message_date.vue'
faEllipsisH import Gallery from 'src/components/gallery/gallery.vue'
) import LinkPreview from 'src/components/link-preview/link-preview.vue'
import Popover from 'src/components/popover/popover.vue'
import StatusContent from 'src/components/status_content/status_content.vue'
import UserAvatar from 'src/components/user_avatar/user_avatar.vue'
import UserPopover from 'src/components/user_popover/user_popover.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 { faEllipsisH, faTimes } from '@fortawesome/free-solid-svg-icons'
library.add(faTimes, faEllipsisH)
const ChatMessage = { const ChatMessage = {
name: 'ChatMessage', name: 'ChatMessage',
@ -27,7 +27,7 @@ const ChatMessage = {
'edited', 'edited',
'noHeading', 'noHeading',
'chatViewItem', 'chatViewItem',
'hoveredMessageChain' 'hoveredMessageChain',
], ],
emits: ['hover'], emits: ['hover'],
components: { components: {
@ -38,73 +38,80 @@ const ChatMessage = {
Gallery, Gallery,
LinkPreview, LinkPreview,
ChatMessageDate, ChatMessageDate,
UserPopover: defineAsyncComponent(() => import('../user_popover/user_popover.vue')) UserPopover,
}, },
computed: { computed: {
// Returns HH:MM (hours and minutes) in local time. // Returns HH:MM (hours and minutes) in local time.
createdAt () { createdAt() {
const time = this.chatViewItem.data.created_at const time = this.chatViewItem.data.created_at
return time.toLocaleTimeString('en', { hour: '2-digit', minute: '2-digit', hour12: false }) return time.toLocaleTimeString('en', {
hour: '2-digit',
minute: '2-digit',
hour12: false,
})
}, },
isCurrentUser () { isCurrentUser() {
return this.message.account_id === this.currentUser.id return this.message.account_id === this.currentUser.id
}, },
message () { message() {
return this.chatViewItem.data return this.chatViewItem.data
}, },
isMessage () { isMessage() {
return this.chatViewItem.type === 'message' return this.chatViewItem.type === 'message'
}, },
messageForStatusContent () { messageForStatusContent() {
return { return {
summary: '', summary: '',
emojis: this.message.emojis, emojis: this.message.emojis,
raw_html: this.message.content || '', raw_html: this.message.content || '',
text: this.message.content || '', text: this.message.content || '',
attachments: this.message.attachments attachments: this.message.attachments,
} }
}, },
hasAttachment () { hasAttachment() {
return this.message.attachments.length > 0 return this.message.attachments.length > 0
}, },
...mapPiniaState(useInterfaceStore, { ...mapPiniaState(useInterfaceStore, {
betterShadow: store => store.browserSupport.cssFilter betterShadow: (store) => store.browserSupport.cssFilter,
}), }),
...mapState({ ...mapState({
currentUser: state => state.users.currentUser, currentUser: (state) => state.users.currentUser,
restrictedNicknames: state => state.instance.restrictedNicknames restrictedNicknames: (state) => useInstanceStore().restrictedNicknames,
}), }),
popoverMarginStyle () { popoverMarginStyle() {
if (this.isCurrentUser) { if (this.isCurrentUser) {
return {} return {}
} else { } else {
return { left: 50 } return { left: 50 }
} }
}, },
...mapGetters(['mergedConfig', 'findUser']) ...mapPiniaState(useMergedConfigStore, ['mergedConfig', 'findUser']),
}, },
data () { data() {
return { return {
hovered: false, hovered: false,
menuOpened: false menuOpened: false,
} }
}, },
methods: { methods: {
onHover (bool) { onHover(bool) {
this.$emit('hover', { isHovered: bool, messageChainId: this.chatViewItem.messageChainId }) this.$emit('hover', {
isHovered: bool,
messageChainId: this.chatViewItem.messageChainId,
})
}, },
async deleteMessage () { async deleteMessage() {
const confirmed = window.confirm(this.$t('chats.delete_confirm')) const confirmed = window.confirm(this.$t('chats.delete_confirm'))
if (confirmed) { if (confirmed) {
await this.$store.dispatch('deleteChatMessage', { await this.$store.dispatch('deleteChatMessage', {
messageId: this.chatViewItem.data.id, messageId: this.chatViewItem.data.id,
chatId: this.chatViewItem.data.chat_id chatId: this.chatViewItem.data.chat_id,
}) })
} }
this.hovered = false this.hovered = false
this.menuOpened = false this.menuOpened = false
} },
} },
} }
export default ChatMessage export default ChatMessage

View file

@ -2,26 +2,21 @@ export default {
name: 'ChatMessage', name: 'ChatMessage',
selector: '.chat-message', selector: '.chat-message',
variants: { variants: {
outgoing: '.outgoing' outgoing: '.outgoing',
}, },
validInnerComponents: [ validInnerComponents: ['Text', 'Icon', 'Border', 'PollGraph'],
'Text',
'Icon',
'Border',
'PollGraph'
],
defaultRules: [ defaultRules: [
{ {
directives: { directives: {
background: '--bg, 2', background: '--bg, 2',
backgroundNoCssColor: 'yes' backgroundNoCssColor: 'yes',
} },
}, },
{ {
variant: 'outgoing', variant: 'outgoing',
directives: { directives: {
background: '--bg, 5' background: '--bg, 5',
} },
} },
] ],
} }

View file

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

View file

@ -1,39 +1,35 @@
import { mapState, mapGetters } from 'vuex' import { mapGetters, mapState } from 'vuex'
import BasicUserCard from '../basic_user_card/basic_user_card.vue'
import UserAvatar from '../user_avatar/user_avatar.vue'
import { library } from '@fortawesome/fontawesome-svg-core'
import {
faSearch,
faChevronLeft
} from '@fortawesome/free-solid-svg-icons'
library.add( import BasicUserCard from 'src/components/basic_user_card/basic_user_card.vue'
faSearch, import UserAvatar from 'src/components/user_avatar/user_avatar.vue'
faChevronLeft
) import { library } from '@fortawesome/fontawesome-svg-core'
import { faChevronLeft, faSearch } from '@fortawesome/free-solid-svg-icons'
library.add(faSearch, faChevronLeft)
const chatNew = { const chatNew = {
components: { components: {
BasicUserCard, BasicUserCard,
UserAvatar UserAvatar,
}, },
data () { data() {
return { return {
suggestions: [], suggestions: [],
userIds: [], userIds: [],
loading: false, loading: false,
query: '' query: '',
} }
}, },
async created () { async created() {
const { chats } = await this.backendInteractor.chats() const { chats } = await this.backendInteractor.chats()
chats.forEach(chat => this.suggestions.push(chat.account)) chats.forEach((chat) => this.suggestions.push(chat.account))
}, },
computed: { computed: {
users () { users() {
return this.userIds.map(userId => this.findUser(userId)) return this.userIds.map((userId) => this.findUser(userId))
}, },
availableUsers () { availableUsers() {
if (this.query.length !== 0) { if (this.query.length !== 0) {
return this.users return this.users
} else { } else {
@ -41,29 +37,29 @@ const chatNew = {
} }
}, },
...mapState({ ...mapState({
currentUser: state => state.users.currentUser, currentUser: (state) => state.users.currentUser,
backendInteractor: state => state.api.backendInteractor backendInteractor: (state) => state.api.backendInteractor,
}), }),
...mapGetters(['findUser']) ...mapGetters(['findUser']),
}, },
methods: { methods: {
goBack () { goBack() {
this.$emit('cancel') this.$emit('cancel')
}, },
goToChat (user) { goToChat(user) {
this.$router.push({ name: 'chat', params: { recipient_id: user.id } }) this.$router.push({ name: 'chat', params: { recipient_id: user.id } })
}, },
onInput () { onInput() {
this.search(this.query) this.search(this.query)
}, },
addUser (user) { addUser(user) {
this.selectedUserIds.push(user.id) this.selectedUserIds.push(user.id)
this.query = '' this.query = ''
}, },
removeUser (userId) { removeUser(userId) {
this.selectedUserIds = this.selectedUserIds.filter(id => id !== userId) this.selectedUserIds = this.selectedUserIds.filter((id) => id !== userId)
}, },
search (query) { search(query) {
if (!query) { if (!query) {
this.loading = false this.loading = false
return return
@ -71,13 +67,14 @@ const chatNew = {
this.loading = true this.loading = true
this.userIds = [] this.userIds = []
this.$store.dispatch('search', { q: query, resolve: true, type: 'accounts' }) this.$store
.then(data => { .dispatch('search', { q: query, resolve: true, type: 'accounts' })
.then((data) => {
this.loading = false this.loading = false
this.userIds = data.accounts.map(a => a.id) this.userIds = data.accounts.map((a) => a.id)
}) })
} },
} },
} }
export default chatNew export default chatNew

View file

@ -1,23 +1,27 @@
import UserAvatar from '../user_avatar/user_avatar.vue'
import RichContent from 'src/components/rich_content/rich_content.jsx'
import { defineAsyncComponent } from 'vue' import { defineAsyncComponent } from 'vue'
import UserAvatar from 'src/components/user_avatar/user_avatar.vue'
import UserPopover from 'src/components/user_popover/user_popover.vue'
import { useMergedConfigStore } from 'src/stores/merged_config.js'
export default { export default {
name: 'ChatTitle', name: 'ChatTitle',
components: { components: {
UserAvatar, UserAvatar,
RichContent,
UserPopover: defineAsyncComponent(() => import('../user_popover/user_popover.vue')) UserPopover,
}, },
props: [ props: ['user', 'withAvatar'],
'user', 'withAvatar'
],
computed: { computed: {
title () { title() {
return this.user ? this.user.screen_name_ui : '' return this.user ? this.user.screen_name_ui : ''
}, },
htmlTitle () { htmlTitle() {
return this.user ? this.user.name_html : '' return this.user ? this.user.name_html : ''
} },
} allowNonSquareEmoji() {
return useMergedConfigStore().mergedConfig.nonSquareEmoji
},
},
} }

View file

@ -19,6 +19,7 @@
:title="'@'+(user && user.screen_name_ui)" :title="'@'+(user && user.screen_name_ui)"
:html="htmlTitle" :html="htmlTitle"
:emoji="user.emoji || []" :emoji="user.emoji || []"
:allow-non-square-emoji="allowNonSquareEmoji"
:is-local="user.is_local" :is-local="user.is_local"
/> />
</div> </div>

View file

@ -1,7 +1,7 @@
<template> <template>
<label <label
class="checkbox" class="checkbox"
:class="[{ disabled, indeterminate, 'indeterminate-fix': indeterminateTransitionFix }, radio ? '-radio' : '-checkbox']" :class="[{ ['-disabled']: disabled, indeterminate, 'indeterminate-fix': indeterminateTransitionFix }, radio ? '-radio' : '-checkbox']"
> >
<span <span
v-if="!!$slots.before" v-if="!!$slots.before"
@ -36,42 +36,39 @@
<script> <script>
export default { export default {
props: [ props: ['radio', 'modelValue', 'indeterminate', 'disabled'],
'radio',
'modelValue',
'indeterminate',
'disabled'
],
emits: ['update:modelValue'], emits: ['update:modelValue'],
data: (vm) => ({ data: (vm) => ({
indeterminateTransitionFix: vm.indeterminate indeterminateTransitionFix: vm.indeterminate,
}), }),
watch: { watch: {
indeterminate (e) { indeterminate(e) {
if (e) { if (e) {
this.indeterminateTransitionFix = true this.indeterminateTransitionFix = true
} }
} },
}, },
methods: { methods: {
onTransitionEnd () { onTransitionEnd() {
if (!this.indeterminate) { if (!this.indeterminate) {
this.indeterminateTransitionFix = false this.indeterminateTransitionFix = false
} }
} },
} },
} }
</script> </script>
<style lang="scss"> <style lang="scss">
.checkbox { .checkbox {
position: relative; position: relative;
display: inline-block; display: inline-flex;
min-height: 1.2em; min-height: 1.2em;
align-items: baseline;
gap: 0 0.5em;
&-indicator, &-indicator,
& .label { & .label {
vertical-align: middle; align-self: center;
} }
& > &-indicator { & > &-indicator {
@ -123,7 +120,7 @@ export default {
.disabled { .disabled {
.checkbox-indicator::before { .checkbox-indicator::before {
background-color: var(--background); background-color: transparent;
} }
} }
@ -143,15 +140,5 @@ export default {
content: ""; content: "";
} }
} }
& > .label {
&.-after {
margin-left: 0.5em;
}
&.-before {
margin-right: 0.5em;
}
}
} }
</style> </style>

View file

@ -1,18 +1,27 @@
.color-input { .color-input {
display: inline-flex; display: inline-flex;
flex-wrap: wrap;
max-width: 10em;
&.-compact {
max-width: none;
}
.label { .label {
flex: 1 1 auto; flex: 1 1 auto;
grid-area: label;
} }
.opt { .opt {
grid-area: checkbox;
margin-right: 0.5em; margin-right: 0.5em;
} }
&-field.input { &-field.input {
display: inline-flex; flex: 1 1 10em;
flex: 0 0 0; max-width: 10em;
max-width: 9em; grid-area: input;
display: flex;
align-items: stretch; align-items: stretch;
input { input {

View file

@ -1,7 +1,7 @@
<template> <template>
<div <div
class="color-input style-control" class="color-input style-control"
:class="{ disabled: !present || disabled }" :class="{ disabled: !present || disabled, '-compact': compact }"
> >
<label <label
:for="name" :for="name"
@ -64,91 +64,96 @@
</div> </div>
</template> </template>
<script> <script>
import Checkbox from '../checkbox/checkbox.vue'
import { hex2rgb } from '../../services/color_convert/color_convert.js'
import { throttle } from 'lodash' import { throttle } from 'lodash'
import { library } from '@fortawesome/fontawesome-svg-core' import Checkbox from 'src/components/checkbox/checkbox.vue'
import { import { hex2rgb } from '../../services/color_convert/color_convert.js'
faEyeDropper
} from '@fortawesome/free-solid-svg-icons'
library.add( import { library } from '@fortawesome/fontawesome-svg-core'
faEyeDropper import { faEyeDropper } from '@fortawesome/free-solid-svg-icons'
)
library.add(faEyeDropper)
export default { export default {
components: { components: {
Checkbox Checkbox,
}, },
props: { props: {
// Name of color, used for identifying // Name of color, used for identifying
name: { name: {
required: true, required: true,
type: String type: String,
}, },
// Readable label // Readable label
label: { label: {
required: true, required: false,
type: String type: String,
default: '',
}, },
// use unstyled, uh, style // use unstyled, uh, style
unstyled: { unstyled: {
required: false, required: false,
type: Boolean type: Boolean,
}, },
// Color value, should be required but vue cannot tell the difference // Color value, should be required but vue cannot tell the difference
// between "property missing" and "property set to undefined" // between "property missing" and "property set to undefined"
modelValue: { modelValue: {
required: false, required: false,
type: String, type: String,
default: undefined default: undefined,
}, },
// Color fallback to use when value is not defeind // Color fallback to use when value is not defeind
fallback: { fallback: {
required: false, required: false,
type: String, type: String,
default: undefined default: undefined,
}, },
// Disable the control // Disable the control
disabled: { disabled: {
required: false, required: false,
type: Boolean, type: Boolean,
default: false default: false,
}, },
// Show "optional" tickbox, for when value might become mandatory // Show "optional" tickbox, for when value might become mandatory
showOptionalCheckbox: { showOptionalCheckbox: {
required: false, required: false,
type: Boolean, type: Boolean,
default: true default: true,
}, },
// Force "optional" tickbox to hide // Force "optional" tickbox to hide
hideOptionalCheckbox: { hideOptionalCheckbox: {
required: false, required: false,
type: Boolean, type: Boolean,
default: false default: false,
} },
compact: {
required: false,
type: Boolean,
},
}, },
emits: ['update:modelValue'], emits: ['update:modelValue'],
computed: { computed: {
present () { present() {
return typeof this.modelValue !== 'undefined' return typeof this.modelValue !== 'undefined'
}, },
validColor () { validColor() {
return hex2rgb(this.modelValue || this.fallback) return hex2rgb(this.modelValue || this.fallback)
}, },
transparentColor () { transparentColor() {
return this.modelValue === 'transparent' return this.modelValue === 'transparent'
}, },
computedColor () { computedColor() {
return this.modelValue && (this.modelValue.startsWith('--') || this.modelValue.startsWith('$')) return (
} this.modelValue &&
(this.modelValue.startsWith('--') || this.modelValue.startsWith('$'))
)
},
}, },
methods: { methods: {
updateValue: throttle(function (value) { updateValue: throttle(function (value) {
this.$emit('update:modelValue', value) this.$emit('update:modelValue', value)
}, 100) }, 100),
} },
} }
</script> </script>
<style lang="scss" src="./color_input.scss"></style> <style lang="scss" src="./color_input.scss"></style>

View file

@ -2,12 +2,15 @@ import Checkbox from 'src/components/checkbox/checkbox.vue'
import ColorInput from 'src/components/color_input/color_input.vue' import ColorInput from 'src/components/color_input/color_input.vue'
import genRandomSeed from 'src/services/random_seed/random_seed.service.js' import genRandomSeed from 'src/services/random_seed/random_seed.service.js'
import { createStyleSheet, adoptStyleSheets } from 'src/services/style_setter/style_setter.js' import {
adoptStyleSheets,
createStyleSheet,
} from 'src/services/style_setter/style_setter.js'
export default { export default {
components: { components: {
Checkbox, Checkbox,
ColorInput ColorInput,
}, },
props: [ props: [
'shadow', 'shadow',
@ -17,41 +20,41 @@ export default {
'previewCss', 'previewCss',
'disabled', 'disabled',
'invalid', 'invalid',
'noColorControl' 'noColorControl',
], ],
emits: ['update:shadow'], emits: ['update:shadow'],
data () { data() {
return { return {
colorOverride: undefined, colorOverride: undefined,
lightGrid: false, lightGrid: false,
zoom: 100, zoom: 100,
randomSeed: genRandomSeed() randomSeed: genRandomSeed(),
} }
}, },
mounted () { mounted() {
this.update() this.update()
}, },
computed: { computed: {
hideControls () { hideControls() {
return typeof this.shadow === 'string' return typeof this.shadow === 'string'
} },
}, },
watch: { watch: {
previewCss () { previewCss() {
this.update() this.update()
}, },
previewStyle () { previewStyle() {
this.update() this.update()
}, },
zoom () { zoom() {
this.update() this.update()
} },
}, },
methods: { methods: {
updateProperty (axis, value) { updateProperty(axis, value) {
this.$emit('update:shadow', { axis, value: Number(value) }) this.$emit('update:shadow', { axis, value: Number(value) })
}, },
update () { update() {
const sheet = createStyleSheet('style-component-preview', 90) const sheet = createStyleSheet('style-component-preview', 90)
sheet.clear() sheet.clear()
@ -60,23 +63,25 @@ export default {
if (this.colorOverride) result.push(`--background: ${this.colorOverride}`) if (this.colorOverride) result.push(`--background: ${this.colorOverride}`)
const styleRule = [ const styleRule = [
'#component-preview-', this.randomSeed, ' {\n', '#component-preview-',
this.randomSeed,
' {\n',
'.preview-block {\n', '.preview-block {\n',
`zoom: ${this.zoom / 100};`, `zoom: ${this.zoom / 100};`,
this.previewStyle, this.previewStyle,
'\n}', '\n}',
'\n}' '\n}',
].join('') ].join('')
sheet.addRule(styleRule) sheet.addRule(styleRule)
sheet.addRule([ sheet.addRule(
'#component-preview-', this.randomSeed, ' {\n', ['#component-preview-', this.randomSeed, ' {\n', ...result, '\n}'].join(
...result, '',
'\n}' ),
].join('')) )
sheet.ready = true sheet.ready = true
adoptStyleSheets() adoptStyleSheets()
} },
} },
} }

View file

@ -104,6 +104,7 @@
v-model="colorOverride" v-model="colorOverride"
class="input-color-input" class="input-color-input"
fallback="#606060" fallback="#606060"
:compact="true"
:label="$t('settings.style.shadows.color_override')" :label="$t('settings.style.shadows.color_override')"
/> />
</div> </div>

View file

@ -1,4 +1,4 @@
import DialogModal from '../dialog_modal/dialog_modal.vue' import DialogModal from 'src/components/dialog_modal/dialog_modal.vue'
/** /**
* This component emits the following events: * This component emits the following events:
@ -9,30 +9,32 @@ import DialogModal from '../dialog_modal/dialog_modal.vue'
*/ */
const ConfirmModal = { const ConfirmModal = {
components: { components: {
DialogModal DialogModal,
}, },
props: { props: {
title: { title: {
type: String type: String,
}, },
cancelText: { cancelText: {
type: String type: String,
}, },
confirmText: { confirmText: {
type: String type: String,
} },
confirmDanger: {
type: Boolean,
},
}, },
emits: ['cancelled', 'accepted'], emits: ['cancelled', 'accepted'],
computed: { computed: {},
},
methods: { methods: {
onCancel () { onCancel() {
this.$emit('cancelled') this.$emit('cancelled')
}, },
onAccept () { onAccept() {
this.$emit('accepted') this.$emit('accepted')
} },
} },
} }
export default ConfirmModal export default ConfirmModal

View file

@ -14,6 +14,7 @@
<slot name="footerLeft" /> <slot name="footerLeft" />
<button <button
class="btn button-default" class="btn button-default"
:class="{ '-danger': confirmDanger }"
@click.prevent="onAccept" @click.prevent="onAccept"
v-text="confirmText" v-text="confirmText"
/> />

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