diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 000000000..3de57a360 --- /dev/null +++ b/.dockerignore @@ -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__/ + diff --git a/.forgejo/issue_template/bug.yaml b/.forgejo/issue_template/bug.yaml new file mode 100644 index 000000000..082ee496e --- /dev/null +++ b/.forgejo/issue_template/bug.yaml @@ -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] diff --git a/.forgejo/issue_template/suggestion.yaml b/.forgejo/issue_template/suggestion.yaml new file mode 100644 index 000000000..c1531d8e3 --- /dev/null +++ b/.forgejo/issue_template/suggestion.yaml @@ -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] diff --git a/.forgejo/pull_request_template.md b/.forgejo/pull_request_template.md new file mode 100644 index 000000000..d2d7689bd --- /dev/null +++ b/.forgejo/pull_request_template.md @@ -0,0 +1,12 @@ +### Checklist +- [ ] Adding a changelog: In the `changelog.d` directory, create a file named `.`. + + diff --git a/.gitignore b/.gitignore index 01ffda9a8..c4a96ee1e 100644 --- a/.gitignore +++ b/.gitignore @@ -4,8 +4,11 @@ dist/ npm-debug.log test/unit/coverage test/e2e/reports +test/e2e-playwright/test-results +test/e2e-playwright/playwright-report selenium-debug.log .idea/ +.gitlab-ci-local/ config/local.json src/assets/emoji.json logs/ diff --git a/.gitlab-ci.yml b/.gitlab-ci.yml index 99c85dd36..06fbf45f9 100644 --- a/.gitlab-ci.yml +++ b/.gitlab-ci.yml @@ -34,12 +34,23 @@ check-changelog: - apk add git - sh ./tools/check-changelog -lint: +lint-eslint: stage: lint script: - yarn - - yarn lint - - yarn stylelint + - yarn ci-eslint + +lint-biome: + stage: lint + script: + - yarn + - yarn ci-biome + +lint-stylelint: + stage: lint + script: + - yarn + - yarn ci-stylelint test: stage: test @@ -60,6 +71,135 @@ test: - test/**/__screenshots__ 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 </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: stage: build tags: diff --git a/.gitlab/merge_request_templates/Release.md b/.gitlab/merge_request_templates/Release.md new file mode 100644 index 000000000..d02e14a73 --- /dev/null +++ b/.gitlab/merge_request_templates/Release.md @@ -0,0 +1,8 @@ +### Release checklist +* [ ] Bump version in `package.json` +* [ ] Compile a changelog with the `tools/collect-changelog` script +* [ ] Create an MR with an announcement to pleroma.social +#### post-merge +* [ ] Tag the release on the merge commit +* [ ] Make the tag into a Gitlab Releaseā„¢ +* [ ] Merge `master` into `develop` (in case the fixes are already in develop, use `git merge -s ours --no-commit` and manually merge the changelogs) diff --git a/.stylelintrc.json b/.stylelintrc.json index c91107595..afdfd5f5b 100644 --- a/.stylelintrc.json +++ b/.stylelintrc.json @@ -12,6 +12,8 @@ "custom-property-pattern": null, "keyframes-name-pattern": null, "scss/operator-no-newline-after": null, + "declaration-property-value-no-unknown": true, + "scss/declaration-property-value-no-unknown": true, "declaration-block-no-redundant-longhand-properties": [ true, { diff --git a/.woodpecker/build.yaml b/.woodpecker/build.yaml new file mode 100644 index 000000000..059fdb1ff --- /dev/null +++ b/.woodpecker/build.yaml @@ -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:18-alpine + commands: + - apk add --no-cache zip git + - yarn --frozen-lockfile + - yarn build + - if [ "${CI_PIPELINE_EVENT}" = "push" ] || [ "${CI_PIPELINE_EVENT}" = "manual" ]; then zip -9qr ${CI_REPO_DEFAULT_BRANCH}.zip dist/; fi + + upload-artifacts: + image: docker.io/woodpeckercommunity/plugin-gitea-package:0.5.0 + when: + - event: push + branch: ${CI_REPO_DEFAULT_BRANCH} + - event: manual + branch: ${CI_REPO_DEFAULT_BRANCH} + settings: + user: + from_secret: pleroma-ci-user + password: + from_secret: pleroma-ci-password + update: true + owner: 'pleroma' + package_name: 'pleroma-fe-builds' + package_version: ${CI_REPO_DEFAULT_BRANCH} + file_source: ./${CI_REPO_DEFAULT_BRANCH}.zip + file_name: latest.zip diff --git a/.woodpecker/changelog.yaml b/.woodpecker/changelog.yaml new file mode 100644 index 000000000..b6c35afbb --- /dev/null +++ b/.woodpecker/changelog.yaml @@ -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 diff --git a/.woodpecker/lint.yaml b/.woodpecker/lint.yaml new file mode 100644 index 000000000..237135ee9 --- /dev/null +++ b/.woodpecker/lint.yaml @@ -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:18-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 diff --git a/.woodpecker/test-e2e.yaml b/.woodpecker/test-e2e.yaml new file mode 100644 index 000000000..c0bf103cd --- /dev/null +++ b/.woodpecker/test-e2e.yaml @@ -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" diff --git a/.woodpecker/test.yaml b/.woodpecker/test.yaml new file mode 100644 index 000000000..acc48aacc --- /dev/null +++ b/.woodpecker/test.yaml @@ -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 diff --git a/CHANGELOG.md b/CHANGELOG.md index c2f0e7d17..1eb5a9cb4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,76 @@ 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/). + +## 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 +### Changed +- BREAKING: due to some internal technical changes logging into AdminFE through PleromaFE is no longer possible +- User card/profile got an overhaul +- Profile editing overhaul +- Visually combined subject and content fields in post form +- Moved post form's emoji button into input field +- Minor visual changes and fixes +- Clicking on fav/rt/emoji notifications' contents expands/collapses it +- Reduced time taken processing theme by half +- Splash screen only appears if loading takes more than 2 seconds + +### Added +- Mutes received an update, adding support for regex, muting based on username and expiration time. +- Mutes are now synchronized across sessions +- Support for expiring mutes and blocks (if available) +- Clicking on emoji shows bigger version of it alongside with its shortcode + - Admins also are able to copy it into a local pack +- Added support for Akkoma and IceShrimp.NET backends +- Compatibility with stricter CSP (Akkoma backend) +- Added a way to upload new packs from a URL or ZIP file via the Admin Dashboard +- Unify show/hide content buttons +- Add support for detachable scrollTop button +- Option to left-align user bio +- Cache assets and emojis with service worker +- Indicate currently active V3 theme as a body element class +- Add arithmetic blend ISS function + +### Fixed +- Display counter for status action buttons when they are in the menu +- Fix bookmark button alignment in the extra actions menu +- Instance favicons are no longer stretched +- A lot more scalable UI fixes + - Emoji picker now should work fine when emoji size is increased + ## 2.8.0 ### Changed - BREAKING: static/img/nsfw.2958239.png is now static/img/nsfw.DepQPhG0.png, which may affect people who specify exactly this path as the cover image @@ -34,8 +104,8 @@ This does not guarantee that browsers will or will not work. - Support displaying time in absolute format - Add draft management system - Compress most kinds of images on upload. -- Added option to always convert images to JPEG format instead of using WebP when compressing images. -- Added configurable image compression option in general settings, allowing users to control whether images are compressed before upload. +- Added option to always convert images to JPEG format instead of using WebP when compressing images. +- Added configurable image compression option in general settings, allowing users to control whether images are compressed before upload. - Inform users that Smithereen public polls are public - Splash screen + loading indicator to make process of identifying initialization issues and load performance - UI for making v3 themes and palettes, support for bundling v3 themes diff --git a/README.md b/README.md index 6a37195d5..16d32dcd2 100644 --- a/README.md +++ b/README.md @@ -6,7 +6,7 @@ # 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. @@ -32,10 +32,10 @@ yarn unit # 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. -* `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. diff --git a/biome.json b/biome.json new file mode 100644 index 000000000..6a464a0e5 --- /dev/null +++ b/biome.json @@ -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/*" + ] + } + } + } + } + } +} diff --git a/build/check-versions.mjs b/build/check-versions.mjs index 73c1eeb15..8c5968a30 100644 --- a/build/check-versions.mjs +++ b/build/check-versions.mjs @@ -1,5 +1,5 @@ -import semver from 'semver' import chalk from 'chalk' +import semver from 'semver' import packageConfig from '../package.json' with { type: 'json' } @@ -7,8 +7,8 @@ var versionRequirements = [ { name: 'node', currentVersion: semver.clean(process.version), - versionRequirement: packageConfig.engines.node - } + versionRequirement: packageConfig.engines.node, + }, ] export default function () { @@ -16,15 +16,22 @@ export default function () { for (let i = 0; i < versionRequirements.length; i++) { const mod = versionRequirements[i] if (!semver.satisfies(mod.currentVersion, mod.versionRequirement)) { - warnings.push(mod.name + ': ' + - chalk.red(mod.currentVersion) + ' should be ' + - chalk.green(mod.versionRequirement) + warnings.push( + mod.name + + ': ' + + chalk.red(mod.currentVersion) + + ' should be ' + + chalk.green(mod.versionRequirement), ) } } if (warnings.length) { - console.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++) { const warning = warnings[i] console.warn(' ' + warning) diff --git a/build/commit_hash.js b/build/commit_hash.js index c104af5d9..c60355804 100644 --- a/build/commit_hash.js +++ b/build/commit_hash.js @@ -1,8 +1,8 @@ import childProcess from 'child_process' -export const getCommitHash = (() => { - const subst = "$Format:%h$" - if(!subst.match(/Format:/)) { +export const getCommitHash = () => { + const subst = '$Format:%h$' + if (!subst.match(/Format:/)) { return subst } else { try { @@ -15,4 +15,4 @@ export const getCommitHash = (() => { return 'UNKNOWN' } } -}) +} diff --git a/build/copy_plugin.js b/build/copy_plugin.js index a783fe7ff..4f020f359 100644 --- a/build/copy_plugin.js +++ b/build/copy_plugin.js @@ -1,8 +1,8 @@ -import serveStatic from 'serve-static' -import { resolve } from 'node:path' 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 + '/' return padEnd.startsWith('/') ? padEnd : '/' + padEnd } @@ -13,28 +13,31 @@ const copyPlugin = ({ inUrl, inFs }) => { let copyTarget const handler = serveStatic(inFs) - return [{ - name: 'copy-plugin-serve', - apply: 'serve', - configureServer (server) { - server.middlewares.use(prefix, handler) - } - }, { - name: 'copy-plugin-build', - apply: 'build', - configResolved (config) { - copyTarget = resolve(config.root, config.build.outDir, subdir) + return [ + { + name: 'copy-plugin-serve', + apply: 'serve', + configureServer(server) { + server.middlewares.use(prefix, handler) + }, }, - closeBundle: { - order: 'post', - sequential: true, - async handler () { - console.log(`Copying '${inFs}' to ${copyTarget}...`) - await cp(inFs, copyTarget, { recursive: true }) - console.log('Done.') - } - } - }] + { + name: 'copy-plugin-build', + apply: 'build', + configResolved(config) { + copyTarget = resolve(config.root, config.build.outDir, subdir) + }, + 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 diff --git a/build/emojis_plugin.js b/build/emojis_plugin.js index aed52066d..9872f5331 100644 --- a/build/emojis_plugin.js +++ b/build/emojis_plugin.js @@ -1,21 +1,23 @@ -import { resolve } from 'node:path' 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 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 // can be chosen in the settings). Data for other languages are // discarded because there is no way for it to be fetched. const getAllAccessibleAnnotations = async (projectRoot) => { - const imports = (await Promise.all( - languages - .map(async lang => { + const imports = ( + await Promise.all( + languages.map(async (lang) => { const destLang = internalToAnnotationsLocale(lang) const importModule = `${annotationsImportPrefix}${destLang}.json` const importFile = resolve(projectRoot, 'node_modules', importModule) @@ -23,11 +25,18 @@ const getAllAccessibleAnnotations = async (projectRoot) => { await access(importFile) return `'${lang}': () => import('${importModule}')` } catch (e) { + if (e.message.match(/ENOENT/)) { + console.warn(`Missing emoji annotations locale: ${destLang}`) + } else { + console.error('test', e.message) + } return } - }))) - .filter(k => k) - .join(',\n') + }), + ) + ) + .filter((k) => k) + .join(',\n') return ` export const annotationsLoader = { @@ -43,21 +52,21 @@ const emojisPlugin = () => { let projectRoot return { name: 'emojis-plugin', - configResolved (conf) { + configResolved(conf) { projectRoot = conf.root }, - resolveId (id) { + resolveId(id) { if (id === emojiAnnotationsId) { return emojiAnnotationsIdResolved } return null }, - async load (id) { + async load(id) { if (id === emojiAnnotationsIdResolved) { return await getAllAccessibleAnnotations(projectRoot) } return null - } + }, } } diff --git a/build/msw_plugin.js b/build/msw_plugin.js index f544348fc..c4e9098c5 100644 --- a/build/msw_plugin.js +++ b/build/msw_plugin.js @@ -1,5 +1,5 @@ -import { resolve } from 'node:path' import { readFile } from 'node:fs/promises' +import { resolve } from 'node:path' const target = 'node_modules/msw/lib/mockServiceWorker.js' @@ -8,10 +8,10 @@ const mswPlugin = () => { return { name: 'msw-plugin', apply: 'serve', - configResolved (conf) { + configResolved(conf) { projectRoot = conf.root }, - configureServer (server) { + configureServer(server) { server.middlewares.use(async (req, res, next) => { if (req.path === '/mockServiceWorker.js') { const file = await readFile(resolve(projectRoot, target)) @@ -21,7 +21,7 @@ const mswPlugin = () => { next() } }) - } + }, } } diff --git a/build/service_worker_messages.js b/build/service_worker_messages.js index c078e8563..0948aa919 100644 --- a/build/service_worker_messages.js +++ b/build/service_worker_messages.js @@ -1,11 +1,12 @@ -import { languages, langCodeToJsonName } from '../src/i18n/languages.js' import { readFile } from 'node:fs/promises' import { dirname, resolve } from 'node:path' import { fileURLToPath } from 'node:url' +import { langCodeToJsonName, languages } from '../src/i18n/languages.js' + const i18nDir = resolve( dirname(dirname(fileURLToPath(import.meta.url))), - 'src/i18n' + 'src/i18n', ) export const i18nFiles = languages.reduce((acc, lang) => { @@ -16,13 +17,15 @@ export const i18nFiles = languages.reduce((acc, lang) => { }, {}) export const generateServiceWorkerMessages = async () => { - const msgArray = await Promise.all(Object.entries(i18nFiles).map(async ([lang, file]) => { - const fileContent = await readFile(file, 'utf-8') - const msg = { - notifications: JSON.parse(fileContent).notifications || {} - } - return [lang, msg] - })) + const msgArray = await Promise.all( + Object.entries(i18nFiles).map(async ([lang, file]) => { + const fileContent = await readFile(file, 'utf-8') + const msg = { + notifications: JSON.parse(fileContent).notifications || {}, + } + return [lang, msg] + }), + ) return msgArray.reduce((acc, [lang, msg]) => { acc[lang] = msg return acc diff --git a/build/sw_plugin.js b/build/sw_plugin.js index a2c792b7d..03c5978d7 100644 --- a/build/sw_plugin.js +++ b/build/sw_plugin.js @@ -1,9 +1,13 @@ -import { fileURLToPath } from 'node:url' -import { dirname, resolve } from 'node:path' import { readFile } from 'node:fs/promises' -import { build } from 'vite' +import { dirname, resolve } from 'node:path' +import { fileURLToPath } from 'node:url' import * as esbuild from 'esbuild' -import { generateServiceWorkerMessages, i18nFiles } from './service_worker_messages.js' +import { build } from 'vite' + +import { + generateServiceWorkerMessages, + i18nFiles, +} from './service_worker_messages.js' const getSWMessagesAsText = async () => { const messages = await generateServiceWorkerMessages() @@ -14,14 +18,10 @@ 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)} };` +const getProdSwEnv = ({ assets }) => + `self.serviceWorkerOption = { assets: ${JSON.stringify(assets)} };` -export const devSwPlugin = ({ - swSrc, - swDest, - transformSW, - alias -}) => { +export const devSwPlugin = ({ swSrc, swDest, transformSW, alias }) => { const swFullSrc = resolve(projectRoot, swSrc) const esbuildAlias = {} Object.entries(alias).forEach(([source, dest]) => { @@ -31,9 +31,10 @@ export const devSwPlugin = ({ return { name: 'dev-sw-plugin', apply: 'serve', - configResolved (conf) { + configResolved() { + /* no-op */ }, - resolveId (id) { + resolveId(id) { const name = id.startsWith('/') ? id.slice(1) : id if (name === swDest) { return swFullSrc @@ -42,7 +43,7 @@ export const devSwPlugin = ({ } return null }, - async load (id) { + async load(id) { if (id === swFullSrc) { return readFile(swFullSrc, 'utf-8') } else if (id === swEnvNameResolved) { @@ -55,7 +56,7 @@ export const devSwPlugin = ({ * during dev, and firefox does not support ESM as service worker * https://bugzilla.mozilla.org/show_bug.cgi?id=1360870 */ - async transform (code, id) { + async transform(code, id) { if (id === swFullSrc && transformSW) { const res = await esbuild.build({ entryPoints: [swSrc], @@ -63,52 +64,54 @@ export const devSwPlugin = ({ 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' + plugins: [ + { + name: 'vite-like-root-resolve', + setup(b) { + b.onResolve({ filter: new RegExp(/^\//) }, (args) => ({ + path: resolve(projectRoot, args.path.slice(1)), })) - b.onLoad( - { filter: /.*/, namespace: 'sw-messages' }, - async () => ({ - contents: await getSWMessagesAsText() + }, + }, + { + 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(), })) - } - }, { - 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 } - } + }, } } @@ -118,16 +121,13 @@ export const devSwPlugin = ({ // 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 // the end of the build. -export const buildSwPlugin = ({ - swSrc, - swDest, -}) => { +export const buildSwPlugin = ({ swSrc, swDest }) => { let config return { name: 'build-sw-plugin', enforce: 'post', apply: 'build', - configResolved (resolvedConfig) { + configResolved(resolvedConfig) { config = { define: resolvedConfig.define, resolve: resolvedConfig.resolve, @@ -138,50 +138,50 @@ export const buildSwPlugin = ({ lib: { entry: swSrc, formats: ['iife'], - name: 'sw_pleroma' + name: 'sw_pleroma', }, emptyOutDir: false, rollupOptions: { output: { - entryFileNames: swDest - } - } + entryFileNames: swDest, + }, + }, }, - configFile: false + configFile: false, } }, generateBundle: { order: 'post', sequential: true, - async handler (_, bundle) { + async handler(_, bundle) { const assets = Object.keys(bundle) - .filter(name => !/\.map$/.test(name)) - .map(name => '/' + name) + .filter((name) => !/\.map$/.test(name)) + .map((name) => '/' + name) config.plugins.push({ name: 'build-sw-env-plugin', - resolveId (id) { + resolveId(id) { if (id === swEnvName) { return swEnvNameResolved } return null }, - load (id) { + load(id) { if (id === swEnvNameResolved) { return getProdSwEnv({ assets }) } return null - } + }, }) - } + }, }, closeBundle: { order: 'post', sequential: true, - async handler () { - console.log('Building service worker for production') + async handler() { + console.info('Building service worker for production') await build(config) - } - } + }, + }, } } @@ -191,9 +191,9 @@ const swMessagesNameResolved = '\0' + swMessagesName export const swMessagesPlugin = () => { return { name: 'sw-messages-plugin', - resolveId (id) { + resolveId(id) { if (id === swMessagesName) { - Object.values(i18nFiles).forEach(f => { + Object.values(i18nFiles).forEach((f) => { this.addWatchFile(f) }) return swMessagesNameResolved @@ -201,11 +201,11 @@ export const swMessagesPlugin = () => { return null } }, - async load (id) { + async load(id) { if (id === swMessagesNameResolved) { return await getSWMessagesAsText() } return null - } + }, } } diff --git a/build/update-emoji.js b/build/update-emoji.js index 5d578ba61..4ff7e1de8 100644 --- a/build/update-emoji.js +++ b/build/update-emoji.js @@ -1,22 +1,21 @@ - -import emojis from '@kazvmoe-infra/unicode-emoji-json/data-by-group.json' with { type: 'json' } +import emojis from '@kazvmoe-infra/unicode-emoji-json/data-by-group.json' with { + type: 'json', +} import fs from 'fs' -Object.keys(emojis) - .map(k => { - emojis[k].map(e => { - delete e.unicode_version - delete e.emoji_version - delete e.skin_tone_support_unicode_version - }) +Object.keys(emojis).map((k) => { + emojis[k].map((e) => { + delete e.unicode_version + delete e.emoji_version + delete e.skin_tone_support_unicode_version }) +}) const res = {} -Object.keys(emojis) - .map(k => { - const groupId = k.replace('&', 'and').replace(/ /g, '-').toLowerCase() - res[groupId] = emojis[k] - }) +Object.keys(emojis).map((k) => { + const groupId = k.replace('&', 'and').replace(/ /g, '-').toLowerCase() + res[groupId] = emojis[k] +}) console.info('Updating emojis...') fs.writeFileSync('src/assets/emoji.json', JSON.stringify(res)) diff --git a/changelog.d/action-button-extra-counter.add b/changelog.d/action-button-extra-counter.add deleted file mode 100644 index 7d5c77447..000000000 --- a/changelog.d/action-button-extra-counter.add +++ /dev/null @@ -1 +0,0 @@ -Display counter for status action buttons when they are on the menu diff --git a/changelog.d/akkoma-sharkey-net-support.add b/changelog.d/akkoma-sharkey-net-support.add deleted file mode 100644 index 4b4bff7fe..000000000 --- a/changelog.d/akkoma-sharkey-net-support.add +++ /dev/null @@ -1 +0,0 @@ -Added support for Akkoma and IceShrimp.NET backend diff --git a/changelog.d/arithmetic-blend.add b/changelog.d/arithmetic-blend.add deleted file mode 100644 index c579dca28..000000000 --- a/changelog.d/arithmetic-blend.add +++ /dev/null @@ -1,2 +0,0 @@ -Add arithmetic blend ISS function - diff --git a/changelog.d/attrs-parsing.fix b/changelog.d/attrs-parsing.fix new file mode 100644 index 000000000..e36e59a86 --- /dev/null +++ b/changelog.d/attrs-parsing.fix @@ -0,0 +1 @@ +Fix HTML attribute parsing for escaped quotes \ No newline at end of file diff --git a/changelog.d/better-scroll-button.add b/changelog.d/better-scroll-button.add deleted file mode 100644 index b206869d1..000000000 --- a/changelog.d/better-scroll-button.add +++ /dev/null @@ -1 +0,0 @@ -Add support for detachable scrollTop button diff --git a/changelog.d/bookmark-button-align.fix b/changelog.d/bookmark-button-align.fix deleted file mode 100644 index 64bc2c807..000000000 --- a/changelog.d/bookmark-button-align.fix +++ /dev/null @@ -1 +0,0 @@ -Fix bookmark button alignment in the extra actions menu diff --git a/changelog.d/akkoma.skip b/changelog.d/ci-pr-uploads-removal.skip similarity index 100% rename from changelog.d/akkoma.skip rename to changelog.d/ci-pr-uploads-removal.skip diff --git a/changelog.d/csp.add b/changelog.d/csp.add deleted file mode 100644 index 260337b97..000000000 --- a/changelog.d/csp.add +++ /dev/null @@ -1 +0,0 @@ -Compatibility with stricter CSP (Akkoma backend) diff --git a/changelog.d/fix-emojis-breaking-bio.fix b/changelog.d/fix-emojis-breaking-bio.fix new file mode 100644 index 000000000..62a607d8a --- /dev/null +++ b/changelog.d/fix-emojis-breaking-bio.fix @@ -0,0 +1 @@ +Fix emojis breaking user bio/description editing diff --git a/changelog.d/filter-fixes.skip b/changelog.d/instance-store-migration.skip similarity index 100% rename from changelog.d/filter-fixes.skip rename to changelog.d/instance-store-migration.skip diff --git a/changelog.d/migrate-auth-flow-pinia.skip b/changelog.d/migrate-auth-flow-pinia.skip deleted file mode 100644 index e69de29bb..000000000 diff --git a/changelog.d/migrate-oauth-tokens-module-to-pinia-store.skip b/changelog.d/migrate-oauth-tokens-module-to-pinia-store.skip deleted file mode 100644 index e69de29bb..000000000 diff --git a/changelog.d/minor.add b/changelog.d/minor.add new file mode 100644 index 000000000..5f4934173 --- /dev/null +++ b/changelog.d/minor.add @@ -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) diff --git a/changelog.d/minor.change b/changelog.d/minor.change new file mode 100644 index 000000000..a234ad9c2 --- /dev/null +++ b/changelog.d/minor.change @@ -0,0 +1,6 @@ +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 diff --git a/changelog.d/minor.fix b/changelog.d/minor.fix new file mode 100644 index 000000000..69d7b11fe --- /dev/null +++ b/changelog.d/minor.fix @@ -0,0 +1,11 @@ +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 diff --git a/changelog.d/mute-dropdown.fix b/changelog.d/mute-dropdown.fix new file mode 100644 index 000000000..33f12a571 --- /dev/null +++ b/changelog.d/mute-dropdown.fix @@ -0,0 +1 @@ +Fixed status action mute hiding itself on click diff --git a/changelog.d/mutes-sync.add b/changelog.d/mutes-sync.add deleted file mode 100644 index e8e0e462a..000000000 --- a/changelog.d/mutes-sync.add +++ /dev/null @@ -1 +0,0 @@ -Synchronized mutes, advanced mute control (regexp, expiry, naming) diff --git a/changelog.d/profile-error.fix b/changelog.d/profile-error.fix deleted file mode 100644 index f123db5ae..000000000 --- a/changelog.d/profile-error.fix +++ /dev/null @@ -1 +0,0 @@ -Fix error styling for user profiles diff --git a/changelog.d/quote-by-url.add b/changelog.d/quote-by-url.add new file mode 100644 index 000000000..ef401f93c --- /dev/null +++ b/changelog.d/quote-by-url.add @@ -0,0 +1 @@ +Add quoting by URL and in replies diff --git a/changelog.d/small-fixes.skip b/changelog.d/small-fixes.skip deleted file mode 100644 index e69de29bb..000000000 diff --git a/changelog.d/sw-cache-assets.add b/changelog.d/sw-cache-assets.add deleted file mode 100644 index 5f7414eee..000000000 --- a/changelog.d/sw-cache-assets.add +++ /dev/null @@ -1 +0,0 @@ -Cache assets and emojis with service worker diff --git a/changelog.d/sync-config.add b/changelog.d/sync-config.add new file mode 100644 index 000000000..76a5a1cca --- /dev/null +++ b/changelog.d/sync-config.add @@ -0,0 +1,2 @@ +settings synchronization +user highlight synchronization diff --git a/changelog.d/theme3-body-class.add b/changelog.d/theme3-body-class.add deleted file mode 100644 index f3d36fd70..000000000 --- a/changelog.d/theme3-body-class.add +++ /dev/null @@ -1 +0,0 @@ -Indicate currently active V3 theme as a body element class diff --git a/changelog.d/unify-show-hide-buttons.add b/changelog.d/unify-show-hide-buttons.add deleted file mode 100644 index 663bc38a5..000000000 --- a/changelog.d/unify-show-hide-buttons.add +++ /dev/null @@ -1 +0,0 @@ -Unify show/hide content buttons diff --git a/changelog.d/fix-wrap.skip b/changelog.d/woodpecker-pr-pipeline.skip similarity index 100% rename from changelog.d/fix-wrap.skip rename to changelog.d/woodpecker-pr-pipeline.skip diff --git a/docker-compose.e2e.yml b/docker-compose.e2e.yml new file mode 100644 index 000000000..75a4979a1 --- /dev/null +++ b/docker-compose.e2e.yml @@ -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"] diff --git a/docker/e2e/Dockerfile.e2e b/docker/e2e/Dockerfile.e2e new file mode 100644 index 000000000..e84359ceb --- /dev/null +++ b/docker/e2e/Dockerfile.e2e @@ -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"] diff --git a/docker/pleroma/entrypoint.e2e.sh b/docker/pleroma/entrypoint.e2e.sh new file mode 100644 index 000000000..96920eeae --- /dev/null +++ b/docker/pleroma/entrypoint.e2e.sh @@ -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" diff --git a/docs/CONFIGURATION.md b/docs/CONFIGURATION.md index dfc5f9dc3..8ca076931 100644 --- a/docs/CONFIGURATION.md +++ b/docs/CONFIGURATION.md @@ -7,9 +7,9 @@ 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) -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). -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) ) +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/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/src/src/stores/instance.js) ) ## Instance-defaults diff --git a/docs/HACKING.md b/docs/HACKING.md index a5c491136..88760b77a 100644 --- a/docs/HACKING.md +++ b/docs/HACKING.md @@ -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. -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. diff --git a/eslint.config.mjs b/eslint.config.mjs index 01bdb2038..417ff8cf3 100644 --- a/eslint.config.mjs +++ b/eslint.config.mjs @@ -1,37 +1,34 @@ -import vue from "eslint-plugin-vue"; -import js from "@eslint/js"; -import globals from "globals"; +import js from '@eslint/js' +import { defineConfig, globalIgnores } from 'eslint/config' +import vue from 'eslint-plugin-vue' +import globals from 'globals' - -export default [ +export default defineConfig([ ...vue.configs['flat/recommended'], - js.configs.recommended, + globalIgnores(['**/*.js', 'build/', 'dist/', 'config/']), { - files: ["**/*.js", "**/*.mjs", "**/*.vue"], - ignores: ["build/*.js", "config/*.js"], - + files: ['src/**/*.vue'], + plugins: { js }, + extends: ['js/recommended'], languageOptions: { ecmaVersion: 2024, - sourceType: "module", + sourceType: 'module', parserOptions: { - parser: "@babel/eslint-parser", + parser: '@babel/eslint-parser', }, globals: { ...globals.browser, ...globals.vitest, ...globals.chai, ...globals.commonjs, - ...globals.serviceworker - } + ...globals.serviceworker, + }, }, rules: { - 'arrow-parens': 0, - 'generator-star-spacing': 0, - 'no-debugger': 0, 'vue/require-prop-types': 0, 'vue/multi-word-component-names': 0, - } - } -] + }, + }, +]) diff --git a/index.html b/index.html index 96c20c4b7..26eeee19b 100644 --- a/index.html +++ b/index.html @@ -11,14 +11,12 @@ - - - + -
+
- { return null } if (!staticInitialResults) { - staticInitialResults = JSON.parse(document.getElementById('initial-results').textContent) + staticInitialResults = JSON.parse( + document.getElementById('initial-results').textContent, + ) } return staticInitialResults } @@ -54,7 +76,7 @@ const preloadFetch = async (request) => { return { ok: true, json: () => requestData, - text: () => requestData + text: () => requestData, } } @@ -63,20 +85,38 @@ const getInstanceConfig = async ({ store }) => { const res = await preloadFetch('/api/v1/instance') if (res.ok) { const data = await res.json() - const textlimit = data.max_toot_chars + const textLimit = data.max_toot_chars const vapidPublicKey = data.pleroma.vapid_public_key - store.dispatch('setInstanceOption', { name: 'pleromaExtensionsAvailable', value: data.pleroma }) - store.dispatch('setInstanceOption', { name: 'textlimit', value: textlimit }) - store.dispatch('setInstanceOption', { name: 'accountApprovalRequired', value: data.approval_required }) - store.dispatch('setInstanceOption', { name: 'birthdayRequired', value: !!data.pleroma?.metadata.birthday_required }) - store.dispatch('setInstanceOption', { name: 'birthdayMinAge', value: data.pleroma?.metadata.birthday_min_age || 0 }) + useInstanceCapabilitiesStore().set( + 'pleromaExtensionsAvailable', + data.pleroma, + ) + 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) { - store.dispatch('setInstanceOption', { name: 'vapidPublicKey', value: vapidPublicKey }) + useInstanceStore().set({ + path: 'vapidPublicKey', + value: vapidPublicKey, + }) } } else { - throw (res) + throw res } } catch (error) { console.error('Could not load instance config, potentially fatal') @@ -93,10 +133,12 @@ const getBackendProvidedConfig = async () => { const data = await res.json() return data.pleroma_fe } else { - throw (res) + throw res } } catch (error) { - console.error('Could not load backend-provided frontend config, potentially fatal') + console.error( + 'Could not load backend-provided frontend config, potentially fatal', + ) console.error(error) } } @@ -107,7 +149,7 @@ const getStaticConfig = async () => { if (res.ok) { return res.json() } else { - throw (res) + throw res } } catch (error) { console.warn('Failed to load static/config.json, continuing without it.') @@ -129,51 +171,23 @@ const setSettings = async ({ apiConfig, staticConfig, store }) => { config = Object.assign({}, staticConfig, apiConfig) } - const copyInstanceOption = (name) => { - store.dispatch('setInstanceOption', { name, value: config[name] }) - } - - copyInstanceOption('theme') - copyInstanceOption('style') - copyInstanceOption('palette') - 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 + Object.keys(INSTANCE_IDENTITY_DEFAULT_DEFINITIONS).forEach((source) => { + if (source === 'name') return + if (INSTANCE_IDENTIY_EXTERNAL.has(source)) return + useInstanceStore().set({ + value: config[source] ?? INSTANCE_IDENTITY_DEFAULT_DEFINITIONS[source].default, + path: `instanceIdentity.${source}`, + }) }) - store.dispatch('setInstanceOption', { - name: 'logoMargin', - value: typeof config.logoMargin === 'undefined' - ? 0 - : config.logoMargin - }) - copyInstanceOption('logoLeft') + Object.keys(INSTANCE_DEFAULT_CONFIG_DEFINITIONS).forEach((source) => + useInstanceStore().set({ + value: config[source] ?? INSTANCE_DEFAULT_CONFIG_DEFINITIONS[source].default, + path: `prefsStorage.${source}`, + }), + ) + 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 }) => { @@ -181,9 +195,9 @@ const getTOS = async ({ store }) => { const res = await window.fetch('/static/terms-of-service.html') if (res.ok) { const html = await res.text() - store.dispatch('setInstanceOption', { name: 'tos', value: html }) + useInstanceStore().set({ path: 'instanceIdentity.tos', value: html }) } else { - throw (res) + throw res } } catch (e) { console.warn("Can't load TOS\n", e) @@ -195,9 +209,12 @@ const getInstancePanel = async ({ store }) => { const res = await preloadFetch('/instance/panel.html') if (res.ok) { const html = await res.text() - store.dispatch('setInstanceOption', { name: 'instanceSpecificPanelContent', value: html }) + useInstanceStore().set({ + path: 'instanceIdentity.instanceSpecificPanelContent', + value: html, + }) } else { - throw (res) + throw res } } catch (e) { console.warn("Can't load instance panel\n", e) @@ -209,25 +226,27 @@ const getStickers = async ({ store }) => { const res = await window.fetch('/static/stickers.json') if (res.ok) { const values = await res.json() - const stickers = (await Promise.all( - Object.entries(values).map(async ([name, path]) => { - const resPack = await window.fetch(path + 'pack.json') - let meta = {} - if (resPack.ok) { - meta = await resPack.json() - } - return { - pack: name, - path, - meta - } - }) - )).sort((a, b) => { + const stickers = ( + await Promise.all( + Object.entries(values).map(async ([name, path]) => { + const resPack = await window.fetch(path + 'pack.json') + let meta = {} + if (resPack.ok) { + meta = await resPack.json() + } + return { + pack: name, + path, + meta, + } + }), + ) + ).sort((a, b) => { return a.meta.title.localeCompare(b.meta.title) }) - store.dispatch('setInstanceOption', { name: 'stickers', value: stickers }) + useEmojiStore().setStickers(stickers) } else { - throw (res) + throw res } } catch (e) { console.warn("Can't load stickers\n", e) @@ -237,13 +256,19 @@ const getStickers = async ({ store }) => { const getAppSecret = async ({ store }) => { const oauth = useOAuthStore() if (oauth.userToken) { - store.commit('setBackendInteractor', backendInteractorService(oauth.getToken)) + store.commit( + 'setBackendInteractor', + backendInteractorService(oauth.getToken), + ) } } const resolveStaffAccounts = ({ store, accounts }) => { - const nicknames = accounts.map(uri => uri.split('/').pop()) - store.dispatch('setInstanceOption', { name: 'staffAccounts', value: nicknames }) + const nicknames = accounts.map((uri) => uri.split('/').pop()) + useInstanceStore().set({ + path: 'staffAccounts', + value: nicknames, + }) } const getNodeInfo = async ({ store }) => { @@ -254,76 +279,165 @@ const getNodeInfo = async ({ store }) => { const data = await res.json() const metadata = data.metadata const features = metadata.features - store.dispatch('setInstanceOption', { name: 'name', value: metadata.nodeName }) - store.dispatch('setInstanceOption', { name: 'registrationOpen', value: data.openRegistrations }) - store.dispatch('setInstanceOption', { name: 'mediaProxyAvailable', value: features.includes('media_proxy') }) - store.dispatch('setInstanceOption', { name: 'safeDM', value: features.includes('safe_dm_mentions') }) - store.dispatch('setInstanceOption', { name: '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') + useInstanceStore().set({ + path: 'instanceIdentity.name', + value: metadata.nodeName, }) - store.dispatch('setInstanceOption', { name: 'pleromaBookmarkFoldersAvailable', value: features.includes('pleroma:bookmark_folders') }) - store.dispatch('setInstanceOption', { name: 'gopherAvailable', value: features.includes('gopher') }) - store.dispatch('setInstanceOption', { name: 'pollsAvailable', value: features.includes('polls') }) - store.dispatch('setInstanceOption', { name: 'editingAvailable', value: features.includes('editing') }) - store.dispatch('setInstanceOption', { name: 'pollLimits', value: metadata.pollLimits }) - store.dispatch('setInstanceOption', { name: 'mailerEnabled', value: metadata.mailerEnabled }) - store.dispatch('setInstanceOption', { name: 'quotingAvailable', value: features.includes('quote_posting') }) - store.dispatch('setInstanceOption', { name: 'groupActorAvailable', value: features.includes('pleroma:group_actors') }) - store.dispatch('setInstanceOption', { name: 'localBubbleInstances', value: metadata.localBubbleInstances ?? [] }) + useInstanceStore().set({ + path: 'registrationOpen', + value: data.openRegistrations, + }) + useInstanceCapabilitiesStore().set( + 'mediaProxyAvailable', + features.includes('media_proxy'), + ) + useInstanceCapabilitiesStore().set( + '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 - store.dispatch('setInstanceOption', { name: 'uploadlimit', value: parseInt(uploadLimits.general) }) - store.dispatch('setInstanceOption', { name: 'avatarlimit', value: parseInt(uploadLimits.avatar) }) - store.dispatch('setInstanceOption', { name: 'backgroundlimit', value: parseInt(uploadLimits.background) }) - store.dispatch('setInstanceOption', { name: 'bannerlimit', value: parseInt(uploadLimits.banner) }) - store.dispatch('setInstanceOption', { name: 'fieldsLimits', value: metadata.fieldsLimits }) + useInstanceStore().set({ + path: 'limits.uploadlimit', + value: parseInt(uploadLimits.general), + }) + 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 }) - store.dispatch('setInstanceOption', { name: 'postFormats', value: metadata.postFormats }) + useInstanceStore().set({ + path: 'restrictedNicknames', + value: metadata.restrictedNicknames, + }) + useInstanceCapabilitiesStore().set('postFormats', metadata.postFormats) const suggestions = metadata.suggestions - store.dispatch('setInstanceOption', { name: 'suggestionsEnabled', value: suggestions.enabled }) - store.dispatch('setInstanceOption', { name: 'suggestionsWeb', value: suggestions.web }) + useInstanceCapabilitiesStore().set( + 'suggestionsEnabled', + suggestions.enabled, + ) + // this is unused, why? + useInstanceCapabilitiesStore().set('suggestionsWeb', suggestions.web) const software = data.software - store.dispatch('setInstanceOption', { name: 'backendVersion', value: software.version }) - store.dispatch('setInstanceOption', { name: 'backendRepository', value: software.repository }) + useInstanceStore().set({ + path: 'backendVersion', + value: software.version, + }) + useInstanceStore().set({ + path: 'backendRepository', + value: software.repository, + }) const priv = metadata.private - store.dispatch('setInstanceOption', { name: 'private', value: priv }) + useInstanceStore().set({ path: 'privateMode', value: priv }) const frontendVersion = window.___pleromafe_commit_hash - store.dispatch('setInstanceOption', { name: 'frontendVersion', value: frontendVersion }) + useInstanceStore().set({ + path: 'frontendVersion', + value: frontendVersion, + }) const federation = metadata.federation - store.dispatch('setInstanceOption', { - name: 'tagPolicyAvailable', - value: typeof federation.mrf_policies === 'undefined' + useInstanceCapabilitiesStore().set( + 'tagPolicyAvailable', + typeof federation.mrf_policies === 'undefined' ? false - : metadata.federation.mrf_policies.includes('TagPolicy') - }) + : metadata.federation.mrf_policies.includes('TagPolicy'), + ) - store.dispatch('setInstanceOption', { name: 'federationPolicy', value: federation }) - store.dispatch('setInstanceOption', { - name: 'federating', - value: typeof federation.enabled === 'undefined' - ? true - : federation.enabled + useInstanceStore().set({ + path: 'federationPolicy', + value: federation, + }) + useInstanceStore().set({ + path: 'federating', + value: + typeof federation.enabled === 'undefined' ? true : federation.enabled, }) const accountActivationRequired = metadata.accountActivationRequired - store.dispatch('setInstanceOption', { name: 'accountActivationRequired', value: accountActivationRequired }) + useInstanceStore().set({ + path: 'accountActivationRequired', + value: accountActivationRequired, + }) const accounts = metadata.staffAccounts resolveStaffAccounts({ store, accounts }) } else { - throw (res) + throw res } } catch (e) { console.warn('Could not load nodeinfo') @@ -333,7 +447,10 @@ const getNodeInfo = async ({ store }) => { const setConfig = async ({ store }) => { // apiConfig, staticConfig - const configInfos = await Promise.all([getBackendProvidedConfig({ store }), getStaticConfig()]) + const configInfos = await Promise.all([ + getBackendProvidedConfig({ store }), + getStaticConfig(), + ]) const apiConfig = configInfos[0] const staticConfig = configInfos[1] @@ -364,29 +481,37 @@ const afterStoreSetup = async ({ pinia, store, storageError, i18n }) => { if (process.env.NODE_ENV === 'development') { // do some checks to avoid common errors 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( - Object.entries(allStores) - .map(async ([name, mod]) => { - const isStoreName = name => name.startsWith('use') - if (process.env.NODE_ENV === 'development') { - if (Object.keys(mod).filter(isStoreName).length !== 1) { - throw new Error('Each store file must export exactly one store as a named export. Check your code in src/stores/') - } + Object.entries(allStores).map(async ([name, mod]) => { + const isStoreName = (name) => name.startsWith('use') + if (process.env.NODE_ENV === 'development') { + if (Object.keys(mod).filter(isStoreName).length !== 1) { + throw new Error( + '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 p = mod[storeFuncName]().$persistLoaded - if (!(p instanceof Promise)) { - throw new Error(`${name} store's $persistLoaded is not a Promise. The persist plugin is not applied.`) - } - await p - } else { - throw new Error(`Store module ${name} does not export a 'use...' function`) + } + const storeFuncName = Object.keys(mod).find(isStoreName) + if (storeFuncName && typeof mod[storeFuncName] === 'function') { + const p = mod[storeFuncName]().$persistLoaded + if (!(p instanceof Promise)) { + throw new Error( + `${name} store's $persistLoaded is not a Promise. The persist plugin is not applied.`, + ) } - })) + await p + } else { + throw new Error( + `Store module ${name} does not export a 'use...' function`, + ) + } + }), + ) } try { @@ -397,30 +522,45 @@ const afterStoreSetup = async ({ pinia, store, storageError, i18n }) => { } if (storageError) { - useInterfaceStore().pushGlobalNotice({ messageKey: 'errors.storage_unavailable', level: 'error' }) + useInterfaceStore().pushGlobalNotice({ + messageKey: 'errors.storage_unavailable', + level: 'error', + }) } useInterfaceStore().setLayoutWidth(windowWidth()) useInterfaceStore().setLayoutHeight(windowHeight()) + window.syncConfig = useSyncConfigStore() + window.mergedConfig = useMergedConfigStore() + window.localConfig = useLocalConfigStore() + window.highlightConfig = useUserHighlightStore() + FaviconService.initFaviconService() initServiceWorker(store) window.addEventListener('focus', () => updateFocus()) const overrides = window.___pleromafe_dev_overrides || {} - const server = (typeof overrides.target !== 'undefined') ? overrides.target : window.location.origin - store.dispatch('setInstanceOption', { name: 'server', value: server }) + const server = + typeof overrides.target !== 'undefined' + ? overrides.target + : window.location.origin + useInstanceStore().set({ path: 'server', value: server }) await setConfig({ store }) 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) { window.splashError(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 // Most of these are preloaded into the index.html so blocking is minimized @@ -428,8 +568,8 @@ const afterStoreSetup = async ({ pinia, store, storageError, i18n }) => { checkOAuthToken({ store }), getInstancePanel({ store }), getNodeInfo({ store }), - getInstanceConfig({ store }) - ]).catch(e => Promise.reject(e)) + getInstanceConfig({ store }), + ]).catch((e) => Promise.reject(e)) // Start fetching things that don't need to block the UI store.dispatch('fetchMutes') @@ -442,11 +582,11 @@ const afterStoreSetup = async ({ pinia, store, storageError, i18n }) => { history: createWebHistory(), routes: routes(store), scrollBehavior: (to, _from, savedPosition) => { - if (to.matched.some(m => m.meta.dontScroll)) { + if (to.matched.some((m) => m.meta.dontScroll)) { return false } return savedPosition || { left: 0, top: 0 } - } + }, }) useI18nStore().setI18n(i18n) diff --git a/src/boot/routes.js b/src/boot/routes.js index 02abf8ce6..193daf4a7 100644 --- a/src/boot/routes.js +++ b/src/boot/routes.js @@ -1,42 +1,48 @@ -import PublicTimeline from 'components/public_timeline/public_timeline.vue' -import BubbleTimeline from 'components/bubble_timeline/bubble_timeline.vue' -import PublicAndExternalTimeline from 'components/public_and_external_timeline/public_and_external_timeline.vue' -import FriendsTimeline from 'components/friends_timeline/friends_timeline.vue' -import TagTimeline from 'components/tag_timeline/tag_timeline.vue' -import BookmarkTimeline from 'components/bookmark_timeline/bookmark_timeline.vue' -import ConversationPage from 'components/conversation-page/conversation-page.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 AnnouncementsPage from 'components/announcements_page/announcements_page.vue' -import QuotesTimeline from '../components/quotes_timeline/quotes_timeline.vue' +import AuthForm from 'components/auth_form/auth_form.js' +import BookmarkTimeline from 'components/bookmark_timeline/bookmark_timeline.vue' +import BubbleTimeline from 'components/bubble_timeline/bubble_timeline.vue' +import Chat from 'components/chat/chat.vue' +import ChatList from 'components/chat_list/chat_list.vue' +import ConversationPage from 'components/conversation-page/conversation-page.vue' +import DMs from 'components/dm_timeline/dm_timeline.vue' import Drafts from 'components/drafts/drafts.vue' -import BookmarkFolders from '../components/bookmark_folders/bookmark_folders.vue' +import FollowRequests from 'components/follow_requests/follow_requests.vue' +import FriendsTimeline from 'components/friends_timeline/friends_timeline.vue' +import Interactions from 'components/interactions/interactions.vue' +import Lists from 'components/lists/lists.vue' +import ListsEdit from 'components/lists_edit/lists_edit.vue' +import ListsTimeline from 'components/lists_timeline/lists_timeline.vue' +import Notifications from 'components/notifications/notifications.vue' +import OAuthCallback from 'components/oauth_callback/oauth_callback.vue' +import PasswordReset from 'components/password_reset/password_reset.vue' +import PublicAndExternalTimeline from 'components/public_and_external_timeline/public_and_external_timeline.vue' +import PublicTimeline from 'components/public_timeline/public_timeline.vue' +import Registration from 'components/registration/registration.vue' +import RemoteUserResolver from 'components/remote_user_resolver/remote_user_resolver.vue' +import Search from 'components/search/search.vue' +import ShoutPanel from 'components/shout_panel/shout_panel.vue' +import TagTimeline from 'components/tag_timeline/tag_timeline.vue' +import UserProfile from 'components/user_profile/user_profile.vue' +import WhoToFollow from 'components/who_to_follow/who_to_follow.vue' + +import NavPanel from 'src/components/nav_panel/nav_panel.vue' import BookmarkFolderEdit from '../components/bookmark_folder_edit/bookmark_folder_edit.vue' +import BookmarkFolders from '../components/bookmark_folders/bookmark_folders.vue' +import QuotesTimeline from '../components/quotes_timeline/quotes_timeline.vue' + +import { useInstanceStore } from 'src/stores/instance.js' +import { useInstanceCapabilitiesStore } from 'src/stores/instance_capabilities.js' export default (store) => { const validateAuthenticatedRoute = (to, from, next) => { if (store.state.users.currentUser) { next() } else { - next(store.state.instance.redirectRootNoLogin || '/main/all') + next( + useInstanceStore().instanceIdentity.redirectRootNoLogin || '/main/all', + ) } } @@ -45,46 +51,125 @@ export default (store) => { name: 'root', path: '/', redirect: () => { - return (store.state.users.currentUser - ? store.state.instance.redirectRootLogin - : store.state.instance.redirectRootNoLogin) || '/main/all' - } + return ( + (store.state.users.currentUser + ? 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: 'bookmarks', path: '/bookmarks', component: BookmarkTimeline }, { 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: 'remote-user-profile-acct', path: '/remote-users/:_(@)?:username([^/@]+)@:hostname([^/@]+)', component: RemoteUserResolver, - beforeEnter: validateAuthenticatedRoute + beforeEnter: validateAuthenticatedRoute, }, { name: 'remote-user-profile', path: '/remote-users/:hostname/:username', component: RemoteUserResolver, - beforeEnter: validateAuthenticatedRoute + beforeEnter: validateAuthenticatedRoute, + }, + { + 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: '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: '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: '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: '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 }, @@ -92,17 +177,51 @@ export default (store) => { { 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 } + { + 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([ - { 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: Chat, + meta: { dontScroll: false }, + beforeEnter: validateAuthenticatedRoute, + }, + { + name: 'chats', + path: '/users/:username/chats', + component: ChatList, + meta: { dontScroll: false }, + beforeEnter: validateAuthenticatedRoute, + }, ]) } diff --git a/src/components/about/about.js b/src/components/about/about.js index 1df258450..dc6733491 100644 --- a/src/components/about/about.js +++ b/src/components/about/about.js @@ -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 StaffPanel from '../staff_panel/staff_panel.vue' +import InstanceSpecificPanel from '../instance_specific_panel/instance_specific_panel.vue' import MRFTransparencyPanel from '../mrf_transparency_panel/mrf_transparency_panel.vue' +import StaffPanel from '../staff_panel/staff_panel.vue' +import TermsOfServicePanel from '../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 = { components: { @@ -10,16 +18,24 @@ const About = { FeaturesPanel, TermsOfServicePanel, StaffPanel, - MRFTransparencyPanel + MRFTransparencyPanel, }, computed: { - showFeaturesPanel () { return this.$store.state.instance.showFeaturesPanel }, - showInstanceSpecificPanel () { - return this.$store.state.instance.showInstanceSpecificPanel && - !this.$store.getters.mergedConfig.hideISP && - this.$store.state.instance.instanceSpecificPanelContent - } - } + showFeaturesPanel() { + return useInstanceStore().instanceIdentity.showFeaturesPanel + }, + frontendVersionLink() { + return pleromaFeCommitUrl + this.frontendVersion + }, + ...mapState(useInstanceStore, ['backendVersion', 'backendRepository', 'frontendVersion']), + showInstanceSpecificPanel() { + return ( + useInstanceStore().instanceIdentity.showInstanceSpecificPanel && + !useMergedConfigStore().mergedConfig.hideISP && + useInstanceStore().instanceIdentity.instanceSpecificPanelContent + ) + }, + }, } export default About diff --git a/src/components/about/about.vue b/src/components/about/about.vue index 8a551485f..df395c7dd 100644 --- a/src/components/about/about.vue +++ b/src/components/about/about.vue @@ -1,11 +1,45 @@ + diff --git a/src/components/account_actions/account_actions.js b/src/components/account_actions/account_actions.js index 9a63f57eb..f204adbde 100644 --- a/src/components/account_actions/account_actions.js +++ b/src/components/account_actions/account_actions.js @@ -1,99 +1,105 @@ -import { mapState } from 'vuex' -import ProgressButton from '../progress_button/progress_button.vue' -import Popover from '../popover/popover.vue' +import { mapState } from 'pinia' + import UserListMenu from 'src/components/user_list_menu/user_list_menu.vue' +import UserTimedFilterModal from 'src/components/user_timed_filter_modal/user_timed_filter_modal.vue' import ConfirmModal from '../confirm_modal/confirm_modal.vue' -import { library } from '@fortawesome/fontawesome-svg-core' -import { - faEllipsisV -} from '@fortawesome/free-solid-svg-icons' +import Popover from '../popover/popover.vue' +import ProgressButton from '../progress_button/progress_button.vue' + +import { useInstanceCapabilitiesStore } from 'src/stores/instance_capabilities.js' +import { useMergedConfigStore } from 'src/stores/merged_config.js' import { useReportsStore } from 'src/stores/reports' -library.add( - faEllipsisV -) +import { library } from '@fortawesome/fontawesome-svg-core' +import { faEllipsisV } from '@fortawesome/free-solid-svg-icons' + +library.add(faEllipsisV) const AccountActions = { - props: [ - 'user', 'relationship' - ], - data () { + props: ['user', 'relationship'], + data() { return { showingConfirmBlock: false, - showingConfirmRemoveFollower: false + showingConfirmRemoveFollower: false, } }, components: { ProgressButton, Popover, UserListMenu, - ConfirmModal + ConfirmModal, + UserTimedFilterModal, }, methods: { - showConfirmBlock () { - this.showingConfirmBlock = true - }, - hideConfirmBlock () { - this.showingConfirmBlock = false - }, - showConfirmRemoveUserFromFollowers () { + showConfirmRemoveUserFromFollowers() { this.showingConfirmRemoveFollower = true }, - hideConfirmRemoveUserFromFollowers () { + hideConfirmRemoveUserFromFollowers() { this.showingConfirmRemoveFollower = false }, - showRepeats () { + hideConfirmBlock() { + this.showingConfirmBlock = false + }, + showRepeats() { this.$store.dispatch('showReblogs', this.user.id) }, - hideRepeats () { + hideRepeats() { this.$store.dispatch('hideReblogs', this.user.id) }, - blockUser () { - if (!this.shouldConfirmBlock) { - this.doBlockUser() + blockUser() { + if (this.$refs.timedBlockDialog) { + this.$refs.timedBlockDialog.optionallyPrompt() } else { - this.showConfirmBlock() + if (!this.shouldConfirmBlock) { + this.doBlockUser() + } else { + this.showingConfirmBlock = true + } } }, - doBlockUser () { - this.$store.dispatch('blockUser', this.user.id) + doBlockUser() { + this.$store.dispatch('blockUser', { id: this.user.id }) this.hideConfirmBlock() }, - unblockUser () { + unblockUser() { this.$store.dispatch('unblockUser', this.user.id) }, - removeUserFromFollowers () { + removeUserFromFollowers() { if (!this.shouldConfirmRemoveUserFromFollowers) { this.doRemoveUserFromFollowers() } else { this.showConfirmRemoveUserFromFollowers() } }, - doRemoveUserFromFollowers () { + doRemoveUserFromFollowers() { this.$store.dispatch('removeUserFromFollowers', this.user.id) this.hideConfirmRemoveUserFromFollowers() }, - reportUser () { + reportUser() { useReportsStore().openUserReportingModal({ userId: this.user.id }) }, - openChat () { + openChat() { this.$router.push({ 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: { - shouldConfirmBlock () { - return this.$store.getters.mergedConfig.modalOnBlock + shouldConfirmBlock() { + return useMergedConfigStore().mergedConfig.modalOnBlock }, - shouldConfirmRemoveUserFromFollowers () { - return this.$store.getters.mergedConfig.modalOnRemoveUserFromFollowers + shouldConfirmRemoveUserFromFollowers() { + return useMergedConfigStore().mergedConfig.modalOnRemoveUserFromFollowers }, - ...mapState({ - pleromaChatMessagesAvailable: state => state.instance.pleromaChatMessagesAvailable - }) - } + ...mapState(useInstanceCapabilitiesStore, [ + 'blockExpiration', + 'pleromaChatMessagesAvailable', + ]), + }, } export default AccountActions diff --git a/src/components/account_actions/account_actions.vue b/src/components/account_actions/account_actions.vue index fd4837ee4..94cb91ee0 100644 --- a/src/components/account_actions/account_actions.vue +++ b/src/components/account_actions/account_actions.vue @@ -3,7 +3,6 @@ diff --git a/src/components/alert.style.js b/src/components/alert.style.js index 868514764..8a6f842ed 100644 --- a/src/components/alert.style.js +++ b/src/components/alert.style.js @@ -1,57 +1,51 @@ export default { name: 'Alert', selector: '.alert', - validInnerComponents: [ - 'Text', - 'Icon', - 'Link', - 'Border', - 'ButtonUnstyled' - ], + validInnerComponents: ['Text', 'Icon', 'Link', 'Border', 'ButtonUnstyled'], variants: { normal: '.neutral', error: '.error', warning: '.warning', - success: '.success' + success: '.success', }, editor: { border: 1, - aspect: '3 / 1' + aspect: '3 / 1', }, defaultRules: [ { directives: { background: '--text', opacity: 0.5, - blur: '9px' - } + blur: '9px', + }, }, { parent: { - component: 'Alert' + component: 'Alert', }, component: 'Border', directives: { - textColor: '--parent' - } + textColor: '--parent', + }, }, { variant: 'error', directives: { - background: '--cRed' - } + background: '--cRed', + }, }, { variant: 'warning', directives: { - background: '--cOrange' - } + background: '--cOrange', + }, }, { variant: 'success', directives: { - background: '--cGreen' - } - } - ] + background: '--cGreen', + }, + }, + ], } diff --git a/src/components/announcement/announcement.js b/src/components/announcement/announcement.js index d1b8257d8..13d55c159 100644 --- a/src/components/announcement/announcement.js +++ b/src/components/announcement/announcement.js @@ -1,109 +1,130 @@ import { mapState } from 'vuex' + +import localeService from '../../services/locale/locale.service.js' import AnnouncementEditor from '../announcement_editor/announcement_editor.vue' import RichContent from '../rich_content/rich_content.jsx' -import localeService from '../../services/locale/locale.service.js' -import { useAnnouncementsStore } from 'src/stores/announcements' + +import { useAnnouncementsStore } from 'src/stores/announcements.js' const Announcement = { components: { AnnouncementEditor, - RichContent + RichContent, }, - data () { + data() { return { editing: false, editedAnnouncement: { content: '', startsAt: undefined, endsAt: undefined, - allDay: undefined + allDay: undefined, }, - editError: '' + editError: '', } }, props: { - announcement: Object + announcement: Object, }, computed: { ...mapState({ - currentUser: state => state.users.currentUser + currentUser: (state) => state.users.currentUser, }), - canEditAnnouncement () { - return this.currentUser && this.currentUser.privileges.includes('announcements_manage_announcements') + canEditAnnouncement() { + return ( + this.currentUser && + this.currentUser.privileges.includes( + 'announcements_manage_announcements', + ) + ) }, - content () { + content() { return this.announcement.content }, - isRead () { + isRead() { return this.announcement.read }, - publishedAt () { + publishedAt() { const time = this.announcement.published_at if (!time) { return } - return this.formatTimeOrDate(time, localeService.internalToBrowserLocale(this.$i18n.locale)) + return this.formatTimeOrDate( + time, + localeService.internalToBrowserLocale(this.$i18n.locale), + ) }, - startsAt () { + startsAt() { const time = this.announcement.starts_at if (!time) { return } - return this.formatTimeOrDate(time, localeService.internalToBrowserLocale(this.$i18n.locale)) + return this.formatTimeOrDate( + time, + localeService.internalToBrowserLocale(this.$i18n.locale), + ) }, - endsAt () { + endsAt() { const time = this.announcement.ends_at if (!time) { return } - return this.formatTimeOrDate(time, localeService.internalToBrowserLocale(this.$i18n.locale)) + return this.formatTimeOrDate( + time, + localeService.internalToBrowserLocale(this.$i18n.locale), + ) }, - inactive () { + inactive() { return this.announcement.inactive - } + }, }, methods: { - markAsRead () { + markAsRead() { if (!this.isRead) { - return useAnnouncementsStore().markAnnouncementAsRead(this.announcement.id) + return useAnnouncementsStore().markAnnouncementAsRead( + this.announcement.id, + ) } }, - deleteAnnouncement () { + deleteAnnouncement() { return useAnnouncementsStore().deleteAnnouncement(this.announcement.id) }, - formatTimeOrDate (time, locale) { + formatTimeOrDate(time, locale) { const d = new Date(time) - return this.announcement.all_day ? d.toLocaleDateString(locale) : d.toLocaleString(locale) + return this.announcement.all_day + ? d.toLocaleDateString(locale) + : d.toLocaleString(locale) }, - enterEditMode () { + enterEditMode() { this.editedAnnouncement.content = this.announcement.pleroma.raw_content this.editedAnnouncement.startsAt = this.announcement.starts_at this.editedAnnouncement.endsAt = this.announcement.ends_at this.editedAnnouncement.allDay = this.announcement.all_day this.editing = true }, - submitEdit () { - useAnnouncementsStore().editAnnouncement({ - id: this.announcement.id, - ...this.editedAnnouncement - }) + submitEdit() { + useAnnouncementsStore() + .editAnnouncement({ + id: this.announcement.id, + ...this.editedAnnouncement, + }) .then(() => { this.editing = false }) - .catch(error => { + .catch((error) => { this.editError = error.error }) }, - cancelEdit () { + cancelEdit() { this.editing = false }, - clearError () { + clearError() { this.editError = undefined - } - } + }, + }, } export default Announcement diff --git a/src/components/announcement_editor/announcement_editor.js b/src/components/announcement_editor/announcement_editor.js index 79a03afe1..6d22ac1fd 100644 --- a/src/components/announcement_editor/announcement_editor.js +++ b/src/components/announcement_editor/announcement_editor.js @@ -2,12 +2,12 @@ import Checkbox from '../checkbox/checkbox.vue' const AnnouncementEditor = { components: { - Checkbox + Checkbox, }, props: { announcement: Object, - disabled: Boolean - } + disabled: Boolean, + }, } export default AnnouncementEditor diff --git a/src/components/announcements_page/announcements_page.js b/src/components/announcements_page/announcements_page.js index 9ce0b45f5..b8b1f000a 100644 --- a/src/components/announcements_page/announcements_page.js +++ b/src/components/announcements_page/announcements_page.js @@ -1,59 +1,67 @@ import { mapState } from 'vuex' + import Announcement from '../announcement/announcement.vue' import AnnouncementEditor from '../announcement_editor/announcement_editor.vue' -import { useAnnouncementsStore } from 'src/stores/announcements' + +import { useAnnouncementsStore } from 'src/stores/announcements.js' const AnnouncementsPage = { components: { Announcement, - AnnouncementEditor + AnnouncementEditor, }, - data () { + data() { return { newAnnouncement: { content: '', startsAt: undefined, endsAt: undefined, - allDay: false + allDay: false, }, posting: false, - error: undefined + error: undefined, } }, - mounted () { + mounted() { useAnnouncementsStore().fetchAnnouncements() }, computed: { ...mapState({ - currentUser: state => state.users.currentUser + currentUser: (state) => state.users.currentUser, }), - announcements () { + announcements() { return useAnnouncementsStore().announcements }, - canPostAnnouncement () { - return this.currentUser && this.currentUser.privileges.includes('announcements_manage_announcements') - } + canPostAnnouncement() { + return ( + this.currentUser && + this.currentUser.privileges.includes( + 'announcements_manage_announcements', + ) + ) + }, }, methods: { - postAnnouncement () { + postAnnouncement() { this.posting = true - useAnnouncementsStore().postAnnouncement(this.newAnnouncement) + useAnnouncementsStore() + .postAnnouncement(this.newAnnouncement) .then(() => { this.newAnnouncement.content = '' this.startsAt = undefined this.endsAt = undefined }) - .catch(error => { + .catch((error) => { this.error = error.error }) .finally(() => { this.posting = false }) }, - clearError () { + clearError() { this.error = undefined - } - } + }, + }, } export default AnnouncementsPage diff --git a/src/components/async_component_error/async_component_error.vue b/src/components/async_component_error/async_component_error.vue index 2ff8974c1..baf430950 100644 --- a/src/components/async_component_error/async_component_error.vue +++ b/src/components/async_component_error/async_component_error.vue @@ -21,10 +21,10 @@ export default { emits: ['resetAsyncComponent'], methods: { - retry () { + retry() { this.$emit('resetAsyncComponent') - } - } + }, + }, } diff --git a/src/components/attachment/attachment.js b/src/components/attachment/attachment.js index 21d793930..fbe77a687 100644 --- a/src/components/attachment/attachment.js +++ b/src/components/attachment/attachment.js @@ -1,24 +1,29 @@ -import StillImage from '../still-image/still-image.vue' -import Flash from '../flash/flash.vue' -import VideoAttachment from '../video_attachment/video_attachment.vue' +import { mapState } from 'pinia' + import nsfwImage from '../../assets/nsfw.png' -import fileTypeService from '../../services/file_type/file_type.service.js' -import { mapGetters } from 'vuex' +import Flash from '../flash/flash.vue' +import StillImage from '../still-image/still-image.vue' +import VideoAttachment from '../video_attachment/video_attachment.vue' + +import { useInstanceStore } from 'src/stores/instance.js' +import { useInstanceCapabilitiesStore } from 'src/stores/instance_capabilities.js' +import { useMediaViewerStore } from 'src/stores/media_viewer' +import { useMergedConfigStore } from 'src/stores/merged_config.js' + import { library } from '@fortawesome/fontawesome-svg-core' import { + faAlignRight, faFile, - faMusic, faImage, - faVideo, - faPlayCircle, - faTimes, - faStop, - faSearchPlus, - faTrashAlt, + faMusic, faPencilAlt, - faAlignRight + faPlayCircle, + faSearchPlus, + faStop, + faTimes, + faTrashAlt, + faVideo, } from '@fortawesome/free-solid-svg-icons' -import { useMediaViewerStore } from 'src/stores/media_viewer' library.add( faFile, @@ -31,7 +36,7 @@ library.add( faSearchPlus, faTrashAlt, faPencilAlt, - faAlignRight + faAlignRight, ) const Attachment = { @@ -46,72 +51,72 @@ const Attachment = { 'remove', 'shiftUp', 'shiftDn', - 'edit' + 'edit', ], - data () { + data() { return { localDescription: this.description || this.attachment.description, - nsfwImage: this.$store.state.instance.nsfwCensorImage || nsfwImage, - hideNsfwLocal: this.$store.getters.mergedConfig.hideNsfw, - preloadImage: this.$store.getters.mergedConfig.preloadImage, + nsfwImage: + useInstanceStore().instanceIdentity.nsfwCensorImage || nsfwImage, + hideNsfwLocal: useMergedConfigStore().mergedConfig.hideNsfw, + preloadImage: useMergedConfigStore().mergedConfig.preloadImage, loading: false, - img: fileTypeService.fileType(this.attachment.mimetype) === 'image' && document.createElement('img'), + img: this.attachment.type === 'image' && document.createElement('img'), modalOpen: false, showHidden: false, flashLoaded: false, - showDescription: false + showDescription: false, } }, components: { Flash, StillImage, - VideoAttachment + VideoAttachment, }, computed: { - classNames () { + classNames() { return [ { '-loading': this.loading, '-nsfw-placeholder': this.hidden, '-editable': this.edit !== undefined, - '-compact': this.compact + '-compact': this.compact, }, - '-type-' + this.type, + '-type-' + this.attachment.type, this.size && '-size-' + this.size, - `-${this.useContainFit ? 'contain' : 'cover'}-fit` + `-${this.useContainFit ? 'contain' : 'cover'}-fit`, ] }, - usePlaceholder () { + usePlaceholder() { return this.size === 'hide' }, - useContainFit () { - return this.$store.getters.mergedConfig.useContainFit + useContainFit() { + return this.mergedConfig.useContainFit }, - placeholderName () { + placeholderName() { if (this.attachment.description === '' || !this.attachment.description) { - return this.type.toUpperCase() + return this.attachment.type.toUpperCase() } return this.attachment.description }, - placeholderIconClass () { - if (this.type === 'image') return 'image' - if (this.type === 'video') return 'video' - if (this.type === 'audio') return 'music' + placeholderIconClass() { + if (this.attachment.type === 'image') return 'image' + if (this.attachment.type === 'video') return 'video' + if (this.attachment.type === 'audio') return 'music' return 'file' }, - referrerpolicy () { - return this.$store.state.instance.mediaProxyAvailable ? '' : 'no-referrer' + referrerpolicy() { + return useInstanceCapabilitiesStore().mediaProxyAvailable + ? '' + : 'no-referrer' }, - type () { - return fileTypeService.fileType(this.attachment.mimetype) - }, - hidden () { + hidden() { return this.nsfw && this.hideNsfwLocal && !this.showHidden }, - isEmpty () { - return (this.type === 'html' && !this.attachment.oembed) + isEmpty() { + return this.attachment.type === 'html' && !this.attachment.oembed }, - useModal () { + useModal() { let modalTypes = [] switch (this.size) { case 'hide': @@ -124,64 +129,66 @@ const Attachment = { : ['image'] break } - return modalTypes.includes(this.type) + return modalTypes.includes(this.attachment.type) }, - videoTag () { + videoTag() { return this.useModal ? 'button' : 'span' }, - ...mapGetters(['mergedConfig']) + ...mapState(useMergedConfigStore, ['mergedConfig']), }, watch: { - 'attachment.description' (newVal) { + 'attachment.description'(newVal) { this.localDescription = newVal }, - localDescription (newVal) { + localDescription(newVal) { this.onEdit(newVal) - } + }, }, methods: { - linkClicked ({ target }) { + linkClicked({ target }) { if (target.tagName === 'A') { window.open(target.href, '_blank') } }, - openModal () { + openModal() { if (this.useModal) { this.$emit('setMedia') useMediaViewerStore().setCurrentMedia(this.attachment) - } else if (this.type === 'unknown') { + } else if (this.attachment.type === 'unknown') { window.open(this.attachment.url) } }, - openModalForce () { + openModalForce() { this.$emit('setMedia') useMediaViewerStore().setCurrentMedia(this.attachment) }, - onEdit (event) { + onEdit(event) { this.edit && this.edit(this.attachment, event) }, - onRemove () { + onRemove() { this.remove && this.remove(this.attachment) }, - onShiftUp () { + onShiftUp() { this.shiftUp && this.shiftUp(this.attachment) }, - onShiftDn () { + onShiftDn() { this.shiftDn && this.shiftDn(this.attachment) }, - stopFlash () { + stopFlash() { this.$refs.flash.closePlayer() }, - setFlashLoaded (event) { + setFlashLoaded(event) { this.flashLoaded = event }, - toggleDescription () { + toggleDescription() { this.showDescription = !this.showDescription }, - toggleHidden (event) { + toggleHidden(event) { if ( - (this.mergedConfig.useOneClickNsfw && !this.showHidden) && - (this.type !== 'video' || this.mergedConfig.playVideosInModal) + this.mergedConfig.useOneClickNsfw && + !this.showHidden && + (this.attachment.type !== 'video' || + this.mergedConfig.playVideosInModal) ) { this.openModal(event) return @@ -201,12 +208,12 @@ const Attachment = { this.showHidden = !this.showHidden } }, - onImageLoad (image) { + onImageLoad(image) { const width = image.naturalWidth const height = image.naturalHeight this.$emit('naturalSizeLoad', { id: this.attachment.id, width, height }) - } - } + }, + }, } export default Attachment diff --git a/src/components/attachment/attachment.scss b/src/components/attachment/attachment.scss index 16346c97c..97515eb32 100644 --- a/src/components/attachment/attachment.scss +++ b/src/components/attachment/attachment.scss @@ -107,9 +107,9 @@ .play-icon { position: absolute; - font-size: 64px; - top: calc(50% - 32px); - left: calc(50% - 32px); + font-size: 4.5em; + top: calc(50% - 2.25rem); + left: calc(50% - 2.25rem); color: rgb(255 255 255 / 75%); text-shadow: 0 0 2px rgb(0 0 0 / 40%); diff --git a/src/components/attachment/attachment.style.js b/src/components/attachment/attachment.style.js deleted file mode 100644 index a9455e367..000000000 --- a/src/components/attachment/attachment.style.js +++ /dev/null @@ -1,27 +0,0 @@ -export default { - name: 'Attachment', - selector: '.Attachment', - notEditable: true, - validInnerComponents: [ - 'Border', - 'Button', - 'Input' - ], - defaultRules: [ - { - directives: { - roundness: 3 - } - }, - { - component: 'Button', - parent: { - component: 'Attachment' - }, - directives: { - background: '#FFFFFF', - opacity: 0.5 - } - } - ] -} diff --git a/src/components/attachment/attachment.vue b/src/components/attachment/attachment.vue index 0701a393e..0db86ff8a 100644 --- a/src/components/attachment/attachment.vue +++ b/src/components/attachment/attachment.vue @@ -6,7 +6,7 @@ @click="openModal" >
diff --git a/src/components/block_card/block_card.js b/src/components/block_card/block_card.js index 0bf4e37bc..967ce2a3c 100644 --- a/src/components/block_card/block_card.js +++ b/src/components/block_card/block_card.js @@ -1,40 +1,50 @@ +import { mapState } from 'pinia' + +import UserTimedFilterModal from 'src/components/user_timed_filter_modal/user_timed_filter_modal.vue' import BasicUserCard from '../basic_user_card/basic_user_card.vue' +import { useInstanceCapabilitiesStore } from 'src/stores/instance_capabilities.js' + const BlockCard = { props: ['userId'], - data () { - return { - progress: false - } - }, computed: { - user () { + user() { return this.$store.getters.findUser(this.userId) }, - relationship () { + relationship() { return this.$store.getters.relationship(this.userId) }, - blocked () { + blocked() { return this.relationship.blocking - } + }, + blockExpiryAvailable() { + return Object.hasOwn(this.user, 'block_expires_at') + }, + blockExpiry() { + return this.user.block_expires_at === false + ? this.$t('user_card.block_expires_forever') + : this.$t('user_card.block_expires_at', [ + new Date(this.user.mute_expires_at).toLocaleString(), + ]) + }, + ...mapState(useInstanceCapabilitiesStore, ['blockExpiration']), }, components: { - BasicUserCard + BasicUserCard, + UserTimedFilterModal, }, methods: { - unblockUser () { - this.progress = true - this.$store.dispatch('unblockUser', this.user.id).then(() => { - this.progress = false - }) + unblockUser() { + this.$store.dispatch('unblockUser', this.user.id) }, - blockUser () { - this.progress = true - this.$store.dispatch('blockUser', this.user.id).then(() => { - this.progress = false - }) - } - } + blockUser() { + if (this.blockExpiration) { + this.$refs.timedBlockDialog.optionallyPrompt() + } else { + this.$store.dispatch('blockUser', { id: this.user.id }) + } + }, + }, } export default BlockCard diff --git a/src/components/block_card/block_card.vue b/src/components/block_card/block_card.vue index b14ef8448..90b9a2b16 100644 --- a/src/components/block_card/block_card.vue +++ b/src/components/block_card/block_card.vue @@ -1,33 +1,35 @@ diff --git a/src/components/bookmark_folder_card/bookmark_folder_card.js b/src/components/bookmark_folder_card/bookmark_folder_card.js index bf274d9d7..37b3f2e5e 100644 --- a/src/components/bookmark_folder_card/bookmark_folder_card.js +++ b/src/components/bookmark_folder_card/bookmark_folder_card.js @@ -1,22 +1,15 @@ import { library } from '@fortawesome/fontawesome-svg-core' -import { - faEllipsisH -} from '@fortawesome/free-solid-svg-icons' +import { faEllipsisH } from '@fortawesome/free-solid-svg-icons' -library.add( - faEllipsisH -) +library.add(faEllipsisH) const BookmarkFolderCard = { - props: [ - 'folder', - 'allBookmarks' - ], + props: ['folder', 'allBookmarks'], computed: { - firstLetter () { + firstLetter() { return this.folder ? this.folder.name[0] : null - } - } + }, + }, } export default BookmarkFolderCard diff --git a/src/components/bookmark_folder_edit/bookmark_folder_edit.js b/src/components/bookmark_folder_edit/bookmark_folder_edit.js index bb96585bf..a036895bf 100644 --- a/src/components/bookmark_folder_edit/bookmark_folder_edit.js +++ b/src/components/bookmark_folder_edit/bookmark_folder_edit.js @@ -1,10 +1,11 @@ -import EmojiPicker from '../emoji_picker/emoji_picker.vue' import apiService from '../../services/api/api.service' -import { useInterfaceStore } from 'src/stores/interface' -import { useBookmarkFoldersStore } from 'src/stores/bookmark_folders' +import EmojiPicker from '../emoji_picker/emoji_picker.vue' + +import { useBookmarkFoldersStore } from 'src/stores/bookmark_folders.js' +import { useInterfaceStore } from 'src/stores/interface.js' const BookmarkFolderEdit = { - data () { + data() { return { name: '', nameDraft: '', @@ -13,54 +14,59 @@ const BookmarkFolderEdit = { emojiDraft: '', emojiUrlDraft: null, emojiPickerExpanded: false, - reallyDelete: false + reallyDelete: false, } }, components: { - EmojiPicker + EmojiPicker, }, - created () { + created() { if (!this.id) return const credentials = this.$store.state.users.currentUser.credentials - apiService.fetchBookmarkFolders({ credentials }) - .then((folders) => { - const folder = folders.find(folder => folder.id === this.id) - if (!folder) return + apiService.fetchBookmarkFolders({ credentials }).then((folders) => { + const folder = folders.find((folder) => folder.id === this.id) + if (!folder) return - this.nameDraft = this.name = folder.name - this.emojiDraft = this.emoji = folder.emoji - this.emojiUrlDraft = this.emojiUrl = folder.emoji_url - }) + this.nameDraft = this.name = folder.name + this.emojiDraft = this.emoji = folder.emoji + this.emojiUrlDraft = this.emojiUrl = folder.emoji_url + }) }, computed: { - id () { + id() { return this.$route.params.id - } + }, }, methods: { - selectEmoji (event) { + selectEmoji(event) { this.emojiDraft = event.insertion this.emojiUrlDraft = event.insertionUrl }, - showEmojiPicker () { + showEmojiPicker() { if (!this.emojiPickerExpanded) { this.$refs.picker.showPicker() } }, - onShowPicker () { + onShowPicker() { this.emojiPickerExpanded = true }, - onClosePicker () { + onClosePicker() { this.emojiPickerExpanded = false }, - updateFolder () { - useBookmarkFoldersStore().updateBookmarkFolder({ folderId: this.id, name: this.nameDraft, emoji: this.emojiDraft }) + updateFolder() { + useBookmarkFoldersStore() + .updateBookmarkFolder({ + folderId: this.id, + name: this.nameDraft, + emoji: this.emojiDraft, + }) .then(() => { this.$router.push({ name: 'bookmark-folders' }) }) }, - createFolder () { - useBookmarkFoldersStore().createBookmarkFolder({ name: this.nameDraft, emoji: this.emojiDraft }) + createFolder() { + useBookmarkFoldersStore() + .createBookmarkFolder({ name: this.nameDraft, emoji: this.emojiDraft }) .then(() => { this.$router.push({ name: 'bookmark-folders' }) }) @@ -68,15 +74,15 @@ const BookmarkFolderEdit = { useInterfaceStore().pushGlobalNotice({ messageKey: 'bookmark_folders.error', messageArgs: [e.message], - level: 'error' + level: 'error', }) }) }, - deleteFolder () { + deleteFolder() { useBookmarkFoldersStore().deleteBookmarkFolder({ folderId: this.id }) this.$router.push({ name: 'bookmark-folders' }) - } - } + }, + }, } export default BookmarkFolderEdit diff --git a/src/components/bookmark_folders/bookmark_folders.js b/src/components/bookmark_folders/bookmark_folders.js index 096f3769d..1147fd3a5 100644 --- a/src/components/bookmark_folders/bookmark_folders.js +++ b/src/components/bookmark_folders/bookmark_folders.js @@ -1,28 +1,29 @@ import BookmarkFolderCard from '../bookmark_folder_card/bookmark_folder_card.vue' -import { useBookmarkFoldersStore } from 'src/stores/bookmark_folders' + +import { useBookmarkFoldersStore } from 'src/stores/bookmark_folders.js' const BookmarkFolders = { - data () { + data() { return { - isNew: false + isNew: false, } }, components: { - BookmarkFolderCard + BookmarkFolderCard, }, computed: { - bookmarkFolders () { + bookmarkFolders() { return useBookmarkFoldersStore().allFolders - } + }, }, methods: { - cancelNewFolder () { + cancelNewFolder() { this.isNew = false }, - newFolder () { + newFolder() { this.isNew = true - } - } + }, + }, } export default BookmarkFolders diff --git a/src/components/bookmark_folders_menu/bookmark_folders_menu_content.js b/src/components/bookmark_folders_menu/bookmark_folders_menu_content.js index dc46b91b3..be1fb06ea 100644 --- a/src/components/bookmark_folders_menu/bookmark_folders_menu_content.js +++ b/src/components/bookmark_folders_menu/bookmark_folders_menu_content.js @@ -1,20 +1,20 @@ import { mapState } from 'pinia' -import NavigationEntry from 'src/components/navigation/navigation_entry.vue' + 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 = { - props: [ - 'showPin' - ], + props: ['showPin'], components: { - NavigationEntry + NavigationEntry, }, computed: { ...mapState(useBookmarkFoldersStore, { - folders: getBookmarkFolderEntries - }) - } + folders: getBookmarkFolderEntries, + }), + }, } export default BookmarkFoldersMenuContent diff --git a/src/components/bookmark_timeline/bookmark_timeline.js b/src/components/bookmark_timeline/bookmark_timeline.js index 9571d630f..a241b6ac7 100644 --- a/src/components/bookmark_timeline/bookmark_timeline.js +++ b/src/components/bookmark_timeline/bookmark_timeline.js @@ -1,32 +1,38 @@ import Timeline from '../timeline/timeline.vue' const Bookmarks = { - created () { + created() { 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: { - Timeline + Timeline, }, computed: { - folderId () { + folderId() { return this.$route.params.id }, - timeline () { + timeline() { return this.$store.state.statuses.timelines.bookmarks - } + }, }, watch: { - folderId () { + folderId() { this.$store.commit('clearTimeline', { timeline: '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.dispatch('stopFetchingTimeline', 'bookmarks') - } + }, } export default Bookmarks diff --git a/src/components/border.style.js b/src/components/border.style.js index 7f2c30163..e7cc31c57 100644 --- a/src/components/border.style.js +++ b/src/components/border.style.js @@ -6,8 +6,8 @@ export default { { directives: { textColor: '$mod(--parent 10)', - textAuto: 'no-auto' - } - } - ] + textAuto: 'no-auto', + }, + }, + ], } diff --git a/src/components/bubble_timeline/bubble_timeline.js b/src/components/bubble_timeline/bubble_timeline.js index 6f73dd2b8..d3835e0e8 100644 --- a/src/components/bubble_timeline/bubble_timeline.js +++ b/src/components/bubble_timeline/bubble_timeline.js @@ -1,18 +1,20 @@ import Timeline from '../timeline/timeline.vue' + const BubbleTimeline = { components: { - Timeline + Timeline, }, computed: { - timeline () { return this.$store.state.statuses.timelines.bubble } + timeline() { + return this.$store.state.statuses.timelines.bubble + }, }, - created () { + created() { this.$store.dispatch('startFetchingTimeline', { timeline: 'bubble' }) }, - unmounted () { + unmounted() { this.$store.dispatch('stopFetchingTimeline', 'bubble') - } - + }, } export default BubbleTimeline diff --git a/src/components/button.style.js b/src/components/button.style.js index 887ff91b5..1644accc4 100644 --- a/src/components/button.style.js +++ b/src/components/button.style.js @@ -10,26 +10,24 @@ export default { // normal: '' // normal state is implicitly added, it is always included toggled: '.toggled', focused: ':focus-within', - pressed: ':focus:active', + pressed: ':active', 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: { // 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 - danger: '.danger' + danger: '.-danger', + 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. // This (currently) is further multipled by number of places where component can exist. }, 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). - validInnerComponents: [ - 'Text', - 'Icon' - ], + validInnerComponents: ['Text', 'Icon'], // Default rules, used as "default theme", essentially. defaultRules: [ { @@ -38,9 +36,11 @@ export default { '--buttonDefaultHoverGlow': 'shadow | 0 0 1 2 --text / 0.4', '--buttonDefaultFocusGlow': 'shadow | 0 0 1 2 --link / 0.5', '--buttonDefaultShadow': 'shadow | 0 0 2 #000000', - '--buttonDefaultBevel': '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)' - } + '--buttonDefaultBevel': + '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 @@ -48,89 +48,128 @@ export default { directives: { background: '--fg', shadow: ['--buttonDefaultShadow', '--buttonDefaultBevel'], - roundness: 3 - } + roundness: 3, + }, }, { - state: ['hover'], + variant: 'danger', directives: { - shadow: ['--buttonDefaultHoverGlow', '--buttonDefaultBevel'] - } + background: '$blend(--cRed 0.25 --inheritedBackground)', + }, }, { - state: ['focused'], + variant: 'transparent', directives: { - shadow: ['--buttonDefaultFocusGlow', '--buttonDefaultBevel'] - } - }, - { - state: ['pressed'], - directives: { - shadow: ['--buttonDefaultShadow', '--buttonPressedBevel'] - } - }, - { - state: ['pressed', 'hover'], - directives: { - shadow: ['--buttonPressedBevel', '--buttonDefaultHoverGlow'] - } - }, - { - state: ['toggled'], - directives: { - background: '--accent,-24.2', - shadow: ['--buttonDefaultShadow', '--buttonPressedBevel'] - } - }, - { - state: ['toggled', 'hover'], - directives: { - background: '--accent,-24.2', - shadow: ['--buttonDefaultHoverGlow', '--buttonPressedBevel'] - } - }, - { - state: ['toggled', 'focused'], - directives: { - background: '--accent,-24.2', - shadow: ['--buttonDefaultHoverGlow', '--buttonPressedBevel'] - } - }, - { - state: ['toggled', 'disabled'], - directives: { - background: '$blend(--accent 0.25 --parent)', - shadow: ['--buttonPressedBevel'] - } - }, - { - state: ['disabled'], - directives: { - background: '$blend(--inheritedBackground 0.25 --parent)', - shadow: ['--buttonDefaultBevel'] - } + opacity: 0.5, + }, }, { component: 'Text', parent: { component: 'Button', - state: ['disabled'] + variant: 'transparent', }, directives: { - textOpacity: 0.25, - textOpacityMode: 'blend' - } + textColor: '--text', + }, }, { component: 'Icon', parent: { component: 'Button', - state: ['disabled'] + variant: 'transparent', + }, + directives: { + textColor: '--text', + }, + }, + { + state: ['hover'], + directives: { + shadow: ['--buttonDefaultHoverGlow', '--buttonDefaultBevel'], + }, + }, + { + state: ['focused'], + directives: { + shadow: ['--buttonDefaultFocusGlow', '--buttonDefaultBevel'], + }, + }, + { + state: ['pressed'], + directives: { + shadow: ['--buttonDefaultShadow', '--buttonPressedBevel'], + }, + }, + { + state: ['pressed', 'hover'], + directives: { + shadow: ['--buttonPressedBevel', '--buttonDefaultHoverGlow'], + }, + }, + { + state: ['toggled'], + directives: { + background: '--accent,-24.2', + shadow: ['--buttonDefaultShadow', '--buttonPressedBevel'], + }, + }, + { + state: ['toggled', 'hover'], + directives: { + background: '--accent,-24.2', + shadow: ['--buttonDefaultHoverGlow', '--buttonPressedBevel'], + }, + }, + { + state: ['toggled', 'focused'], + directives: { + background: '--accent,-24.2', + shadow: ['--buttonDefaultHoverGlow', '--buttonPressedBevel'], + }, + }, + { + state: ['toggled', 'hover', 'focused'], + directives: { + background: '--accent,-24.2', + shadow: ['--buttonDefaultHoverGlow', '--buttonPressedBevel'], + }, + }, + { + state: ['toggled', 'disabled'], + directives: { + background: '$blend(--accent 0.25 --parent)', + shadow: ['--buttonPressedBevel'], + }, + }, + { + state: ['disabled'], + directives: { + background: '$blend(--inheritedBackground 0.25 --parent)', + shadow: ['--buttonDefaultBevel'], + }, + }, + { + component: 'Text', + parent: { + component: 'Button', + state: ['disabled'], }, directives: { textOpacity: 0.25, - textOpacityMode: 'blend' - } - } - ] + textOpacityMode: 'blend', + }, + }, + { + component: 'Icon', + parent: { + component: 'Button', + state: ['disabled'], + }, + directives: { + textOpacity: 0.25, + textOpacityMode: 'blend', + }, + }, + ], } diff --git a/src/components/button_unstyled.style.js b/src/components/button_unstyled.style.js index 9e1a2ca90..c35fb8f69 100644 --- a/src/components/button_unstyled.style.js +++ b/src/components/button_unstyled.style.js @@ -7,91 +7,86 @@ export default { toggled: '.toggled', disabled: ':disabled', hover: ':is(:hover, :focus-visible):not(:disabled)', - focused: ':focus-within:not(:is(:focus-visible))' + focused: ':focus-within:not(:is(:focus-visible))', }, - validInnerComponents: [ - 'Text', - 'Link', - 'Icon', - 'Badge' - ], + validInnerComponents: ['Text', 'Link', 'Icon', 'Badge'], defaultRules: [ { directives: { - shadow: [] - } + shadow: [], + }, }, { component: 'Icon', parent: { component: 'ButtonUnstyled', - state: ['hover'] + state: ['hover'], }, directives: { - textColor: '--parent--text' - } + textColor: '--parent--text', + }, }, { component: 'Icon', parent: { component: 'ButtonUnstyled', - state: ['toggled'] + state: ['toggled'], }, directives: { - textColor: '--parent--text' - } + textColor: '--parent--text', + }, }, { component: 'Icon', parent: { component: 'ButtonUnstyled', - state: ['toggled', 'hover'] + state: ['toggled', 'hover'], }, directives: { - textColor: '--parent--text' - } + textColor: '--parent--text', + }, }, { component: 'Icon', parent: { component: 'ButtonUnstyled', - state: ['toggled', 'focused'] + state: ['toggled', 'focused'], }, directives: { - textColor: '--parent--text' - } + textColor: '--parent--text', + }, }, { component: 'Icon', parent: { component: 'ButtonUnstyled', - state: ['toggled', 'focused', 'hover'] + state: ['toggled', 'focused', 'hover'], }, directives: { - textColor: '--parent--text' - } + textColor: '--parent--text', + }, }, { component: 'Text', parent: { component: 'ButtonUnstyled', - state: ['disabled'] + state: ['disabled'], }, directives: { textOpacity: 0.25, - textOpacityMode: 'blend' - } + textOpacityMode: 'blend', + }, }, { component: 'Icon', parent: { component: 'ButtonUnstyled', - state: ['disabled'] + state: ['disabled'], }, directives: { textOpacity: 0.25, - textOpacityMode: 'blend' - } - } - ] + textOpacityMode: 'blend', + }, + }, + ], } diff --git a/src/components/chat/chat.js b/src/components/chat/chat.js index 56f389e60..caeb2aea7 100644 --- a/src/components/chat/chat.js +++ b/src/components/chat/chat.js @@ -1,25 +1,27 @@ import _ from 'lodash' -import { WSConnectionStatus } from '../../services/api/api.service.js' -import { mapGetters, mapState } from 'vuex' import { mapState as mapPiniaState } from 'pinia' -import ChatMessage from '../chat_message/chat_message.vue' -import PostStatusForm from '../post_status_form/post_status_form.vue' -import ChatTitle from '../chat_title/chat_title.vue' +import { mapGetters, mapState } from 'vuex' + +import { WSConnectionStatus } from '../../services/api/api.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 { promiseInterval } from '../../services/promise_interval/promise_interval.js' +import ChatMessage from '../chat_message/chat_message.vue' +import ChatTitle from '../chat_title/chat_title.vue' +import PostStatusForm from '../post_status_form/post_status_form.vue' +import { + getNewTopPosition, + getScrollPosition, + isBottomedOut, + isScrollable, +} from './chat_layout_utils.js' + import { useInterfaceStore } from 'src/stores/interface.js' -library.add( - faChevronDown, - faChevronLeft -) +import { library } from '@fortawesome/fontawesome-svg-core' +import { faChevronDown, faChevronLeft } from '@fortawesome/free-solid-svg-icons' + +library.add(faChevronDown, faChevronLeft) const BOTTOMED_OUT_OFFSET = 10 const JUMP_TO_BOTTOM_BUTTON_VISIBILITY_OFFSET = 10 @@ -31,78 +33,95 @@ const Chat = { components: { ChatMessage, ChatTitle, - PostStatusForm + PostStatusForm, }, - data () { + data() { return { jumpToBottomButtonVisible: false, hoveredMessageChainId: undefined, lastScrollPosition: {}, scrollableContainerHeight: '100%', errorLoadingChat: false, - messageRetriers: {} + messageRetriers: {}, } }, - created () { + created() { this.startFetching() window.addEventListener('resize', this.handleResize) }, - mounted () { + mounted() { window.addEventListener('scroll', this.handleScroll) if (typeof document.hidden !== 'undefined') { - document.addEventListener('visibilitychange', this.handleVisibilityChange, false) + document.addEventListener( + 'visibilitychange', + this.handleVisibilityChange, + false, + ) } this.$nextTick(() => { this.handleResize() }) }, - unmounted () { + unmounted() { window.removeEventListener('scroll', this.handleScroll) 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') }, computed: { - recipient () { + recipient() { return this.currentChat && this.currentChat.account }, - recipientId () { + recipientId() { return this.$route.params.recipient_id }, - formPlaceholder () { + formPlaceholder() { 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 { return '' } }, - chatViewItems () { + chatViewItems() { return chatService.getView(this.currentChatMessageService) }, - newMessageCount () { - return this.currentChatMessageService && this.currentChatMessageService.newMessageCount + newMessageCount() { + return ( + this.currentChatMessageService && + this.currentChatMessageService.newMessageCount + ) }, - streamingEnabled () { - return this.mergedConfig.useStreamingApi && this.mastoUserSocketStatus === WSConnectionStatus.JOINED + streamingEnabled() { + return ( + this.mergedConfig.useStreamingApi && + this.mastoUserSocketStatus === WSConnectionStatus.JOINED + ) }, ...mapGetters([ 'currentChat', 'currentChatMessageService', 'findOpenedChatByRecipientId', - 'mergedConfig' + 'mergedConfig', ]), ...mapPiniaState(useInterfaceStore, { - mobileLayout: store => store.layoutType === 'mobile' + mobileLayout: (store) => store.layoutType === 'mobile', }), ...mapState({ - backendInteractor: state => state.api.backendInteractor, - mastoUserSocketStatus: state => state.api.mastoUserSocketStatus, - currentUser: state => state.users.currentUser - }) + backendInteractor: (state) => state.api.backendInteractor, + mastoUserSocketStatus: (state) => state.api.mastoUserSocketStatus, + currentUser: (state) => state.users.currentUser, + }), }, watch: { - chatViewItems () { + chatViewItems() { // We don't want to scroll to the bottom on a new message when the user is viewing older messages. // Therefore we need to know whether the scroll position was at the bottom before the DOM update. const bottomedOutBeforeUpdate = this.bottomedOut(BOTTOMED_OUT_OFFSET) @@ -115,23 +134,23 @@ const Chat = { $route: function () { this.startFetching() }, - mastoUserSocketStatus (newValue) { + mastoUserSocketStatus(newValue) { if (newValue === WSConnectionStatus.JOINED) { this.fetchChat({ isFirstFetch: true }) } - } + }, }, methods: { // 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 }, - onFilesDropped () { + onFilesDropped() { this.$nextTick(() => { this.handleResize() }) }, - handleVisibilityChange () { + handleVisibilityChange() { this.$nextTick(() => { if (!document.hidden && this.bottomedOut(BOTTOMED_OUT_OFFSET)) { this.scrollDown({ forceRead: true }) @@ -139,7 +158,7 @@ const Chat = { }) }, // "Sticks" scroll to bottom instead of top, helps with OSK resizing the viewport - handleResize (opts = {}) { + handleResize(opts = {}) { const { delayed = false } = opts if (delayed) { @@ -160,40 +179,56 @@ const Chat = { this.lastScrollPosition = getScrollPosition() }) }, - scrollDown (options = {}) { + scrollDown(options = {}) { const { behavior = 'auto', forceRead = false } = options this.$nextTick(() => { - window.scrollTo({ top: document.documentElement.scrollHeight, behavior }) + window.scrollTo({ + top: document.documentElement.scrollHeight, + behavior, + }) }) if (forceRead) { this.readChat() } }, - readChat () { - if (!(this.currentChatMessageService && this.currentChatMessageService.maxId)) { return } - if (document.hidden) { return } + readChat() { + if ( + !( + this.currentChatMessageService && this.currentChatMessageService.maxId + ) + ) { + return + } + if (document.hidden) { + return + } const lastReadId = this.currentChatMessageService.maxId this.$store.dispatch('readChat', { id: this.currentChat.id, - lastReadId + lastReadId, }) }, - bottomedOut (offset) { + bottomedOut(offset) { return isBottomedOut(offset) }, - reachedTop () { + reachedTop() { return window.scrollY <= 0 }, - cullOlderCheck () { + cullOlderCheck() { window.setTimeout(() => { if (this.bottomedOut(JUMP_TO_BOTTOM_BUTTON_VISIBILITY_OFFSET)) { - this.$store.dispatch('cullOlderMessages', this.currentChatMessageService.chatId) + this.$store.dispatch( + 'cullOlderMessages', + this.currentChatMessageService.chatId, + ) } }, 5000) }, handleScroll: _.throttle(function () { this.lastScrollPosition = getScrollPosition() - if (!this.currentChat) { return } + if (!this.currentChat) { + return + } if (this.reachedTop()) { this.fetchChat({ maxId: this.currentChatMessageService.minId }) @@ -213,22 +248,27 @@ const Chat = { this.jumpToBottomButtonVisible = true } }, 200), - handleScrollUp (positionBeforeLoading) { + handleScrollUp(positionBeforeLoading) { const positionAfterLoading = getScrollPosition() 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 - if (!chatMessageService) { return } - if (fetchLatest && this.streamingEnabled) { return } + if (!chatMessageService) { + return + } + if (fetchLatest && this.streamingEnabled) { + return + } const chatId = chatMessageService.chatId const fetchOlderMessages = !!maxId const sinceId = fetchLatest && chatMessageService.maxId - return this.backendInteractor.chatMessages({ id: chatId, maxId, sinceId }) + return this.backendInteractor + .chatMessages({ id: chatId, maxId, sinceId }) .then((messages) => { // Clear the current chat in case we're recovering from a ws connection loss. if (isFirstFetch) { @@ -236,28 +276,34 @@ const Chat = { } const positionBeforeUpdate = getScrollPosition() - this.$store.dispatch('addChatMessages', { chatId, messages }).then(() => { - this.$nextTick(() => { - if (fetchOlderMessages) { - this.handleScrollUp(positionBeforeUpdate) - } + this.$store + .dispatch('addChatMessages', { chatId, messages }) + .then(() => { + this.$nextTick(() => { + if (fetchOlderMessages) { + this.handleScrollUp(positionBeforeUpdate) + } - // In vertical screens, the first batch of fetched messages may not always take the - // full height of the scrollable container. - // If this is the case, we want to fetch the messages until the scrollable container - // is fully populated so that the user has the ability to scroll up and load the history. - if (!isScrollable() && messages.length > 0) { - this.fetchChat({ maxId: this.currentChatMessageService.minId }) - } + // In vertical screens, the first batch of fetched messages may not always take the + // full height of the scrollable container. + // If this is the case, we want to fetch the messages until the scrollable container + // is fully populated so that the user has the ability to scroll up and load the history. + if (!isScrollable() && messages.length > 0) { + this.fetchChat({ + maxId: this.currentChatMessageService.minId, + }) + } + }) }) - }) }) }, - async startFetching () { + async startFetching() { let chat = this.findOpenedChatByRecipientId(this.recipientId) if (!chat) { try { - chat = await this.backendInteractor.getOrCreateChat({ accountId: this.recipientId }) + chat = await this.backendInteractor.getOrCreateChat({ + accountId: this.recipientId, + }) } catch (e) { console.error('Error creating or getting a chat', e) this.errorLoadingChat = true @@ -271,13 +317,14 @@ const Chat = { this.doStartFetching() } }, - doStartFetching () { + doStartFetching() { this.$store.dispatch('startFetchingCurrentChat', { - fetcher: () => promiseInterval(() => this.fetchChat({ fetchLatest: true }), 5000) + fetcher: () => + promiseInterval(() => this.fetchChat({ fetchLatest: true }), 5000), }) this.fetchChat({ isFirstFetch: true }) }, - handleAttachmentPosting () { + handleAttachmentPosting() { this.$nextTick(() => { this.handleResize() // 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 }) }) }, - sendMessage ({ status, media, idempotencyKey }) { + sendMessage({ status, media, idempotencyKey }) { const params = { id: this.currentChat.id, content: status, - idempotencyKey + idempotencyKey, } if (media[0]) { @@ -301,52 +348,72 @@ const Chat = { chatId: this.currentChat.id, content: status, userId: this.currentUser.id, - idempotencyKey + idempotencyKey, }) - this.$store.dispatch('addChatMessages', { - chatId: this.currentChat.id, - messages: [fakeMessage] - }).then(() => { - this.handleAttachmentPosting() - }) + this.$store + .dispatch('addChatMessages', { + chatId: this.currentChat.id, + messages: [fakeMessage], + }) + .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 - this.backendInteractor.sendChatMessage(params) - .then(data => { + this.backendInteractor + .sendChatMessage(params) + .then((data) => { this.$store.dispatch('addChatMessages', { chatId: this.currentChat.id, updateMaxId: false, - messages: [{ ...data, fakeId: fakeMessage.id }] + messages: [{ ...data, fakeId: fakeMessage.id }], }) return data }) - .catch(error => { + .catch((error) => { console.error('Error sending message', error) this.$store.dispatch('handleMessageError', { chatId: this.currentChat.id, fakeId: fakeMessage.id, - isRetry: retriesLeft !== MAX_RETRIES + isRetry: retriesLeft !== MAX_RETRIES, }) - if ((error.statusCode >= 500 && error.statusCode < 600) || error.message === 'Failed to fetch') { - this.messageRetriers[fakeMessage.id] = setTimeout(() => { - this.doSendMessage({ params, fakeMessage, retriesLeft: retriesLeft - 1 }) - }, 1000 * (2 ** (MAX_RETRIES - retriesLeft))) + if ( + (error.statusCode >= 500 && error.statusCode < 600) || + error.message === 'Failed to fetch' + ) { + this.messageRetriers[fakeMessage.id] = setTimeout( + () => { + this.doSendMessage({ + params, + fakeMessage, + retriesLeft: retriesLeft - 1, + }) + }, + 1000 * 2 ** (MAX_RETRIES - retriesLeft), + ) } return {} }) return Promise.resolve(fakeMessage) }, - goBack () { - this.$router.push({ name: 'chats', params: { username: this.currentUser.screen_name } }) - } - } + goBack() { + this.$router.push({ + name: 'chats', + params: { username: this.currentUser.screen_name }, + }) + }, + }, } export default Chat diff --git a/src/components/chat/chat.style.js b/src/components/chat/chat.style.js index 9ae2b7d71..55cf657c2 100644 --- a/src/components/chat/chat.style.js +++ b/src/components/chat/chat.style.js @@ -1,19 +1,13 @@ export default { name: 'Chat', selector: '.chat-message-list', - validInnerComponents: [ - 'Text', - 'Link', - 'Icon', - 'Avatar', - 'ChatMessage' - ], + validInnerComponents: ['Text', 'Link', 'Icon', 'Avatar', 'ChatMessage'], defaultRules: [ { directives: { background: '--bg', - blur: '5px' - } - } - ] + blur: '5px', + }, + }, + ], } diff --git a/src/components/chat/chat.vue b/src/components/chat/chat.vue index dfd197e56..cedbdce69 100644 --- a/src/components/chat/chat.vue +++ b/src/components/chat/chat.vue @@ -73,6 +73,7 @@ :disable-notice="true" :disable-lock-warning="true" :disable-polls="true" + :disable-quotes="true" :disable-sensitivity-checkbox="true" :disable-submit="errorLoadingChat || !currentChat" :disable-preview="true" diff --git a/src/components/chat/chat_layout_utils.js b/src/components/chat/chat_layout_utils.js index c187892d9..10d0a5e45 100644 --- a/src/components/chat/chat_layout_utils.js +++ b/src/components/chat/chat_layout_utils.js @@ -3,14 +3,17 @@ export const getScrollPosition = () => { return { scrollTop: window.scrollY, 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 // Takes two scroll positions, before and after the update. export const getNewTopPosition = (previousPosition, newPosition) => { - return previousPosition.scrollTop + (newPosition.scrollHeight - previousPosition.scrollHeight) + return ( + previousPosition.scrollTop + + (newPosition.scrollHeight - previousPosition.scrollHeight) + ) } export const isBottomedOut = (offset = 0) => { diff --git a/src/components/chat_list/chat_list.js b/src/components/chat_list/chat_list.js index 95708d1dd..4c3194ae1 100644 --- a/src/components/chat_list/chat_list.js +++ b/src/components/chat_list/chat_list.js @@ -1,4 +1,5 @@ -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 List from '../list/list.vue' @@ -7,31 +8,31 @@ const ChatList = { components: { ChatListItem, List, - ChatNew + ChatNew, }, computed: { ...mapState({ - currentUser: state => state.users.currentUser + currentUser: (state) => state.users.currentUser, }), - ...mapGetters(['sortedChatList']) + ...mapGetters(['sortedChatList']), }, - data () { + data() { return { - isNew: false + isNew: false, } }, - created () { + created() { this.$store.dispatch('fetchChats', { latest: true }) }, methods: { - cancelNewChat () { + cancelNewChat() { this.isNew = false this.$store.dispatch('fetchChats', { latest: true }) }, - newChat () { + newChat() { this.isNew = true - } - } + }, + }, } export default ChatList diff --git a/src/components/chat_list_item/chat_list_item.js b/src/components/chat_list_item/chat_list_item.js index 8f8c491f5..74df26c1f 100644 --- a/src/components/chat_list_item/chat_list_item.js +++ b/src/components/chat_list_item/chat_list_item.js @@ -1,31 +1,31 @@ import { mapState } from 'vuex' -import StatusBody from '../status_content/status_content.vue' -import fileType from 'src/services/file_type/file_type.service' -import UserAvatar from '../user_avatar/user_avatar.vue' + import AvatarList from '../avatar_list/avatar_list.vue' -import Timeago from '../timeago/timeago.vue' import ChatTitle from '../chat_title/chat_title.vue' +import StatusBody from '../status_content/status_content.vue' +import Timeago from '../timeago/timeago.vue' +import UserAvatar from '../user_avatar/user_avatar.vue' const ChatListItem = { name: 'ChatListItem', - props: [ - 'chat' - ], + props: ['chat'], components: { UserAvatar, AvatarList, Timeago, ChatTitle, - StatusBody + StatusBody, }, computed: { ...mapState({ - currentUser: state => state.users.currentUser + currentUser: (state) => state.users.currentUser, }), - attachmentInfo () { - if (this.chat.lastMessage.attachments.length === 0) { return } + attachmentInfo() { + 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')) { return this.$t('file_type.video') } else if (types.includes('audio')) { @@ -36,34 +36,36 @@ const ChatListItem = { return this.$t('file_type.file') } }, - messageForStatusContent () { + messageForStatusContent() { const message = this.chat.lastMessage const messageEmojis = message ? message.emojis : [] const isYou = message && message.account_id === this.currentUser.id - const content = message ? (this.attachmentInfo || message.content) : '' - const messagePreview = isYou ? `${this.$t('chats.you')} ${content}` : content + const content = message ? this.attachmentInfo || message.content : '' + const messagePreview = isYou + ? `${this.$t('chats.you')} ${content}` + : content return { summary: '', emojis: messageEmojis, raw_html: messagePreview, text: messagePreview, - attachments: [] + attachments: [], } - } + }, }, methods: { - openChat () { + openChat() { if (this.chat.id) { this.$router.push({ name: 'chat', params: { username: this.currentUser.screen_name, - recipient_id: this.chat.account.id - } + recipient_id: this.chat.account.id, + }, }) } - } - } + }, + }, } export default ChatListItem diff --git a/src/components/chat_message/chat_message.js b/src/components/chat_message/chat_message.js index 837f6d214..1675f9ddd 100644 --- a/src/components/chat_message/chat_message.js +++ b/src/components/chat_message/chat_message.js @@ -1,24 +1,23 @@ -import { mapState, mapGetters } from 'vuex' import { mapState as mapPiniaState } from 'pinia' -import Popover from '../popover/popover.vue' +import { defineAsyncComponent } from 'vue' +import { mapGetters, mapState } from 'vuex' + import Attachment from '../attachment/attachment.vue' -import UserAvatar from '../user_avatar/user_avatar.vue' +import ChatMessageDate from '../chat_message_date/chat_message_date.vue' import Gallery from '../gallery/gallery.vue' import LinkPreview from '../link-preview/link-preview.vue' +import Popover from '../popover/popover.vue' import StatusContent from '../status_content/status_content.vue' -import ChatMessageDate from '../chat_message_date/chat_message_date.vue' -import { defineAsyncComponent } from 'vue' -import { library } from '@fortawesome/fontawesome-svg-core' -import { - faTimes, - faEllipsisH -} from '@fortawesome/free-solid-svg-icons' -import { useInterfaceStore } from 'src/stores/interface' +import UserAvatar from '../user_avatar/user_avatar.vue' -library.add( - faTimes, - faEllipsisH -) +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 = { name: 'ChatMessage', @@ -27,7 +26,7 @@ const ChatMessage = { 'edited', 'noHeading', 'chatViewItem', - 'hoveredMessageChain' + 'hoveredMessageChain', ], emits: ['hover'], components: { @@ -38,73 +37,82 @@ const ChatMessage = { Gallery, LinkPreview, ChatMessageDate, - UserPopover: defineAsyncComponent(() => import('../user_popover/user_popover.vue')) + UserPopover: defineAsyncComponent( + () => import('../user_popover/user_popover.vue'), + ), }, computed: { // Returns HH:MM (hours and minutes) in local time. - createdAt () { + createdAt() { 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 }, - message () { + message() { return this.chatViewItem.data }, - isMessage () { + isMessage() { return this.chatViewItem.type === 'message' }, - messageForStatusContent () { + messageForStatusContent() { return { summary: '', emojis: this.message.emojis, raw_html: this.message.content || '', text: this.message.content || '', - attachments: this.message.attachments + attachments: this.message.attachments, } }, - hasAttachment () { + hasAttachment() { return this.message.attachments.length > 0 }, ...mapPiniaState(useInterfaceStore, { - betterShadow: store => store.browserSupport.cssFilter + betterShadow: (store) => store.browserSupport.cssFilter, }), ...mapState({ - currentUser: state => state.users.currentUser, - restrictedNicknames: state => state.instance.restrictedNicknames + currentUser: (state) => state.users.currentUser, + restrictedNicknames: (state) => useInstanceStore().restrictedNicknames, }), - popoverMarginStyle () { + popoverMarginStyle() { if (this.isCurrentUser) { return {} } else { return { left: 50 } } }, - ...mapGetters(['mergedConfig', 'findUser']) + ...mapPiniaState(useMergedConfigStore, ['mergedConfig', 'findUser']), }, - data () { + data() { return { hovered: false, - menuOpened: false + menuOpened: false, } }, methods: { - onHover (bool) { - this.$emit('hover', { isHovered: bool, messageChainId: this.chatViewItem.messageChainId }) + onHover(bool) { + this.$emit('hover', { + isHovered: bool, + messageChainId: this.chatViewItem.messageChainId, + }) }, - async deleteMessage () { + async deleteMessage() { const confirmed = window.confirm(this.$t('chats.delete_confirm')) if (confirmed) { await this.$store.dispatch('deleteChatMessage', { messageId: this.chatViewItem.data.id, - chatId: this.chatViewItem.data.chat_id + chatId: this.chatViewItem.data.chat_id, }) } this.hovered = false this.menuOpened = false - } - } + }, + }, } export default ChatMessage diff --git a/src/components/chat_message/chat_message.style.js b/src/components/chat_message/chat_message.style.js index 9b57ad371..f7632bc6f 100644 --- a/src/components/chat_message/chat_message.style.js +++ b/src/components/chat_message/chat_message.style.js @@ -2,29 +2,21 @@ export default { name: 'ChatMessage', selector: '.chat-message', variants: { - outgoing: '.outgoing' + outgoing: '.outgoing', }, - validInnerComponents: [ - 'Text', - 'Icon', - 'Border', - 'Button', - 'RichContent', - 'Attachment', - 'PollGraph' - ], + validInnerComponents: ['Text', 'Icon', 'Border', 'PollGraph'], defaultRules: [ { directives: { background: '--bg, 2', - backgroundNoCssColor: 'yes' - } + backgroundNoCssColor: 'yes', + }, }, { variant: 'outgoing', directives: { - background: '--bg, 5' - } - } - ] + background: '--bg, 5', + }, + }, + ], } diff --git a/src/components/chat_message_date/chat_message_date.vue b/src/components/chat_message_date/chat_message_date.vue index 98349b753..f0cadb6e7 100644 --- a/src/components/chat_message_date/chat_message_date.vue +++ b/src/components/chat_message_date/chat_message_date.vue @@ -11,16 +11,19 @@ export default { name: 'Timeago', props: ['date'], computed: { - displayDate () { + displayDate() { const today = new Date() today.setHours(0, 0, 0, 0) if (this.date.getTime() === today.getTime()) { return this.$t('display_date.today') } else { - return this.date.toLocaleDateString(localeService.internalToBrowserLocale(this.$i18n.locale), { day: 'numeric', month: 'long' }) + return this.date.toLocaleDateString( + localeService.internalToBrowserLocale(this.$i18n.locale), + { day: 'numeric', month: 'long' }, + ) } - } - } + }, + }, } diff --git a/src/components/chat_new/chat_new.js b/src/components/chat_new/chat_new.js index 71585995a..50e0f7a8f 100644 --- a/src/components/chat_new/chat_new.js +++ b/src/components/chat_new/chat_new.js @@ -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( - faSearch, - faChevronLeft -) +import { library } from '@fortawesome/fontawesome-svg-core' +import { faChevronLeft, faSearch } from '@fortawesome/free-solid-svg-icons' + +library.add(faSearch, faChevronLeft) const chatNew = { components: { BasicUserCard, - UserAvatar + UserAvatar, }, - data () { + data() { return { suggestions: [], userIds: [], loading: false, - query: '' + query: '', } }, - async created () { + async created() { const { chats } = await this.backendInteractor.chats() - chats.forEach(chat => this.suggestions.push(chat.account)) + chats.forEach((chat) => this.suggestions.push(chat.account)) }, computed: { - users () { - return this.userIds.map(userId => this.findUser(userId)) + users() { + return this.userIds.map((userId) => this.findUser(userId)) }, - availableUsers () { + availableUsers() { if (this.query.length !== 0) { return this.users } else { @@ -41,29 +37,29 @@ const chatNew = { } }, ...mapState({ - currentUser: state => state.users.currentUser, - backendInteractor: state => state.api.backendInteractor + currentUser: (state) => state.users.currentUser, + backendInteractor: (state) => state.api.backendInteractor, }), - ...mapGetters(['findUser']) + ...mapGetters(['findUser']), }, methods: { - goBack () { + goBack() { this.$emit('cancel') }, - goToChat (user) { + goToChat(user) { this.$router.push({ name: 'chat', params: { recipient_id: user.id } }) }, - onInput () { + onInput() { this.search(this.query) }, - addUser (user) { + addUser(user) { this.selectedUserIds.push(user.id) this.query = '' }, - removeUser (userId) { - this.selectedUserIds = this.selectedUserIds.filter(id => id !== userId) + removeUser(userId) { + this.selectedUserIds = this.selectedUserIds.filter((id) => id !== userId) }, - search (query) { + search(query) { if (!query) { this.loading = false return @@ -71,13 +67,14 @@ const chatNew = { this.loading = true this.userIds = [] - this.$store.dispatch('search', { q: query, resolve: true, type: 'accounts' }) - .then(data => { + this.$store + .dispatch('search', { q: query, resolve: true, type: 'accounts' }) + .then((data) => { this.loading = false - this.userIds = data.accounts.map(a => a.id) + this.userIds = data.accounts.map((a) => a.id) }) - } - } + }, + }, } export default chatNew diff --git a/src/components/chat_title/chat_title.js b/src/components/chat_title/chat_title.js index b87211265..7ec4c81f1 100644 --- a/src/components/chat_title/chat_title.js +++ b/src/components/chat_title/chat_title.js @@ -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 RichContent from 'src/components/rich_content/rich_content.jsx' +import UserAvatar from '../user_avatar/user_avatar.vue' + export default { name: 'ChatTitle', components: { UserAvatar, RichContent, - UserPopover: defineAsyncComponent(() => import('../user_popover/user_popover.vue')) + UserPopover: defineAsyncComponent( + () => import('../user_popover/user_popover.vue'), + ), }, - props: [ - 'user', 'withAvatar' - ], + props: ['user', 'withAvatar'], computed: { - title () { + title() { return this.user ? this.user.screen_name_ui : '' }, - htmlTitle () { + htmlTitle() { return this.user ? this.user.name_html : '' - } - } + }, + allowNonSquareEmoji() { + return useMergedConfigStore().mergedConfig.nonSquareEmoji + }, + }, } diff --git a/src/components/chat_title/chat_title.vue b/src/components/chat_title/chat_title.vue index 72660cca0..313e66ce3 100644 --- a/src/components/chat_title/chat_title.vue +++ b/src/components/chat_title/chat_title.vue @@ -19,6 +19,8 @@ :title="'@'+(user && user.screen_name_ui)" :html="htmlTitle" :emoji="user.emoji || []" + :allow-non-square-emoji="allowNonSquareEmoji" + :is-local="user.is_local" />
diff --git a/src/components/checkbox/checkbox.vue b/src/components/checkbox/checkbox.vue index c8bba4c44..4a3ea49bc 100644 --- a/src/components/checkbox/checkbox.vue +++ b/src/components/checkbox/checkbox.vue @@ -1,7 +1,7 @@ diff --git a/src/components/edit_status_form/edit_status_form.js b/src/components/edit_status_form/edit_status_form.js index 323763370..b8df92794 100644 --- a/src/components/edit_status_form/edit_status_form.js +++ b/src/components/edit_status_form/edit_status_form.js @@ -1,21 +1,21 @@ -import PostStatusForm from '../post_status_form/post_status_form.vue' import statusPosterService from '../../services/status_poster/status_poster.service.js' +import PostStatusForm from '../post_status_form/post_status_form.vue' const EditStatusForm = { components: { - PostStatusForm + PostStatusForm, }, props: { params: { type: Object, - required: true - } + required: true, + }, }, methods: { - requestClose () { + requestClose() { this.$refs.postStatusForm.requestClose() }, - doEditStatus ({ status, spoilerText, sensitive, media, contentType, poll }) { + doEditStatus({ status, spoilerText, sensitive, media, contentType, poll }) { const params = { store: this.$store, statusId: this.params.statusId, @@ -24,21 +24,22 @@ const EditStatusForm = { sensitive, poll, media, - contentType + contentType, } - return statusPosterService.editStatus(params) + return statusPosterService + .editStatus(params) .then((data) => { return data }) .catch((err) => { console.error('Error editing status', err) return { - error: err.message + error: err.message, } }) - } - } + }, + }, } export default EditStatusForm diff --git a/src/components/edit_status_form/edit_status_form.vue b/src/components/edit_status_form/edit_status_form.vue index 0a7ec760a..1452be422 100644 --- a/src/components/edit_status_form/edit_status_form.vue +++ b/src/components/edit_status_form/edit_status_form.vue @@ -4,6 +4,7 @@ v-bind="params" :post-handler="doEditStatus" :disable-polls="true" + :disable-quotes="true" :disable-visibility-selector="true" /> diff --git a/src/components/edit_status_modal/edit_status_modal.js b/src/components/edit_status_modal/edit_status_modal.js index 4c10c21a0..07836f161 100644 --- a/src/components/edit_status_modal/edit_status_modal.js +++ b/src/components/edit_status_modal/edit_status_modal.js @@ -1,34 +1,36 @@ +import get from 'lodash/get' + import EditStatusForm from '../edit_status_form/edit_status_form.vue' import Modal from '../modal/modal.vue' -import get from 'lodash/get' -import { useEditStatusStore } from 'src/stores/editStatus' + +import { useEditStatusStore } from 'src/stores/editStatus.js' const EditStatusModal = { components: { EditStatusForm, - Modal + Modal, }, - data () { + data() { return { - resettingForm: false + resettingForm: false, } }, computed: { - isLoggedIn () { + isLoggedIn() { return !!this.$store.state.users.currentUser }, - modalActivated () { + modalActivated() { return useEditStatusStore().modalActivated }, - isFormVisible () { + isFormVisible() { return this.isLoggedIn && !this.resettingForm && this.modalActivated }, - params () { + params() { return useEditStatusStore().params || {} - } + }, }, watch: { - params (newVal, oldVal) { + params(newVal, oldVal) { if (get(newVal, 'statusId') !== get(oldVal, 'statusId')) { this.resettingForm = true this.$nextTick(() => { @@ -36,20 +38,22 @@ const EditStatusModal = { }) } }, - isFormVisible (val) { + isFormVisible(val) { if (val) { - this.$nextTick(() => this.$el && this.$el.querySelector('textarea').focus()) + this.$nextTick( + () => this.$el && this.$el.querySelector('textarea').focus(), + ) } - } + }, }, methods: { - closeModal () { + closeModal() { this.$refs.editStatusForm.requestClose() }, - doCloseModal () { + doCloseModal() { useEditStatusStore().closeEditStatusModal() - } - } + }, + }, } export default EditStatusModal diff --git a/src/components/emoji_input/emoji_input.js b/src/components/emoji_input/emoji_input.js index f3b6dfe9b..0146a3de8 100644 --- a/src/components/emoji_input/emoji_input.js +++ b/src/components/emoji_input/emoji_input.js @@ -1,20 +1,20 @@ -import Completion from '../../services/completion/completion.js' -import genRandomSeed from '../../services/random_seed/random_seed.service.js' -import EmojiPicker from '../emoji_picker/emoji_picker.vue' +import { take } from 'lodash' + import Popover from 'src/components/popover/popover.vue' import ScreenReaderNotice from 'src/components/screen_reader_notice/screen_reader_notice.vue' -import UnicodeDomainIndicator from '../unicode_domain_indicator/unicode_domain_indicator.vue' -import { take } from 'lodash' -import { findOffset } from '../../services/offset_finder/offset_finder.service.js' import { ensureFinalFallback } from '../../i18n/languages.js' -import { library } from '@fortawesome/fontawesome-svg-core' -import { - faSmileBeam -} from '@fortawesome/free-regular-svg-icons' +import Completion from '../../services/completion/completion.js' +import { findOffset } from '../../services/offset_finder/offset_finder.service.js' +import genRandomSeed from '../../services/random_seed/random_seed.service.js' +import EmojiPicker from '../emoji_picker/emoji_picker.vue' +import UnicodeDomainIndicator from '../unicode_domain_indicator/unicode_domain_indicator.vue' -library.add( - faSmileBeam -) +import { useMergedConfigStore } from 'src/stores/merged_config.js' + +import { library } from '@fortawesome/fontawesome-svg-core' +import { faSmileBeam } from '@fortawesome/free-regular-svg-icons' + +library.add(faSmileBeam) /** * EmojiInput - augmented inputs for emoji and autocomplete support in inputs @@ -60,14 +60,14 @@ const EmojiInput = { * For commonly used suggestors (emoji, users, both) use suggestor.js */ required: true, - type: Function + type: Function, }, modelValue: { /** * Used for v-model */ required: true, - type: String + type: String, }, enableEmojiPicker: { /** @@ -75,7 +75,7 @@ const EmojiInput = { */ required: false, type: Boolean, - default: false + default: false, }, hideEmojiButton: { /** @@ -84,7 +84,7 @@ const EmojiInput = { */ required: false, type: Boolean, - default: false + default: false, }, enableStickerPicker: { /** @@ -92,7 +92,7 @@ const EmojiInput = { */ required: false, type: Boolean, - default: false + default: false, }, placement: { /** @@ -101,15 +101,15 @@ const EmojiInput = { */ required: false, type: String, // 'auto', 'top', 'bottom' - default: 'auto' + default: 'auto', }, newlineOnCtrlEnter: { required: false, type: Boolean, - default: false - } + default: false, + }, }, - data () { + data() { return { randomSeed: genRandomSeed(), input: undefined, @@ -122,58 +122,65 @@ const EmojiInput = { disableClickOutside: false, suggestions: [], overlayStyle: {}, - pickerShown: false + pickerShown: false, } }, components: { Popover, EmojiPicker, UnicodeDomainIndicator, - ScreenReaderNotice + ScreenReaderNotice, }, computed: { - padEmoji () { - return this.$store.getters.mergedConfig.padEmoji + padEmoji() { + return useMergedConfigStore().mergedConfig.padEmoji }, - defaultCandidateIndex () { - return this.$store.getters.mergedConfig.autocompleteSelect ? 0 : -1 + defaultCandidateIndex() { + return useMergedConfigStore().mergedConfig.autocompleteSelect ? 0 : -1 }, - preText () { + preText() { return this.modelValue.slice(0, this.caret) }, - postText () { + postText() { return this.modelValue.slice(this.caret) }, - showSuggestions () { - return this.focused && + showSuggestions() { + return ( + this.focused && this.suggestions && this.suggestions.length > 0 && !this.pickerShown && !this.temporarilyHideSuggestions + ) }, - textAtCaret () { + textAtCaret() { return this.wordAtCaret?.word }, - wordAtCaret () { + wordAtCaret() { if (this.modelValue && this.caret) { - const word = Completion.wordAtPosition(this.modelValue, this.caret - 1) || {} + const word = + Completion.wordAtPosition(this.modelValue, this.caret - 1) || {} return word } }, - languages () { - return ensureFinalFallback(this.$store.getters.mergedConfig.interfaceLanguage) + languages() { + return ensureFinalFallback( + useMergedConfigStore().mergedConfig.interfaceLanguage, + ) }, - maybeLocalizedEmojiNamesAndKeywords () { - return emoji => { + maybeLocalizedEmojiNamesAndKeywords() { + return (emoji) => { const names = [emoji.displayText] const keywords = [] if (emoji.displayTextI18n) { - names.push(this.$t(emoji.displayTextI18n.key, emoji.displayTextI18n.args)) + names.push( + this.$t(emoji.displayTextI18n.key, emoji.displayTextI18n.args), + ) } if (emoji.annotations) { - this.languages.forEach(lang => { + this.languages.forEach((lang) => { names.push(emoji.annotations[lang]?.name) keywords.push(...(emoji.annotations[lang]?.keywords || [])) @@ -181,13 +188,13 @@ const EmojiInput = { } return { - names: names.filter(k => k), - keywords: keywords.filter(k => k) + names: names.filter((k) => k), + keywords: keywords.filter((k) => k), } } }, - maybeLocalizedEmojiName () { - return emoji => { + maybeLocalizedEmojiName() { + return (emoji) => { if (!emoji.annotations) { return emoji.displayText } @@ -205,22 +212,18 @@ const EmojiInput = { return emoji.displayText } }, - onInputScroll () { - this.$refs.hiddenOverlay.scrollTo({ - top: this.input.scrollTop, - left: this.input.scrollLeft - }) - }, - suggestionListId () { + suggestionListId() { return `suggestions-${this.randomSeed}` }, - suggestionItemId () { + suggestionItemId() { return (index) => `suggestion-item-${index}-${this.randomSeed}` - } + }, }, - mounted () { + mounted() { const { root, hiddenOverlayCaret, suggestorPopover } = this.$refs - const input = root.querySelector('.emoji-input > input') || root.querySelector('.emoji-input > textarea') + const input = + root.querySelector('.emoji-input > input') || + root.querySelector('.emoji-input > textarea') if (!input) return this.input = input this.caretEl = hiddenOverlayCaret @@ -239,7 +242,6 @@ const EmojiInput = { this.overlayStyle.fontSize = style.fontSize this.overlayStyle.wordWrap = style.wordWrap this.overlayStyle.whiteSpace = style.whiteSpace - this.resize() input.addEventListener('blur', this.onBlur) input.addEventListener('focus', this.onFocus) input.addEventListener('paste', this.onPaste) @@ -250,7 +252,7 @@ const EmojiInput = { input.addEventListener('input', this.onInput) input.addEventListener('scroll', this.onInputScroll) }, - unmounted () { + unmounted() { const { input } = this if (input) { input.removeEventListener('blur', this.onBlur) @@ -280,29 +282,40 @@ const EmojiInput = { this.suggestions = [] return } - const matchedSuggestions = await this.suggest(newWord, this.maybeLocalizedEmojiNamesAndKeywords) + const matchedSuggestions = await this.suggest( + newWord, + this.maybeLocalizedEmojiNamesAndKeywords, + ) // Async: cancel if textAtCaret has changed during wait if (this.textAtCaret !== newWord || matchedSuggestions.length <= 0) { this.suggestions = [] return } - this.suggestions = take(matchedSuggestions, 5) - .map(({ imageUrl, ...rest }) => ({ + this.suggestions = take(matchedSuggestions, 5).map( + ({ imageUrl, ...rest }) => ({ ...rest, - img: imageUrl || '' - })) + img: imageUrl || '', + }), + ) this.highlighted = this.defaultCandidateIndex this.$refs.screenReaderNotice.announce( this.$t( 'tool_tip.autocomplete_available', { number: this.suggestions.length }, - this.suggestions.length - ) + this.suggestions.length, + ), ) - } + }, }, methods: { - triggerShowPicker () { + onInputScroll(e) { + this.$refs.hiddenOverlay.scrollTo({ + top: this.input.scrollTop, + left: this.input.scrollLeft, + }) + this.setCaret(e) + }, + triggerShowPicker() { this.$nextTick(() => { this.$refs.picker.showPicker() this.scrollIntoView() @@ -315,7 +328,7 @@ const EmojiInput = { this.disableClickOutside = false }, 0) }, - togglePicker () { + togglePicker() { this.input.focus() if (!this.pickerShown) { this.scrollIntoView() @@ -325,12 +338,16 @@ const EmojiInput = { this.$refs.picker.hidePicker() } }, - replace (replacement) { - const newValue = Completion.replaceWord(this.modelValue, this.wordAtCaret, replacement) + replace(replacement) { + const newValue = Completion.replaceWord( + this.modelValue, + this.wordAtCaret, + replacement, + ) this.$emit('update:modelValue', newValue) this.caret = 0 }, - insert ({ insertion, keepOpen, surroundingSpace = true }) { + insert({ insertion, keepOpen, surroundingSpace = true }) { const before = this.modelValue.substring(0, this.caret) || '' const after = this.modelValue.substring(this.caret) || '' @@ -349,18 +366,24 @@ const EmojiInput = { * them, masto seem to be rendering :emoji::emoji: correctly now so why not */ const isSpaceRegex = /\s/ - const spaceBefore = (surroundingSpace && !isSpaceRegex.exec(before.slice(-1)) && before.length && this.padEmoji > 0) ? ' ' : '' - const spaceAfter = (surroundingSpace && !isSpaceRegex.exec(after[0]) && this.padEmoji) ? ' ' : '' + const spaceBefore = + surroundingSpace && + !isSpaceRegex.exec(before.slice(-1)) && + before.length && + this.padEmoji > 0 + ? ' ' + : '' + const spaceAfter = + surroundingSpace && !isSpaceRegex.exec(after[0]) && this.padEmoji + ? ' ' + : '' - const newValue = [ - before, - spaceBefore, - insertion, - spaceAfter, - after - ].join('') + const newValue = [before, spaceBefore, insertion, spaceAfter, after].join( + '', + ) this.$emit('update:modelValue', newValue) - const position = this.caret + (insertion + spaceAfter + spaceBefore).length + const position = + this.caret + (insertion + spaceAfter + spaceBefore).length if (!keepOpen) { this.input.focus() } @@ -372,13 +395,20 @@ const EmojiInput = { this.caret = position }) }, - replaceText (e, suggestion) { + replaceText(e, suggestion) { const len = this.suggestions.length || 0 - if (this.textAtCaret.length === 1) { return } + if (this.textAtCaret.length === 1) { + return + } if (len > 0 || suggestion) { - const chosenSuggestion = suggestion || this.suggestions[this.highlighted] + const chosenSuggestion = + suggestion || this.suggestions[this.highlighted] const replacement = chosenSuggestion.replacement - const newValue = Completion.replaceWord(this.modelValue, this.wordAtCaret, replacement) + const newValue = Completion.replaceWord( + this.modelValue, + this.wordAtCaret, + replacement, + ) this.$emit('update:modelValue', newValue) this.highlighted = 0 const position = this.wordAtCaret.start + replacement.length @@ -393,7 +423,7 @@ const EmojiInput = { e.preventDefault() } }, - cycleBackward (e) { + cycleBackward(e) { const len = this.suggestions.length || 0 this.highlighted -= 1 @@ -406,7 +436,7 @@ const EmojiInput = { e.preventDefault() } }, - cycleForward (e) { + cycleForward(e) { const len = this.suggestions.length || 0 this.highlighted += 1 @@ -418,26 +448,28 @@ const EmojiInput = { e.preventDefault() } }, - scrollIntoView () { + scrollIntoView() { const rootRef = this.$refs.picker.$el /* Scroller is either `window` (replies in TL), sidebar (main post form, * replies in notifs) or mobile post form. Note that getting and setting * scroll is different for `Window` and `Element`s */ - const scrollerRef = this.$el.closest('.sidebar-scroller') || - this.$el.closest('.post-form-modal-view') || - window - const currentScroll = scrollerRef === window - ? scrollerRef.scrollY - : scrollerRef.scrollTop - const scrollerHeight = scrollerRef === window - ? scrollerRef.innerHeight - : scrollerRef.offsetHeight + const scrollerRef = + this.$el.closest('.sidebar-scroller') || + this.$el.closest('.post-form-modal-view') || + window + const currentScroll = + scrollerRef === window ? scrollerRef.scrollY : scrollerRef.scrollTop + const scrollerHeight = + scrollerRef === window + ? scrollerRef.innerHeight + : scrollerRef.offsetHeight const scrollerBottomBorder = currentScroll + scrollerHeight // We check where the bottom border of root element is, this uses findOffset // to find offset relative to scrollable container (scroller) - const rootBottomBorder = rootRef.offsetHeight + findOffset(rootRef, scrollerRef).top + const rootBottomBorder = + rootRef.offsetHeight + findOffset(rootRef, scrollerRef).top const bottomDelta = Math.max(0, rootBottomBorder - scrollerBottomBorder) // could also check top delta but there's no case for it @@ -459,13 +491,13 @@ const EmojiInput = { } }) }, - onPickerShown () { + onPickerShown() { this.pickerShown = true }, - onPickerClosed () { + onPickerClosed() { this.pickerShown = false }, - onBlur (e) { + onBlur(e) { // Clicking on any suggestion removes focus from autocomplete, // preventing click handler ever executing. this.blurTimeout = setTimeout(() => { @@ -473,10 +505,10 @@ const EmojiInput = { this.setCaret(e) }, 200) }, - onClick (e, suggestion) { + onClick(e, suggestion) { this.replaceText(e, suggestion) }, - onFocus (e) { + onFocus(e) { if (this.blurTimeout) { clearTimeout(this.blurTimeout) this.blurTimeout = null @@ -486,7 +518,7 @@ const EmojiInput = { this.setCaret(e) this.temporarilyHideSuggestions = false }, - onKeyUp (e) { + onKeyUp(e) { const { key } = e this.setCaret(e) @@ -498,10 +530,10 @@ const EmojiInput = { this.temporarilyHideSuggestions = false } }, - onPaste (e) { + onPaste(e) { this.setCaret(e) }, - onKeyDown (e) { + onKeyDown(e) { const { ctrlKey, shiftKey, key } = e if (this.newlineOnCtrlEnter && ctrlKey && key === 'Enter') { this.insert({ insertion: '\n', surroundingSpace: false }) @@ -545,32 +577,30 @@ const EmojiInput = { } } }, - onInput (e) { + onInput(e) { this.setCaret(e) this.$emit('update:modelValue', e.target.value) }, - onStickerUploaded (e) { + onStickerUploaded(e) { this.$emit('sticker-uploaded', e) }, - onStickerUploadFailed (e) { + onStickerUploadFailed(e) { this.$emit('sticker-upload-Failed', e) }, - setCaret ({ target: { selectionStart } }) { + setCaret({ target: { selectionStart } }) { this.caret = selectionStart this.$nextTick(() => { this.$refs.suggestorPopover.updateStyles() }) }, - resize () { - }, - autoCompleteItemLabel (suggestion) { + autoCompleteItemLabel(suggestion) { if (suggestion.user) { return suggestion.displayText + ' ' + suggestion.detailText } else { return this.maybeLocalizedEmojiName(suggestion) } - } - } + }, + }, } export default EmojiInput diff --git a/src/components/emoji_input/emoji_input.vue b/src/components/emoji_input/emoji_input.vue index f9788d874..b362db32f 100644 --- a/src/components/emoji_input/emoji_input.vue +++ b/src/components/emoji_input/emoji_input.vue @@ -2,7 +2,7 @@
{ +export default (data) => { const emojiCurry = suggestEmoji(data.emoji) const usersCurry = data.store && suggestUsers(data.store) return (input, nameKeywordLocalizer) => { @@ -25,22 +27,35 @@ export default data => { } } -export const suggestEmoji = emojis => (input, nameKeywordLocalizer) => { +export const suggestEmoji = (emojis) => (input, nameKeywordLocalizer) => { const noPrefix = input.toLowerCase().substr(1) return emojis - .map(emoji => ({ ...emoji, ...nameKeywordLocalizer(emoji) })) - .filter((emoji) => (emoji.names.concat(emoji.keywords)).filter(kw => kw.toLowerCase().match(noPrefix)).length) - .map(k => { + .map((emoji) => ({ ...emoji, ...nameKeywordLocalizer(emoji) })) + .filter( + (emoji) => + emoji.names + .concat(emoji.keywords) + .filter((kw) => kw.toLowerCase().match(noPrefix)).length, + ) + .map((k) => { let score = 0 // An exact match always wins - score += Math.max(...k.names.map(name => name.toLowerCase() === noPrefix ? 200 : 0), 0) + score += Math.max( + ...k.names.map((name) => (name.toLowerCase() === noPrefix ? 200 : 0)), + 0, + ) // Prioritize custom emoji a lot score += k.imageUrl ? 100 : 0 // Prioritize prefix matches somewhat - score += Math.max(...k.names.map(kw => kw.toLowerCase().startsWith(noPrefix) ? 10 : 0), 0) + score += Math.max( + ...k.names.map((kw) => + kw.toLowerCase().startsWith(noPrefix) ? 10 : 0, + ), + 0, + ) // Sort by length score -= k.displayText.length @@ -78,7 +93,7 @@ export const suggestUsers = ({ dispatch, state }) => { }) } - return async input => { + return async (input) => { const noPrefix = input.toLowerCase().substr(1) if (previousQuery === noPrefix) return suggestions @@ -92,37 +107,43 @@ export const suggestUsers = ({ dispatch, state }) => { await debounceUserSearch(noPrefix) } - const newSuggestions = state.users.users.filter( - user => - user.screen_name && user.name && ( - user.screen_name.toLowerCase().startsWith(noPrefix) || - user.name.toLowerCase().startsWith(noPrefix)) - ).slice(0, 20).sort((a, b) => { - let aScore = 0 - let bScore = 0 + const newSuggestions = state.users.users + .filter( + (user) => + user.screen_name && + user.name && + (user.screen_name.toLowerCase().startsWith(noPrefix) || + user.name.toLowerCase().startsWith(noPrefix)), + ) + .slice(0, 20) + .sort((a, b) => { + let aScore = 0 + let bScore = 0 - // Matches on screen name (i.e. user@instance) makes a priority - aScore += a.screen_name.toLowerCase().startsWith(noPrefix) ? 2 : 0 - bScore += b.screen_name.toLowerCase().startsWith(noPrefix) ? 2 : 0 + // Matches on screen name (i.e. user@instance) makes a priority + aScore += a.screen_name.toLowerCase().startsWith(noPrefix) ? 2 : 0 + bScore += b.screen_name.toLowerCase().startsWith(noPrefix) ? 2 : 0 - // Matches on name takes second priority - aScore += a.name.toLowerCase().startsWith(noPrefix) ? 1 : 0 - bScore += b.name.toLowerCase().startsWith(noPrefix) ? 1 : 0 + // Matches on name takes second priority + aScore += a.name.toLowerCase().startsWith(noPrefix) ? 1 : 0 + bScore += b.name.toLowerCase().startsWith(noPrefix) ? 1 : 0 - const diff = (bScore - aScore) * 10 + const diff = (bScore - aScore) * 10 - // Then sort alphabetically - const nameAlphabetically = a.name > b.name ? 1 : -1 - const screenNameAlphabetically = a.screen_name > b.screen_name ? 1 : -1 + // Then sort alphabetically + const activity = a.last_status_at < b.last_status_at ? 100 : -100 + const nameAlphabetically = a.name > b.name ? 1 : -1 + const screenNameAlphabetically = a.screen_name > b.screen_name ? 1 : -1 - return diff + nameAlphabetically + screenNameAlphabetically - }).map((user) => ({ - user, - displayText: user.screen_name_ui, - detailText: user.name, - imageUrl: user.profile_image_url_original, - replacement: '@' + user.screen_name + ' ' - })) + return diff + nameAlphabetically + screenNameAlphabetically + activity + }) + .map((user) => ({ + user, + displayText: user.screen_name_ui, + detailText: user.name, + imageUrl: user.profile_image_url_original, + replacement: '@' + user.screen_name + ' ', + })) suggestions = newSuggestions || [] return suggestions diff --git a/src/components/emoji_picker/emoji_picker.js b/src/components/emoji_picker/emoji_picker.js index 17a317a4d..1d19987ac 100644 --- a/src/components/emoji_picker/emoji_picker.js +++ b/src/components/emoji_picker/emoji_picker.js @@ -1,24 +1,30 @@ +import { chunk, debounce, trim } from 'lodash' import { defineAsyncComponent } from 'vue' -import Checkbox from '../checkbox/checkbox.vue' + import Popover from 'src/components/popover/popover.vue' -import StillImage from '../still-image/still-image.vue' import { ensureFinalFallback } from '../../i18n/languages.js' +import Checkbox from '../checkbox/checkbox.vue' +import StillImage from '../still-image/still-image.vue' + +import { useEmojiStore } from 'src/stores/emoji.js' +import { useInstanceStore } from 'src/stores/instance.js' +import { useMergedConfigStore } from 'src/stores/merged_config.js' + import { library } from '@fortawesome/fontawesome-svg-core' import { - faBoxOpen, - faStickyNote, - faSmileBeam, - faSmile, - faUser, - faPaw, - faIceCream, - faBus, faBasketballBall, - faLightbulb, + faBoxOpen, + faBus, faCode, - faFlag + faFlag, + faIceCream, + faLightbulb, + faPaw, + faSmile, + faSmileBeam, + faStickyNote, + faUser, } from '@fortawesome/free-solid-svg-icons' -import { debounce, trim, chunk } from 'lodash' library.add( faBoxOpen, @@ -32,7 +38,7 @@ library.add( faBasketballBall, faLightbulb, faCode, - faFlag + faFlag, ) const UNICODE_EMOJI_GROUP_ICON = { @@ -44,16 +50,16 @@ const UNICODE_EMOJI_GROUP_ICON = { activities: 'basketball-ball', objects: 'lightbulb', symbols: 'code', - flags: 'flag' + flags: 'flag', } const maybeLocalizedKeywords = (emoji, languages, nameLocalizer) => { const res = [emoji.displayText, nameLocalizer(emoji)] if (emoji.annotations) { - languages.forEach(lang => { + languages.forEach((lang) => { const keywords = emoji.annotations[lang]?.keywords || [] const name = emoji.annotations[lang]?.name - res.push(...(keywords.concat([name]).filter(k => k))) + res.push(...keywords.concat([name]).filter((k) => k)) }) } return res @@ -66,8 +72,8 @@ const filterByKeyword = (list, keyword = '', languages, nameLocalizer) => { const orderedEmojiList = [] for (const emoji of list) { const indices = maybeLocalizedKeywords(emoji, languages, nameLocalizer) - .map(k => k.toLowerCase().indexOf(keywordLowercase)) - .filter(k => k > -1) + .map((k) => k.toLowerCase().indexOf(keywordLowercase)) + .filter((k) => k > -1) const indexOfKeyword = indices.length ? Math.min(...indices) : -1 @@ -84,11 +90,13 @@ const filterByKeyword = (list, keyword = '', languages, nameLocalizer) => { const getOffset = (elem) => { const style = elem.style.transform const res = /translateY\((\d+)px\)/.exec(style) - if (!res) { return 0 } + if (!res) { + return 0 + } return res[1] } -const toHeaderId = id => { +const toHeaderId = (id) => { return id.replace(/^row-\d+-/, '') } @@ -97,20 +105,20 @@ const EmojiPicker = { enableStickerPicker: { required: false, type: Boolean, - default: true + default: true, }, hideCustomEmoji: { required: false, type: Boolean, - default: false - } + default: false, + }, }, inject: { popoversZLayer: { - default: '' - } + default: '', + }, }, - data () { + data() { return { keyword: '', activeGroup: 'custom', @@ -125,28 +133,30 @@ const EmojiPicker = { emojiRefs: {}, filteredEmojiGroups: [], emojiSize: 0, - width: 0 + width: 0, } }, components: { - StickerPicker: defineAsyncComponent(() => import('../sticker_picker/sticker_picker.vue')), + StickerPicker: defineAsyncComponent( + () => import('../sticker_picker/sticker_picker.vue'), + ), Checkbox, StillImage, - Popover + Popover, }, methods: { - groupScroll (e) { + groupScroll(e) { e.currentTarget.scrollLeft += e.deltaY + e.deltaX }, - updateEmojiSize () { + updateEmojiSize() { const css = window.getComputedStyle(this.$refs.popover.$el) - const fontSize = css.getPropertyValue('font-size') || '14px' + const fontSize = css.getPropertyValue('font-size') || '1rem' const emojiSize = css.getPropertyValue('--emojiSize') || '2.2rem' - const fontSizeUnit = fontSize.replace(/[0-9,.]+/, '') + const fontSizeUnit = fontSize.replace(/[0-9,.]+/, '').trim() const fontSizeValue = Number(fontSize.replace(/[^0-9,.]+/, '')) - const emojiSizeUnit = emojiSize.replace(/[0-9,.]+/, '') + const emojiSizeUnit = emojiSize.replace(/[0-9,.]+/, '').trim() const emojiSizeValue = Number(emojiSize.replace(/[^0-9,.]+/, '')) let fontSizeMultiplier @@ -163,56 +173,68 @@ const EmojiPicker = { emojiSizeReal = emojiSizeValue } - const fullEmojiSize = emojiSizeReal + (2 * 0.2 * fontSizeMultiplier * 14) + const fullEmojiSize = emojiSizeReal + 2 * 0.2 * fontSizeMultiplier * 14 this.emojiSize = fullEmojiSize }, - showPicker () { + showPicker() { this.$refs.popover.showPopover() this.$nextTick(() => { this.onShowing() }) }, - hidePicker () { + hidePicker() { this.$refs.popover.hidePopover() }, - setAnchorEl (el) { + setAnchorEl(el) { this.$refs.popover.setAnchorEl(el) }, - setGroupRef (name) { - return el => { this.groupRefs[name] = el } + setGroupRef(name) { + return (el) => { + this.groupRefs[name] = el + } }, - onPopoverShown () { + onPopoverShown() { this.$emit('show') }, - onPopoverClosed () { + onPopoverClosed() { this.$emit('close') }, - onStickerUploaded (e) { + onStickerUploaded(e) { this.$emit('sticker-uploaded', e) }, - onStickerUploadFailed (e) { + onStickerUploadFailed(e) { this.$emit('sticker-upload-failed', e) }, - onEmoji (emoji) { - const value = emoji.imageUrl ? `:${emoji.displayText}:` : emoji.replacement + onEmoji(emoji) { + const value = emoji.imageUrl + ? `:${emoji.displayText}:` + : emoji.replacement if (!this.keepOpen) { this.$refs.popover.hidePopover() } - this.$emit('emoji', { insertion: value, insertionUrl: emoji.imageUrl, keepOpen: this.keepOpen }) + this.$emit('emoji', { + insertion: value, + insertionUrl: emoji.imageUrl, + keepOpen: this.keepOpen, + }) }, - onScroll (startIndex, endIndex, visibleStartIndex, visibleEndIndex) { + onScroll(startIndex, endIndex, visibleStartIndex, visibleEndIndex) { const target = this.$refs['emoji-groups'].$el this.scrolledGroup(target, visibleStartIndex, visibleEndIndex) }, - scrolledGroup (target, start, end) { + scrolledGroup(target, start, end) { const top = target.scrollTop + 5 this.$nextTick(() => { - this.emojiItems.slice(start, end + 1).forEach(group => { + this.emojiItems.slice(start, end + 1).forEach((group) => { const headerId = toHeaderId(group.id) const ref = this.groupRefs['group-' + group.id] - if (!ref) { return } + if (!ref) { + return + } const elem = ref.$el.parentElement - if (!elem) { return } + if (!elem) { + return + } if (elem && getOffset(elem) <= top) { this.activeGroup = headerId } @@ -220,7 +242,7 @@ const EmojiPicker = { this.scrollHeader() }) }, - scrollHeader () { + scrollHeader() { // Scroll the active tab's header into view const headerRef = this.groupRefs['group-header-' + this.activeGroup] const left = headerRef.offsetLeft @@ -228,7 +250,9 @@ const EmojiPicker = { const headerCont = this.$refs.header const currentScroll = headerCont.scrollLeft const currentScrollRight = currentScroll + headerCont.clientWidth - const setScroll = s => { headerCont.scrollLeft = s } + const setScroll = (s) => { + headerCont.scrollLeft = s + } const margin = 7 // .emoji-tabs-item: padding if (left - margin < currentScroll) { @@ -237,12 +261,12 @@ const EmojiPicker = { setScroll(right + margin - headerCont.clientWidth) } }, - highlight (groupId) { + highlight(groupId) { this.setShowStickers(false) - const indexInList = this.emojiItems.findIndex(k => k.id === groupId) + const indexInList = this.emojiItems.findIndex((k) => k.id === groupId) this.$refs['emoji-groups'].scrollToItem(indexInList) }, - updateScrolledClass (target) { + updateScrolledClass(target) { if (target.scrollTop <= 5) { this.groupsScrolledClass = 'scrolled-top' } else if (target.scrollTop >= target.scrollTopMax - 5) { @@ -251,16 +275,21 @@ const EmojiPicker = { this.groupsScrolledClass = 'scrolled-middle' } }, - toggleStickers () { + toggleStickers() { this.showingStickers = !this.showingStickers }, - setShowStickers (value) { + setShowStickers(value) { this.showingStickers = value }, - filterByKeyword (list, keyword) { - return filterByKeyword(list, keyword, this.languages, this.maybeLocalizedEmojiName) + filterByKeyword(list, keyword) { + return filterByKeyword( + list, + keyword, + this.languages, + this.maybeLocalizedEmojiName, + ) }, - onShowing () { + onShowing() { const oldContentLoaded = this.contentLoaded this.updateEmojiSize() this.recalculateItemPerRow() @@ -277,108 +306,111 @@ const EmojiPicker = { }) } }, - getFilteredEmojiGroups () { + getFilteredEmojiGroups() { return this.allEmojiGroups - .map(group => ({ + .map((group) => ({ ...group, - emojis: this.filterByKeyword(group.emojis, trim(this.keyword)) + emojis: this.filterByKeyword(group.emojis, trim(this.keyword)), })) - .filter(group => group.emojis.length > 0) + .filter((group) => group.emojis.length > 0) }, - recalculateItemPerRow () { + recalculateItemPerRow() { this.$nextTick(() => { if (!this.$refs['emoji-groups']) { return } this.width = this.$refs['emoji-groups'].$el.clientWidth }) - } + }, }, watch: { - keyword () { + keyword() { this.onScroll() this.debouncedHandleKeywordChange() }, - allCustomGroups () { + allCustomGroups() { this.filteredEmojiGroups = this.getFilteredEmojiGroups() - } + }, }, computed: { - minItemSize () { + minItemSize() { return this.emojiSize }, // used to watch it - fontSize () { + fontSize() { this.$nextTick(() => { this.updateEmojiSize() }) - return this.$store.getters.mergedConfig.fontSize + return useMergedConfigStore().mergedConfig.fontSize }, - emojiHeight () { + emojiHeight() { return this.emojiSize }, - itemPerRow () { + itemPerRow() { return this.width ? Math.floor(this.width / this.emojiSize) : 6 }, - activeGroupView () { + activeGroupView() { return this.showingStickers ? '' : this.activeGroup }, - stickersAvailable () { - if (this.$store.state.instance.stickers) { - return this.$store.state.instance.stickers.length > 0 + stickersAvailable() { + if (useEmojiStore().stickers) { + return useEmojiStore().stickers.length > 0 } return 0 }, - allCustomGroups () { + allCustomGroups() { if (this.hideCustomEmoji || this.hideCustomEmojiInPicker) { return {} } - const emojis = this.$store.getters.groupedCustomEmojis + const emojis = useEmojiStore().groupedCustomEmojis if (emojis.unpacked) { emojis.unpacked.text = this.$t('emoji.unpacked') } return emojis }, - defaultGroup () { + defaultGroup() { return Object.keys(this.allCustomGroups)[0] }, - unicodeEmojiGroups () { - return this.$store.getters.standardEmojiGroupList.map(group => ({ + unicodeEmojiGroups() { + return useEmojiStore().standardEmojiGroupList.map((group) => ({ id: `standard-${group.id}`, text: this.$t(`emoji.unicode_groups.${group.id}`), icon: UNICODE_EMOJI_GROUP_ICON[group.id], - emojis: group.emojis + emojis: group.emojis, })) }, - allEmojiGroups () { + allEmojiGroups() { return Object.entries(this.allCustomGroups) .map(([, v]) => v) .concat(this.unicodeEmojiGroups) }, - stickerPickerEnabled () { - return (this.$store.state.instance.stickers || []).length !== 0 + stickerPickerEnabled() { + return (useEmojiStore().stickers || []).length !== 0 }, - debouncedHandleKeywordChange () { + debouncedHandleKeywordChange() { return debounce(() => { this.filteredEmojiGroups = this.getFilteredEmojiGroups() }, 500) }, - emojiItems () { - return this.filteredEmojiGroups.map(group => - chunk(group.emojis, this.itemPerRow) - .map((items, index) => ({ + emojiItems() { + return this.filteredEmojiGroups + .map((group) => + chunk(group.emojis, this.itemPerRow).map((items, index) => ({ ...group, id: index === 0 ? group.id : `row-${index}-${group.id}`, emojis: items, - isFirstRow: index === 0 - }))) + isFirstRow: index === 0, + })), + ) .reduce((a, c) => a.concat(c), []) }, - languages () { - return ensureFinalFallback(this.$store.getters.mergedConfig.interfaceLanguage) + languages() { + return ensureFinalFallback( + useMergedConfigStore().mergedConfig.interfaceLanguage, + ) }, - maybeLocalizedEmojiName () { - return emoji => { + maybeLocalizedEmojiName() { + return (emoji) => { if (!emoji.annotations) { return emoji.displayText } @@ -396,10 +428,10 @@ const EmojiPicker = { return emoji.displayText } }, - isInModal () { + isInModal() { return this.popoversZLayer === 'modals' - } - } + }, + }, } export default EmojiPicker diff --git a/src/components/emoji_picker/emoji_picker.scss b/src/components/emoji_picker/emoji_picker.scss index 05600c790..e318cdb40 100644 --- a/src/components/emoji_picker/emoji_picker.scss +++ b/src/components/emoji_picker/emoji_picker.scss @@ -154,10 +154,12 @@ // Autoprefixed seem to ignore this one, and also syntax is different /* stylelint-disable mask-composite */ /* stylelint-disable declaration-property-value-no-unknown */ + /* stylelint-disable scss/declaration-property-value-no-unknown */ /* TODO check if this is still needed */ mask-composite: xor; /* stylelint-enable declaration-property-value-no-unknown */ + /* stylelint-enable scss/declaration-property-value-no-unknown */ /* stylelint-enable mask-composite */ mask-composite: exclude; diff --git a/src/components/emoji_reactions/emoji_reactions.js b/src/components/emoji_reactions/emoji_reactions.js index f5e1b68f6..e22336138 100644 --- a/src/components/emoji_reactions/emoji_reactions.js +++ b/src/components/emoji_reactions/emoji_reactions.js @@ -1,18 +1,14 @@ +import StillImage from 'src/components/still-image/still-image.vue' import UserAvatar from '../user_avatar/user_avatar.vue' import UserListPopover from '../user_list_popover/user_list_popover.vue' -import StillImage from 'src/components/still-image/still-image.vue' -import { library } from '@fortawesome/fontawesome-svg-core' -import { - faPlus, - faMinus, - faCheck -} from '@fortawesome/free-solid-svg-icons' -library.add( - faPlus, - faMinus, - faCheck -) +import { useInstanceStore } from 'src/stores/instance.js' +import { useMergedConfigStore } from 'src/stores/merged_config.js' + +import { library } from '@fortawesome/fontawesome-svg-core' +import { faCheck, faMinus, faPlus } from '@fortawesome/free-solid-svg-icons' + +library.add(faPlus, faMinus, faCheck) const EMOJI_REACTION_COUNT_CUTOFF = 12 @@ -21,57 +17,65 @@ const EmojiReactions = { components: { UserAvatar, UserListPopover, - StillImage + StillImage, }, props: ['status'], data: () => ({ - showAll: false + showAll: false, }), computed: { - tooManyReactions () { + tooManyReactions() { return this.status.emoji_reactions.length > EMOJI_REACTION_COUNT_CUTOFF }, - emojiReactions () { + emojiReactions() { return this.showAll ? this.status.emoji_reactions : this.status.emoji_reactions.slice(0, EMOJI_REACTION_COUNT_CUTOFF) }, - showMoreString () { + showMoreString() { return `+${this.status.emoji_reactions.length - EMOJI_REACTION_COUNT_CUTOFF}` }, - accountsForEmoji () { + accountsForEmoji() { return this.status.emoji_reactions.reduce((acc, reaction) => { acc[reaction.name] = reaction.accounts || [] return acc }, {}) }, - loggedIn () { + loggedIn() { return !!this.$store.state.users.currentUser }, - remoteInteractionLink () { - return this.$store.getters.remoteInteractionLink({ statusId: this.status.id }) - } + remoteInteractionLink() { + return useInstanceStore().getRemoteInteractionLink({ + statusId: this.status.id, + }) + }, + allowNonSquareEmoji() { + return useMergedConfigStore().mergedConfig.nonSquareEmoji + }, }, methods: { - toggleShowAll () { + toggleShowAll() { this.showAll = !this.showAll }, - reactedWith (emoji) { - return this.status.emoji_reactions.find(r => r.name === emoji).me + reactedWith(emoji) { + return this.status.emoji_reactions.find((r) => r.name === emoji).me }, - async fetchEmojiReactionsByIfMissing () { - const hasNoAccounts = this.status.emoji_reactions.find(r => !r.accounts) + async fetchEmojiReactionsByIfMissing() { + const hasNoAccounts = this.status.emoji_reactions.find((r) => !r.accounts) if (hasNoAccounts) { - return await this.$store.dispatch('fetchEmojiReactionsBy', this.status.id) + return await this.$store.dispatch( + 'fetchEmojiReactionsBy', + this.status.id, + ) } }, - reactWith (emoji) { + reactWith(emoji) { this.$store.dispatch('reactWithEmoji', { id: this.status.id, emoji }) }, - unreact (emoji) { + unreact(emoji) { this.$store.dispatch('unreactWithEmoji', { id: this.status.id, emoji }) }, - async emojiOnClick (emoji) { + async emojiOnClick(emoji) { if (!this.loggedIn) return await this.fetchEmojiReactionsByIfMissing() @@ -81,19 +85,23 @@ const EmojiReactions = { this.reactWith(emoji) } }, - counterTriggerAttrs (reaction) { + counterTriggerAttrs(reaction) { return { class: [ 'emoji-reaction-count-button', { '-picked-reaction': this.reactedWith(reaction.name), - toggled: this.reactedWith(reaction.name) - } + toggled: this.reactedWith(reaction.name), + }, ], - 'aria-label': this.$t('status.reaction_count_label', { num: reaction.count }, reaction.count) + 'aria-label': this.$t( + 'status.reaction_count_label', + { num: reaction.count }, + reaction.count, + ), } - } - } + }, + }, } export default EmojiReactions diff --git a/src/components/emoji_reactions/emoji_reactions.scss b/src/components/emoji_reactions/emoji_reactions.scss index 8ea45e1b4..88ff1cee6 100644 --- a/src/components/emoji_reactions/emoji_reactions.scss +++ b/src/components/emoji_reactions/emoji_reactions.scss @@ -49,6 +49,12 @@ justify-content: center; align-items: center; + &.-wide { + width: auto; + min-width: var(--emoji-size); + max-width: calc(var(--emoji-size) * 3); + } + --_still_image-label-scale: 0.3; } @@ -62,6 +68,12 @@ font-size: calc(var(--emoji-size) * 0.8); margin: 0; + &.-wide { + width: auto; + min-width: var(--emoji-size); + max-width: calc(var(--emoji-size) * 3); + } + img { object-fit: contain; } diff --git a/src/components/emoji_reactions/emoji_reactions.vue b/src/components/emoji_reactions/emoji_reactions.vue index e2361885a..16c69fa40 100644 --- a/src/components/emoji_reactions/emoji_reactions.vue +++ b/src/components/emoji_reactions/emoji_reactions.vue @@ -17,11 +17,13 @@ > { - const fileToDownload = document.createElement('a') - fileToDownload.setAttribute('href', 'data:text/plain;charset=utf-8,' + encodeURIComponent(content)) - fileToDownload.setAttribute('download', this.filename) - fileToDownload.style.display = 'none' - document.body.appendChild(fileToDownload) - fileToDownload.click() - document.body.removeChild(fileToDownload) - // Add delay before hiding processing state since browser takes some time to handle file download - setTimeout(() => { this.processing = false }, 2000) - }) - } - } + this.getContent().then((content) => { + const fileToDownload = document.createElement('a') + fileToDownload.setAttribute( + 'href', + 'data:text/plain;charset=utf-8,' + encodeURIComponent(content), + ) + fileToDownload.setAttribute('download', this.filename) + fileToDownload.style.display = 'none' + document.body.appendChild(fileToDownload) + fileToDownload.click() + document.body.removeChild(fileToDownload) + // Add delay before hiding processing state since browser takes some time to handle file download + setTimeout(() => { + this.processing = false + }, 2000) + }) + }, + }, } export default Exporter diff --git a/src/components/exporter/exporter.vue b/src/components/exporter/exporter.vue index 79defdf6f..e36d9dd62 100644 --- a/src/components/exporter/exporter.vue +++ b/src/components/exporter/exporter.vue @@ -23,6 +23,9 @@ diff --git a/src/components/link-preview/link-preview.js b/src/components/link-preview/link-preview.js index add7c5631..808030fbb 100644 --- a/src/components/link-preview/link-preview.js +++ b/src/components/link-preview/link-preview.js @@ -1,38 +1,34 @@ -import { mapGetters } from 'vuex' +import { mapState } from 'pinia' + +import { useMergedConfigStore } from 'src/stores/merged_config.js' const LinkPreview = { name: 'LinkPreview', - props: [ - 'card', - 'size', - 'nsfw' - ], - data () { + props: ['card', 'size', 'nsfw'], + data() { return { - imageLoaded: false + imageLoaded: false, } }, computed: { - useImage () { + useImage() { // Currently BE shoudn't give cards if tagged NSFW, this is a bit paranoid // as it makes sure to hide the image if somehow NSFW tagged preview can // exist. return this.card.image && !this.censored && this.size !== 'hide' }, - censored () { + censored() { return this.nsfw && this.hideNsfwConfig }, - useDescription () { + useDescription() { return this.card.description && /\S/.test(this.card.description) }, - hideNsfwConfig () { + hideNsfwConfig() { return this.mergedConfig.hideNsfw }, - ...mapGetters([ - 'mergedConfig' - ]) + ...mapState(useMergedConfigStore, ['mergedConfig']), }, - created () { + created() { if (this.useImage) { const newImg = new Image() newImg.onload = () => { @@ -40,7 +36,7 @@ const LinkPreview = { } newImg.src = this.card.image } - } + }, } export default LinkPreview diff --git a/src/components/link.style.js b/src/components/link.style.js index d13cef338..141a1f023 100644 --- a/src/components/link.style.js +++ b/src/components/link.style.js @@ -3,22 +3,22 @@ export default { selector: 'a', virtual: true, states: { - faint: '.faint' + faint: '.faint', }, defaultRules: [ { component: 'Link', directives: { - textColor: '--link' - } + textColor: '--link', + }, }, { component: 'Link', state: ['faint'], directives: { textOpacity: 0.5, - textOpacityMode: 'fake' - } - } - ] + textOpacityMode: 'fake', + }, + }, + ], } diff --git a/src/components/list/list.vue b/src/components/list/list.vue index 5d2c49b3c..f34a5f073 100644 --- a/src/components/list/list.vue +++ b/src/components/list/list.vue @@ -29,20 +29,20 @@ export default { props: { items: { type: Array, - default: () => [] + default: () => [], }, getKey: { type: Function, - default: item => item.id + default: (item) => item.id, }, getClass: { type: Function, - default: () => '' + default: () => '', }, nonInteractive: { type: Boolean, - default: false - } - } + default: false, + }, + }, } diff --git a/src/components/list/list_item.style.js b/src/components/list/list_item.style.js deleted file mode 100644 index 49b2b035f..000000000 --- a/src/components/list/list_item.style.js +++ /dev/null @@ -1,48 +0,0 @@ -export default { - name: 'ListItem', - selector: '.list-item', - states: { - active: '.-active', - hover: ':is(:hover, :focus-visible, :has(:focus-visible)):not(.-non-interactive)' - }, - validInnerComponents: [ - 'Text', - 'Link', - 'Icon', - 'Border', - 'Button', - 'ButtonUnstyled', - 'RichContent', - 'Input', - 'Avatar' - ], - defaultRules: [ - { - directives: { - background: '--bg', - opacity: 0 - } - }, - { - state: ['active'], - directives: { - background: '--inheritedBackground, 10', - opacity: 1 - } - }, - { - state: ['hover'], - directives: { - background: '--inheritedBackground, 10', - opacity: 1 - } - }, - { - state: ['hover', 'active'], - directives: { - background: '--inheritedBackground, 20', - opacity: 1 - } - } - ] -} diff --git a/src/components/lists/lists.js b/src/components/lists/lists.js index 8dcb48b52..ae58960f5 100644 --- a/src/components/lists/lists.js +++ b/src/components/lists/lists.js @@ -1,28 +1,29 @@ -import { useListsStore } from 'src/stores/lists' import ListsCard from '../lists_card/lists_card.vue' +import { useListsStore } from 'src/stores/lists.js' + const Lists = { - data () { + data() { return { - isNew: false + isNew: false, } }, components: { - ListsCard + ListsCard, }, computed: { - lists () { + lists() { return useListsStore().allLists - } + }, }, methods: { - cancelNewList () { + cancelNewList() { this.isNew = false }, - newList () { + newList() { this.isNew = true - } - } + }, + }, } export default Lists diff --git a/src/components/lists_card/lists_card.js b/src/components/lists_card/lists_card.js index b503caec4..81b811534 100644 --- a/src/components/lists_card/lists_card.js +++ b/src/components/lists_card/lists_card.js @@ -1,16 +1,10 @@ import { library } from '@fortawesome/fontawesome-svg-core' -import { - faEllipsisH -} from '@fortawesome/free-solid-svg-icons' +import { faEllipsisH } from '@fortawesome/free-solid-svg-icons' -library.add( - faEllipsisH -) +library.add(faEllipsisH) const ListsCard = { - props: [ - 'list' - ] + props: ['list'], } export default ListsCard diff --git a/src/components/lists_edit/lists_edit.js b/src/components/lists_edit/lists_edit.js index ca36ab886..483a9c02a 100644 --- a/src/components/lists_edit/lists_edit.js +++ b/src/components/lists_edit/lists_edit.js @@ -1,22 +1,19 @@ -import { mapState, mapGetters } from 'vuex' import { mapState as mapPiniaState } from 'pinia' +import { mapGetters, mapState } from 'vuex' + +import PanelLoading from 'src/components/panel_loading/panel_loading.vue' +import TabSwitcher from 'src/components/tab_switcher/tab_switcher.jsx' import BasicUserCard from '../basic_user_card/basic_user_card.vue' import ListsUserSearch from '../lists_user_search/lists_user_search.vue' -import PanelLoading from 'src/components/panel_loading/panel_loading.vue' import UserAvatar from '../user_avatar/user_avatar.vue' -import TabSwitcher from 'src/components/tab_switcher/tab_switcher.jsx' -import { library } from '@fortawesome/fontawesome-svg-core' -import { - faSearch, - faChevronLeft -} from '@fortawesome/free-solid-svg-icons' -import { useInterfaceStore } from 'src/stores/interface' -import { useListsStore } from 'src/stores/lists' -library.add( - faSearch, - faChevronLeft -) +import { useInterfaceStore } from 'src/stores/interface.js' +import { useListsStore } from 'src/stores/lists.js' + +import { library } from '@fortawesome/fontawesome-svg-core' +import { faChevronLeft, faSearch } from '@fortawesome/free-solid-svg-icons' + +library.add(faSearch, faChevronLeft) const ListsNew = { components: { @@ -24,9 +21,9 @@ const ListsNew = { UserAvatar, ListsUserSearch, TabSwitcher, - PanelLoading + PanelLoading, }, - data () { + data() { return { title: '', titleDraft: '', @@ -35,46 +32,51 @@ const ListsNew = { searchUserIds: [], addedUserIds: new Set([]), // users we added from search, to undo searchLoading: false, - reallyDelete: false + reallyDelete: false, } }, - created () { + created() { if (!this.id) return - useListsStore().fetchList({ listId: this.id }) + useListsStore() + .fetchList({ listId: this.id }) .then(() => { this.title = this.findListTitle(this.id) this.titleDraft = this.title }) - useListsStore().fetchListAccounts({ listId: this.id }) + useListsStore() + .fetchListAccounts({ listId: this.id }) .then(() => { this.membersUserIds = this.findListAccounts(this.id) - this.membersUserIds.forEach(userId => { + this.membersUserIds.forEach((userId) => { this.$store.dispatch('fetchUserIfMissing', userId) }) }) }, computed: { - id () { + id() { return this.$route.params.id }, - membersUsers () { + membersUsers() { return [...this.membersUserIds, ...this.addedUserIds] - .map(userId => this.findUser(userId)).filter(user => user) + .map((userId) => this.findUser(userId)) + .filter((user) => user) }, - searchUsers () { - return this.searchUserIds.map(userId => this.findUser(userId)).filter(user => user) + searchUsers() { + return this.searchUserIds + .map((userId) => this.findUser(userId)) + .filter((user) => user) }, ...mapState({ - currentUser: state => state.users.currentUser + currentUser: (state) => state.users.currentUser, }), ...mapPiniaState(useListsStore, ['findListTitle', 'findListAccounts']), - ...mapGetters(['findUser']) + ...mapGetters(['findUser']), }, methods: { - onInput () { + onInput() { this.search(this.query) }, - toggleRemoveMember (user) { + toggleRemoveMember(user) { if (this.removedUserIds.has(user.id)) { this.id && this.addUser(user) this.removedUserIds.delete(user.id) @@ -83,7 +85,7 @@ const ListsNew = { this.removedUserIds.add(user.id) } }, - toggleAddFromSearch (user) { + toggleAddFromSearch(user) { if (this.addedUserIds.has(user.id)) { this.id && this.removeUser(user.id) this.addedUserIds.delete(user.id) @@ -92,40 +94,41 @@ const ListsNew = { this.addedUserIds.add(user.id) } }, - isRemoved (user) { + isRemoved(user) { return this.removedUserIds.has(user.id) }, - isAdded (user) { + isAdded(user) { return this.addedUserIds.has(user.id) }, - addUser (user) { + addUser(user) { useListsStore().addListAccount({ accountId: user.id, listId: this.id }) }, - removeUser (userId) { + removeUser(userId) { useListsStore().removeListAccount({ accountId: userId, listId: this.id }) }, - onSearchLoading () { + onSearchLoading() { this.searchLoading = true }, - onSearchLoadingDone () { + onSearchLoadingDone() { this.searchLoading = false }, - onSearchResults (results) { + onSearchResults(results) { this.searchLoading = false this.searchUserIds = results }, - updateListTitle () { + updateListTitle() { useListsStore().setList({ listId: this.id, title: this.titleDraft }) - .then(() => { - this.title = this.findListTitle(this.id) - }) + this.title = this.findListTitle(this.id) }, - createList () { - useListsStore().createList({ title: this.titleDraft }) + createList() { + useListsStore() + .createList({ title: this.titleDraft }) .then((list) => { - return useListsStore() - .setListAccounts({ listId: list.id, accountIds: [...this.addedUserIds] }) - .then(() => list.id) + useListsStore().setListAccounts({ + listId: list.id, + accountIds: [...this.addedUserIds], + }) + return list.id }) .then((listId) => { this.$router.push({ name: 'lists-timeline', params: { id: listId } }) @@ -134,15 +137,15 @@ const ListsNew = { useInterfaceStore().pushGlobalNotice({ messageKey: 'lists.error', messageArgs: [e.message], - level: 'error' + level: 'error', }) }) }, - deleteList () { + deleteList() { useListsStore().deleteList({ listId: this.id }) this.$router.push({ name: 'lists' }) - } - } + }, + }, } export default ListsNew diff --git a/src/components/lists_menu/lists_menu_content.js b/src/components/lists_menu/lists_menu_content.js index 726fd5fc4..d96f14599 100644 --- a/src/components/lists_menu/lists_menu_content.js +++ b/src/components/lists_menu/lists_menu_content.js @@ -1,26 +1,25 @@ -import { mapState } from 'vuex' import { mapState as mapPiniaState } from 'pinia' -import NavigationEntry from 'src/components/navigation/navigation_entry.vue' +import { mapState } from 'vuex' + import { getListEntries } from 'src/components/navigation/filter.js' -import { useListsStore } from 'src/stores/lists' +import NavigationEntry from 'src/components/navigation/navigation_entry.vue' + +import { useInstanceStore } from 'src/stores/instance.js' +import { useListsStore } from 'src/stores/lists.js' export const ListsMenuContent = { - props: [ - 'showPin' - ], + props: ['showPin'], components: { - NavigationEntry + NavigationEntry, }, computed: { ...mapPiniaState(useListsStore, { - lists: getListEntries + lists: getListEntries, }), ...mapState({ - currentUser: state => state.users.currentUser, - privateMode: state => state.instance.private, - federating: state => state.instance.federating - }) - } + currentUser: (state) => state.users.currentUser, + }), + }, } export default ListsMenuContent diff --git a/src/components/lists_timeline/lists_timeline.js b/src/components/lists_timeline/lists_timeline.js index eae82a867..3d2cafa73 100644 --- a/src/components/lists_timeline/lists_timeline.js +++ b/src/components/lists_timeline/lists_timeline.js @@ -1,16 +1,20 @@ -import { useListsStore } from 'src/stores/lists' import Timeline from '../timeline/timeline.vue' + +import { useListsStore } from 'src/stores/lists.js' + const ListsTimeline = { - data () { + data() { return { - listId: null + listId: null, } }, components: { - Timeline + Timeline, }, computed: { - timeline () { return this.$store.state.statuses.timelines.list } + timeline() { + return this.$store.state.statuses.timelines.list + }, }, watch: { $route: function (route) { @@ -19,19 +23,25 @@ const ListsTimeline = { this.$store.dispatch('stopFetchingTimeline', 'list') this.$store.commit('clearTimeline', { timeline: 'list' }) useListsStore().fetchList({ listId: this.listId }) - this.$store.dispatch('startFetchingTimeline', { timeline: 'list', listId: this.listId }) + this.$store.dispatch('startFetchingTimeline', { + timeline: 'list', + listId: this.listId, + }) } - } + }, }, - created () { + created() { this.listId = this.$route.params.id useListsStore().fetchList({ listId: this.listId }) - this.$store.dispatch('startFetchingTimeline', { timeline: 'list', listId: this.listId }) + this.$store.dispatch('startFetchingTimeline', { + timeline: 'list', + listId: this.listId, + }) }, - unmounted () { + unmounted() { this.$store.dispatch('stopFetchingTimeline', 'list') this.$store.commit('clearTimeline', { timeline: 'list' }) - } + }, } export default ListsTimeline diff --git a/src/components/lists_user_search/lists_user_search.js b/src/components/lists_user_search/lists_user_search.js index c92ec0eee..699e6c156 100644 --- a/src/components/lists_user_search/lists_user_search.js +++ b/src/components/lists_user_search/lists_user_search.js @@ -1,33 +1,29 @@ -import { library } from '@fortawesome/fontawesome-svg-core' -import { - faSearch, - faChevronLeft -} from '@fortawesome/free-solid-svg-icons' import { debounce } from 'lodash' + import Checkbox from '../checkbox/checkbox.vue' -library.add( - faSearch, - faChevronLeft -) +import { library } from '@fortawesome/fontawesome-svg-core' +import { faChevronLeft, faSearch } from '@fortawesome/free-solid-svg-icons' + +library.add(faSearch, faChevronLeft) const ListsUserSearch = { components: { - Checkbox + Checkbox, }, emits: ['loading', 'loadingDone', 'results'], - data () { + data() { return { loading: false, query: '', - followingOnly: true + followingOnly: true, } }, methods: { onInput: debounce(function () { this.search(this.query) }, 2000), - search (query) { + search(query) { if (!query) { this.loading = false return @@ -36,16 +32,25 @@ const ListsUserSearch = { this.loading = true this.$emit('loading') this.userIds = [] - this.$store.dispatch('search', { q: query, resolve: true, type: 'accounts', following: this.followingOnly }) - .then(data => { - this.$emit('results', data.accounts.map(a => a.id)) + this.$store + .dispatch('search', { + q: query, + resolve: true, + type: 'accounts', + following: this.followingOnly, + }) + .then((data) => { + this.$emit( + 'results', + data.accounts.map((a) => a.id), + ) }) .finally(() => { this.loading = false this.$emit('loadingDone') }) - } - } + }, + }, } export default ListsUserSearch diff --git a/src/components/login_form/login_form.js b/src/components/login_form/login_form.js index 9566aa903..b16fb8d2f 100644 --- a/src/components/login_form/login_form.js +++ b/src/components/login_form/login_form.js @@ -1,98 +1,103 @@ +import { mapActions, mapState as mapPiniaState, mapStores } from 'pinia' import { mapState } from 'vuex' -import { mapStores, mapActions, mapState as mapPiniaState } from 'pinia' -import oauthApi from '../../services/new_api/oauth.js' -import { useOAuthStore } from 'src/stores/oauth.js' -import { useAuthFlowStore } from 'src/stores/auth_flow.js' -import { library } from '@fortawesome/fontawesome-svg-core' -import { - faTimes -} from '@fortawesome/free-solid-svg-icons' -library.add( - faTimes -) +import oauthApi from '../../services/new_api/oauth.js' + +import { useAuthFlowStore } from 'src/stores/auth_flow.js' +import { useInstanceStore } from 'src/stores/instance.js' +import { useOAuthStore } from 'src/stores/oauth.js' + +import { library } from '@fortawesome/fontawesome-svg-core' +import { faTimes } from '@fortawesome/free-solid-svg-icons' + +library.add(faTimes) const LoginForm = { data: () => ({ user: {}, - error: false + error: false, }), computed: { - isPasswordAuth () { return this.requiredPassword }, - isTokenAuth () { return this.requiredToken }, - ...mapStores(useOAuthStore), ...mapState({ - registrationOpen: state => state.instance.registrationOpen, - instance: state => state.instance, - loggingIn: state => state.users.loggingIn, + loggingIn: (state) => state.users.loggingIn, + }), + ...mapPiniaState(useOAuthStore, ['clientId', 'clientSecret']), + ...mapPiniaState(useInstanceStore, ['server', 'registrationOpen']), + ...mapPiniaState(useAuthFlowStore, { + isTokenAuth: (store) => store.requiredToken, + isPasswordAuth: (store) => !store.requiredToken, }), - ...mapPiniaState(useAuthFlowStore, ['requiredPassword', 'requiredToken', 'requiredMFA']) }, methods: { ...mapActions(useAuthFlowStore, ['requireMFA', 'login']), - submit () { + ...mapActions(useOAuthStore, ['ensureAppToken']), + submit() { this.isTokenAuth ? this.submitToken() : this.submitPassword() }, - submitToken () { + submitToken() { const data = { - instance: this.instance.server, - commit: this.$store.commit + instance: this.server, + commit: this.$store.commit, } // NOTE: we do not really need the app token, but obtaining a token and // calling verify_credentials is the only way to ensure the app still works. - this.oauthStore.ensureAppToken() - .then(() => { - const app = { - clientId: this.oauthStore.clientId, - clientSecret: this.oauthStore.clientSecret, - } - oauthApi.login({ ...app, ...data }) - }) + this.ensureAppToken().then(() => { + const app = { + clientId: this.clientId, + clientSecret: this.clientSecret, + } + oauthApi.login({ ...app, ...data }) + }) }, - submitPassword () { + submitPassword() { this.error = false // NOTE: we do not really need the app token, but obtaining a token and // calling verify_credentials is the only way to ensure the app still works. - this.oauthStore.ensureAppToken().then(() => { + this.ensureAppToken().then(() => { const app = { - clientId: this.oauthStore.clientId, - clientSecret: this.oauthStore.clientSecret, + clientId: this.clientId, + clientSecret: this.clientSecret, } - oauthApi.getTokenWithCredentials( - { + oauthApi + .getTokenWithCredentials({ ...app, - instance: this.instance.server, + instance: this.server, username: this.user.username, - password: this.user.password - } - ).then((result) => { - if (result.error) { - if (result.error === 'mfa_required') { - this.requireMFA({ settings: result }) - } else if (result.identifier === 'password_reset_required') { - this.$router.push({ name: 'password-reset', params: { passwordResetRequested: true } }) - } else { - this.error = result.error - this.focusOnPasswordInput() - } - return - } - this.login(result).then(() => { - this.$router.push({ name: 'friends' }) + password: this.user.password, + }) + .then((result) => { + if (result.error) { + if (result.error === 'mfa_required') { + this.requireMFA({ settings: result }) + } else if (result.identifier === 'password_reset_required') { + this.$router.push({ + name: 'password-reset', + params: { passwordResetRequested: true }, + }) + } else { + this.error = result.error + this.focusOnPasswordInput() + } + return + } + this.login(result).then(() => { + this.$router.push({ name: 'friends' }) + }) }) - }) }) }, - clearError () { this.error = false }, - focusOnPasswordInput () { + clearError() { + this.error = false + }, + focusOnPasswordInput() { const passwordInput = this.$refs.passwordInput passwordInput.focus() passwordInput.setSelectionRange(0, passwordInput.value.length) - } - } + }, + }, } export default LoginForm diff --git a/src/components/media_modal/media_modal.js b/src/components/media_modal/media_modal.js index 9a57f8250..0808b8cd6 100644 --- a/src/components/media_modal/media_modal.js +++ b/src/components/media_modal/media_modal.js @@ -1,26 +1,22 @@ -import StillImage from '../still-image/still-image.vue' -import VideoAttachment from '../video_attachment/video_attachment.vue' +import Flash from 'src/components/flash/flash.vue' +import GestureService from '../../services/gesture_service/gesture_service' import Modal from '../modal/modal.vue' import PinchZoom from '../pinch_zoom/pinch_zoom.vue' +import StillImage from '../still-image/still-image.vue' import SwipeClick from '../swipe_click/swipe_click.vue' -import GestureService from '../../services/gesture_service/gesture_service' -import Flash from 'src/components/flash/flash.vue' -import fileTypeService from '../../services/file_type/file_type.service.js' +import VideoAttachment from '../video_attachment/video_attachment.vue' + +import { useMediaViewerStore } from 'src/stores/media_viewer.js' + import { library } from '@fortawesome/fontawesome-svg-core' import { faChevronLeft, faChevronRight, faCircleNotch, - faTimes + faTimes, } from '@fortawesome/free-solid-svg-icons' -import { useMediaViewerStore } from 'src/stores/media_viewer' -library.add( - faChevronLeft, - faChevronRight, - faCircleNotch, - faTimes -) +library.add(faChevronLeft, faChevronRight, faCircleNotch, faTimes) const MediaModal = { components: { @@ -29,9 +25,9 @@ const MediaModal = { PinchZoom, SwipeClick, Modal, - Flash + Flash, }, - data () { + data() { return { loading: false, swipeDirection: GestureService.DIRECTION_LEFT, @@ -40,42 +36,36 @@ const MediaModal = { return window.innerWidth * considerableMoveRatio }, pinchZoomMinScale: 1, - pinchZoomScaleResetLimit: 1.2 + pinchZoomScaleResetLimit: 1.2, } }, computed: { - showing () { + showing() { return useMediaViewerStore().activated }, - media () { + media() { return useMediaViewerStore().media }, - description () { + description() { return this.currentMedia.description }, - currentIndex () { + currentIndex() { return useMediaViewerStore().currentIndex }, - currentMedia () { + currentMedia() { return this.media[this.currentIndex] }, - canNavigate () { + canNavigate() { return this.media.length > 1 }, - type () { - return this.currentMedia ? this.getType(this.currentMedia) : null - }, - swipeDisableClickThreshold () { + swipeDisableClickThreshold() { // If there is only one media, allow more mouse movements to close the modal // because there is less chance that the user wants to switch to another image - return () => this.canNavigate ? 1 : 30 - } + return () => (this.canNavigate ? 1 : 30) + }, }, methods: { - getType (media) { - return fileTypeService.fileType(media.mimetype) - }, - hide () { + hide() { // HACK: Closing immediately via a touch will cause the click // to be processed on the content below the overlay const transitionTime = 100 // ms @@ -83,7 +73,7 @@ const MediaModal = { useMediaViewerStore().closeMediaViewer() }, transitionTime) }, - hideIfNotSwiped (event) { + hideIfNotSwiped(event) { // If we have swiped over SwipeClick, do not trigger hide const comp = this.$refs.swipeClick if (!comp) { @@ -92,33 +82,39 @@ const MediaModal = { comp.$gesture.click(event) } }, - goPrev () { + goPrev() { if (this.canNavigate) { - const prevIndex = this.currentIndex === 0 ? this.media.length - 1 : (this.currentIndex - 1) + const prevIndex = + this.currentIndex === 0 + ? this.media.length - 1 + : this.currentIndex - 1 const newMedia = this.media[prevIndex] - if (this.getType(newMedia) === 'image') { + if (newMedia.type === 'image') { this.loading = true } useMediaViewerStore().setCurrentMedia(newMedia) } }, - goNext () { + goNext() { if (this.canNavigate) { - const nextIndex = this.currentIndex === this.media.length - 1 ? 0 : (this.currentIndex + 1) + const nextIndex = + this.currentIndex === this.media.length - 1 + ? 0 + : this.currentIndex + 1 const newMedia = this.media[nextIndex] - if (this.getType(newMedia) === 'image') { + if (newMedia.type === 'image') { this.loading = true } useMediaViewerStore().setCurrentMedia(newMedia) } }, - onImageLoaded () { + onImageLoaded() { this.loading = false }, - handleSwipePreview (offsets) { + handleSwipePreview(offsets) { this.$refs.pinchZoom.setTransform({ scale: 1, x: offsets[0], y: 0 }) }, - handleSwipeEnd (sign) { + handleSwipeEnd(sign) { this.$refs.pinchZoom.setTransform({ scale: 1, x: 0, y: 0 }) if (sign > 0) { this.goNext() @@ -126,33 +122,36 @@ const MediaModal = { this.goPrev() } }, - handleKeyupEvent (e) { - if (this.showing && e.keyCode === 27) { // escape + handleKeyupEvent(e) { + if (this.showing && e.keyCode === 27) { + // escape this.hide() } }, - handleKeydownEvent (e) { + handleKeydownEvent(e) { if (!this.showing) { return } - if (e.keyCode === 39) { // arrow right + if (e.keyCode === 39) { + // arrow right this.goNext() - } else if (e.keyCode === 37) { // arrow left + } else if (e.keyCode === 37) { + // arrow left this.goPrev() } - } + }, }, - mounted () { + mounted() { window.addEventListener('popstate', this.hide) document.addEventListener('keyup', this.handleKeyupEvent) document.addEventListener('keydown', this.handleKeydownEvent) }, - unmounted () { + unmounted() { window.removeEventListener('popstate', this.hide) document.removeEventListener('keyup', this.handleKeyupEvent) document.removeEventListener('keydown', this.handleKeydownEvent) - } + }, } export default MediaModal diff --git a/src/components/media_modal/media_modal.vue b/src/components/media_modal/media_modal.vue index 5cc8c50a3..93587210f 100644 --- a/src/components/media_modal/media_modal.vue +++ b/src/components/media_modal/media_modal.vue @@ -5,7 +5,7 @@ @backdrop-clicked="hideIfNotSwiped" >
{ +export const filterNavigation = ( + list = [], + { + hasChats, + hasAnnouncements, + isFederating, + isPrivate, + currentUser, + supportsBookmarkFolders, + supportsBubbleTimeline, + }, +) => { return list.filter(({ criteria, anon, anonRoute }) => { const set = new Set(criteria || []) if (!isFederating && set.has('federating')) return false if (!currentUser && isPrivate && set.has('!private')) return false if (!currentUser && !(anon || anonRoute)) return false - if ((!currentUser || !currentUser.locked) && set.has('lockedUser')) return false + if ((!currentUser || !currentUser.locked) && set.has('lockedUser')) + return false if (!hasChats && set.has('chats')) return false if (!hasAnnouncements && set.has('announcements')) return false - if (!supportsBubbleTimeline && set.has('supportsBubbleTimeline')) return false - if (!supportsBookmarkFolders && set.has('supportsBookmarkFolders')) return false - if (supportsBookmarkFolders && set.has('!supportsBookmarkFolders')) return false + if (!supportsBubbleTimeline && set.has('supportsBubbleTimeline')) + return false + if (!supportsBookmarkFolders && set.has('supportsBookmarkFolders')) + return false + if (supportsBookmarkFolders && set.has('!supportsBookmarkFolders')) + return false return true }) } -export const getListEntries = store => store.allLists.map(list => ({ - name: 'list-' + list.id, - routeObject: { name: 'lists-timeline', params: { id: list.id } }, - labelRaw: list.title, - iconLetter: list.title[0] -})) +export const getListEntries = (store) => + store.allLists.map((list) => ({ + name: 'list-' + list.id, + routeObject: { name: 'lists-timeline', params: { id: list.id } }, + labelRaw: list.title, + iconLetter: list.title[0], + })) -export const getBookmarkFolderEntries = store => store.allFolders ? store.allFolders.map(folder => ({ - name: 'bookmark-folder-' + folder.id, - routeObject: { name: 'bookmark-folder', params: { id: folder.id } }, - labelRaw: folder.name, - iconEmoji: folder.emoji, - iconEmojiUrl: folder.emoji_url, - iconLetter: folder.name[0] -})) : [] +export const getBookmarkFolderEntries = (store) => + store.allFolders + ? store.allFolders.map((folder) => ({ + name: 'bookmark-folder-' + folder.id, + routeObject: { name: 'bookmark-folder', params: { id: folder.id } }, + labelRaw: folder.name, + iconEmoji: folder.emoji, + iconEmojiUrl: folder.emoji_url, + iconLetter: folder.name[0], + })) + : [] diff --git a/src/components/navigation/navigation.js b/src/components/navigation/navigation.js index d1c2b6763..66fb0d347 100644 --- a/src/components/navigation/navigation.js +++ b/src/components/navigation/navigation.js @@ -4,42 +4,39 @@ export const USERNAME_ROUTES = new Set([ 'interactions', 'notifications', 'chat', - 'chats' + 'chats', ]) // routes that take :name property -export const NAME_ROUTES = new Set([ - 'user-profile', - 'legacy-user-profile' -]) +export const NAME_ROUTES = new Set(['user-profile', 'legacy-user-profile']) export const TIMELINES = { home: { route: 'friends', icon: 'home', label: 'nav.home_timeline', - criteria: ['!private'] + criteria: ['!private'], }, public: { route: 'public-timeline', anon: true, icon: 'users', label: 'nav.public_tl', - criteria: ['!private'] + criteria: ['!private'], }, bubble: { route: 'bubble', anon: true, icon: 'city', label: 'nav.bubble', - criteria: ['!private', 'federating', 'supportsBubbleTimeline'] + criteria: ['!private', 'federating', 'supportsBubbleTimeline'], }, twkn: { route: 'public-external-timeline', anon: true, icon: 'globe', label: 'nav.twkn', - criteria: ['!private', 'federating'] + criteria: ['!private', 'federating'], }, // bookmarks are still technically a timeline so we should show it in the dropdown bookmarks: { @@ -50,13 +47,13 @@ export const TIMELINES = { favorites: { routeObject: { name: 'user-profile', query: { tab: 'favorites' } }, icon: 'star', - label: 'user_card.favorites' + label: 'user_card.favorites', }, dms: { route: 'dms', icon: 'envelope', - label: 'nav.dms' - } + label: 'nav.dms', + }, } export const ROOT_ITEMS = { @@ -67,12 +64,12 @@ export const ROOT_ITEMS = { // shows bookmarks entry in a better suited location // hides it when bookmark folders are supported since // we show custom component instead of it - criteria: ['!supportsBookmarkFolders'] + criteria: ['!supportsBookmarkFolders'], }, interactions: { route: 'interactions', icon: 'bell', - label: 'nav.interactions' + label: 'nav.interactions', }, chats: { route: 'chats', @@ -80,7 +77,7 @@ export const ROOT_ITEMS = { label: 'nav.chats', badgeStyle: 'notification', badgeGetter: 'unreadChatCount', - criteria: ['chats'] + criteria: ['chats'], }, friendRequests: { route: 'friend-requests', @@ -88,13 +85,13 @@ export const ROOT_ITEMS = { label: 'nav.friend_requests', badgeStyle: 'notification', criteria: ['lockedUser'], - badgeGetter: 'followRequestCount' + badgeGetter: 'followRequestCount', }, about: { route: 'about', anon: true, icon: 'info-circle', - label: 'nav.about' + label: 'nav.about', }, announcements: { route: 'announcements', @@ -103,18 +100,18 @@ export const ROOT_ITEMS = { store: 'announcements', badgeStyle: 'notification', badgeGetter: 'unreadAnnouncementCount', - criteria: ['announcements'] + criteria: ['announcements'], }, drafts: { route: 'drafts', icon: 'file-pen', label: 'nav.drafts', badgeStyle: 'neutral', - badgeGetter: 'draftCount' - } + badgeGetter: 'draftCount', + }, } -export function routeTo (item, currentUser) { +export function routeTo(item, currentUser) { if (!item.route && !item.routeObject) return null let route @@ -122,7 +119,7 @@ export function routeTo (item, currentUser) { if (item.routeObject) { route = item.routeObject } else { - route = { name: (item.anon || currentUser) ? item.route : item.anonRoute } + route = { name: item.anon || currentUser ? item.route : item.anonRoute } } if (USERNAME_ROUTES.has(route.name)) { diff --git a/src/components/navigation/navigation_entry.js b/src/components/navigation/navigation_entry.js index 11db1c9e3..7a43000ce 100644 --- a/src/components/navigation/navigation_entry.js +++ b/src/components/navigation/navigation_entry.js @@ -1,48 +1,57 @@ +import { mapState as mapPiniaState, mapStores } from 'pinia' import { mapState } from 'vuex' + import { routeTo } from 'src/components/navigation/navigation.js' import OptionalRouterLink from 'src/components/optional_router_link/optional_router_link.vue' + +import { useAnnouncementsStore } from 'src/stores/announcements.js' +import { useSyncConfigStore } from 'src/stores/sync_config.js' + import { library } from '@fortawesome/fontawesome-svg-core' import { faThumbtack } from '@fortawesome/free-solid-svg-icons' -import { mapStores, mapState as mapPiniaState } from 'pinia' - -import { useAnnouncementsStore } from 'src/stores/announcements' -import { useServerSideStorageStore } from 'src/stores/serverSideStorage' library.add(faThumbtack) const NavigationEntry = { props: ['item', 'showPin'], components: { - OptionalRouterLink + OptionalRouterLink, }, methods: { - isPinned (value) { + isPinned(value) { return this.pinnedItems.has(value) }, - togglePin (value) { + togglePin(value) { if (this.isPinned(value)) { - useServerSideStorageStore().removeCollectionPreference({ path: 'collections.pinnedNavItems', value }) + useSyncConfigStore().removeCollectionPreference({ + path: 'collections.pinnedNavItems', + value, + }) } else { - useServerSideStorageStore().addCollectionPreference({ path: 'collections.pinnedNavItems', value }) + useSyncConfigStore().addCollectionPreference({ + path: 'collections.pinnedNavItems', + value, + }) } - useServerSideStorageStore().pushServerSideStorage() - } + useSyncConfigStore().pushSyncConfig() + }, }, computed: { - routeTo () { + routeTo() { return routeTo(this.item, this.currentUser) }, - getters () { + getters() { return this.$store.getters }, ...mapStores(useAnnouncementsStore), ...mapState({ - currentUser: state => state.users.currentUser + currentUser: (state) => state.users.currentUser, }), - ...mapPiniaState(useServerSideStorageStore, { - pinnedItems: store => new Set(store.prefsStorage.collections.pinnedNavItems) + ...mapPiniaState(useSyncConfigStore, { + pinnedItems: (store) => + new Set(store.prefsStorage.collections.pinnedNavItems), }), - } + }, } export default NavigationEntry diff --git a/src/components/navigation/navigation_pins.js b/src/components/navigation/navigation_pins.js index f9a900fc6..85f6fafee 100644 --- a/src/components/navigation/navigation_pins.js +++ b/src/components/navigation/navigation_pins.js @@ -1,27 +1,38 @@ -import { mapState } from 'vuex' import { mapState as mapPiniaState } from 'pinia' -import { TIMELINES, ROOT_ITEMS, routeTo } from 'src/components/navigation/navigation.js' -import { getBookmarkFolderEntries, getListEntries, filterNavigation } from 'src/components/navigation/filter.js' +import { mapState } from 'vuex' +import { + filterNavigation, + getBookmarkFolderEntries, + getListEntries, +} from 'src/components/navigation/filter.js' +import { + ROOT_ITEMS, + routeTo, + TIMELINES, +} from 'src/components/navigation/navigation.js' import StillImage from 'src/components/still-image/still-image.vue' +import { useAnnouncementsStore } from 'src/stores/announcements' +import { useBookmarkFoldersStore } from 'src/stores/bookmark_folders' +import { useInstanceStore } from 'src/stores/instance.js' +import { useInstanceCapabilitiesStore } from 'src/stores/instance_capabilities.js' +import { useListsStore } from 'src/stores/lists' +import { useSyncConfigStore } from 'src/stores/sync_config.js' + import { library } from '@fortawesome/fontawesome-svg-core' import { - faUsers, - faGlobe, - faCity, - faBookmark, - faEnvelope, - faComments, faBell, + faBookmark, + faCity, + faComments, + faEnvelope, + faGlobe, faInfoCircle, + faList, faStream, - faList + faUsers, } from '@fortawesome/free-solid-svg-icons' -import { useListsStore } from 'src/stores/lists' -import { useAnnouncementsStore } from 'src/stores/announcements' -import { useBookmarkFoldersStore } from 'src/stores/bookmark_folders' -import { useServerSideStorageStore } from 'src/stores/serverSideStorage' library.add( faUsers, @@ -33,85 +44,87 @@ library.add( faBell, faInfoCircle, faStream, - faList + faList, ) const NavPanel = { props: ['limit'], methods: { - getRouteTo (item) { + getRouteTo(item) { return routeTo(item, this.currentUser) - } + }, }, components: { - StillImage + StillImage, }, computed: { - getters () { + getters() { return this.$store.getters }, ...mapPiniaState(useListsStore, { - lists: getListEntries + lists: getListEntries, }), ...mapPiniaState(useAnnouncementsStore, { - supportsAnnouncements: store => store.supportsAnnouncements + supportsAnnouncements: (store) => store.supportsAnnouncements, }), ...mapPiniaState(useBookmarkFoldersStore, { bookmarks: getBookmarkFolderEntries, }), - ...mapPiniaState(useServerSideStorageStore, { - pinnedItems: store => new Set(store.prefsStorage.collections.pinnedNavItems) + ...mapPiniaState(useSyncConfigStore, { + pinnedItems: (store) => + new Set(store.prefsStorage.collections.pinnedNavItems), }), + ...mapPiniaState(useInstanceStore, ['privateMode', 'federating']), + ...mapPiniaState(useInstanceCapabilitiesStore, [ + 'pleromaChatMessagesAvailable', + 'localBubble', + ]), ...mapState({ - currentUser: state => state.users.currentUser, - followRequestCount: state => state.api.followRequests.length, - privateMode: state => state.instance.private, - federating: state => state.instance.federating, - pleromaChatMessagesAvailable: state => state.instance.pleromaChatMessagesAvailable, - bubbleTimeline: state => state.instance.localBubbleInstances.length > 0 + currentUser: (state) => state.users.currentUser, + followRequestCount: (state) => state.api.followRequests.length, }), - pinnedList () { + pinnedList() { if (!this.currentUser) { - return filterNavigation([ - { ...TIMELINES.public, name: 'public' }, - { ...TIMELINES.twkn, name: 'twkn' }, - { ...ROOT_ITEMS.about, name: 'about' } - ], - { - hasChats: this.pleromaChatMessagesAvailable, - hasAnnouncements: this.supportsAnnouncements, - isFederating: this.federating, - isPrivate: this.privateMode, - currentUser: this.currentUser, - supportsBubbleTimeline: this.bubbleTimeline, - supportsBookmarkFolders: this.bookmarks - }) + return filterNavigation( + [ + { ...TIMELINES.public, name: 'public' }, + { ...TIMELINES.twkn, name: 'twkn' }, + { ...ROOT_ITEMS.about, name: 'about' }, + ], + { + hasChats: this.pleromaChatMessagesAvailable, + hasAnnouncements: this.supportsAnnouncements, + isFederating: this.federating, + isPrivate: this.privateMode, + currentUser: this.currentUser, + supportsBubbleTimeline: this.localBubble, + supportsBookmarkFolders: this.bookmarks, + }, + ) } return filterNavigation( [ - ...Object - .entries({ ...TIMELINES }) + ...Object.entries({ ...TIMELINES }) .filter(([k]) => this.pinnedItems.has(k)) .map(([k, v]) => ({ ...v, name: k })), ...this.lists.filter((k) => this.pinnedItems.has(k.name)), ...this.bookmarks.filter((k) => this.pinnedItems.has(k.name)), - ...Object - .entries({ ...ROOT_ITEMS }) + ...Object.entries({ ...ROOT_ITEMS }) .filter(([k]) => this.pinnedItems.has(k)) - .map(([k, v]) => ({ ...v, name: k })) + .map(([k, v]) => ({ ...v, name: k })), ], { hasChats: this.pleromaChatMessagesAvailable, hasAnnouncements: this.supportsAnnouncements, - supportsBubbleTimeline: this.bubbleTimeline, + supportsBubbleTimeline: this.localBubble, supportsBookmarkFolders: this.bookmarks, isFederating: this.federating, isPrivate: this.privateMode, - currentUser: this.currentUser - } + currentUser: this.currentUser, + }, ).slice(0, this.limit) - } - } + }, + }, } export default NavPanel diff --git a/src/components/notification/notification.js b/src/components/notification/notification.js index 043be1b1a..fc4ffc518 100644 --- a/src/components/notification/notification.js +++ b/src/components/notification/notification.js @@ -1,29 +1,39 @@ -import StatusContent from '../status_content/status_content.vue' import { mapState } from 'vuex' + +import RichContent from 'src/components/rich_content/rich_content.jsx' +import { isStatusNotification } from '../../services/notification_utils/notification_utils.js' +import { + highlightClass, + highlightStyle, +} from '../../services/user_highlighter/user_highlighter.js' +import ConfirmModal from '../confirm_modal/confirm_modal.vue' +import Report from '../report/report.vue' import Status from '../status/status.vue' +import StatusContent from '../status_content/status_content.vue' +import Timeago from '../timeago/timeago.vue' import UserAvatar from '../user_avatar/user_avatar.vue' import UserCard from '../user_card/user_card.vue' -import Timeago from '../timeago/timeago.vue' -import Report from '../report/report.vue' import UserLink from '../user_link/user_link.vue' -import RichContent from 'src/components/rich_content/rich_content.jsx' import UserPopover from '../user_popover/user_popover.vue' -import ConfirmModal from '../confirm_modal/confirm_modal.vue' -import { isStatusNotification } from '../../services/notification_utils/notification_utils.js' -import { highlightClass, highlightStyle } from '../../services/user_highlighter/user_highlighter.js' + +import { useInstanceStore } from 'src/stores/instance.js' +import { useMergedConfigStore } from 'src/stores/merged_config.js' +import { useUserHighlightStore } from 'src/stores/user_highlight.js' + import generateProfileLink from 'src/services/user_profile_link_generator/user_profile_link_generator' + import { library } from '@fortawesome/fontawesome-svg-core' import { faCheck, - faTimes, - faStar, - faRetweet, - faUserPlus, - faEyeSlash, - faUser, - faSuitcaseRolling, + faCompressAlt, faExpandAlt, - faCompressAlt + faEyeSlash, + faRetweet, + faStar, + faSuitcaseRolling, + faTimes, + faUser, + faUserPlus, } from '@fortawesome/free-solid-svg-icons' library.add( @@ -36,16 +46,17 @@ library.add( faEyeSlash, faSuitcaseRolling, faExpandAlt, - faCompressAlt + faCompressAlt, ) const Notification = { - data () { + data() { return { + selecting: false, statusExpanded: false, unmuted: false, showingApproveConfirmDialog: false, - showingDenyConfirmDialog: false + showingDenyConfirmDialog: false, } }, props: ['notification'], @@ -60,113 +71,158 @@ const Notification = { RichContent, UserPopover, UserLink, - ConfirmModal + ConfirmModal, + }, + mounted() { + document.addEventListener('selectionchange', this.onContentSelect) + }, + unmounted() { + document.removeEventListener('selectionchange', this.onContentSelect) }, methods: { - toggleStatusExpanded () { + toggleStatusExpanded() { + if (!this.expandable) return this.statusExpanded = !this.statusExpanded }, - generateUserProfileLink (user) { - return generateProfileLink(user.id, user.screen_name, this.$store.state.instance.restrictedNicknames) + onContentSelect() { + const { isCollapsed, anchorNode, offsetNode } = document.getSelection() + if (isCollapsed) { + this.selecting = false + return + } + const within = + this.$refs.root.contains(anchorNode) || + this.$refs.root.contains(offsetNode) + if (within) { + this.selecting = true + } else { + this.selecting = false + } }, - getUser (notification) { + onContentClick(e) { + if ( + !this.selecting && + !e.target.closest('a') && + !e.target.closest('button') + ) { + this.toggleStatusExpanded() + } + }, + generateUserProfileLink(user) { + return generateProfileLink( + user.id, + user.screen_name, + useInstanceStore().restrictedNicknames, + ) + }, + getUser(notification) { return this.$store.state.users.usersObject[notification.from_profile.id] }, - interacted () { + interacted() { this.$emit('interacted') }, - toggleMute () { + toggleMute() { this.unmuted = !this.unmuted }, - showApproveConfirmDialog () { + showApproveConfirmDialog() { this.showingApproveConfirmDialog = true }, - hideApproveConfirmDialog () { + hideApproveConfirmDialog() { this.showingApproveConfirmDialog = false }, - showDenyConfirmDialog () { + showDenyConfirmDialog() { this.showingDenyConfirmDialog = true }, - hideDenyConfirmDialog () { + hideDenyConfirmDialog() { this.showingDenyConfirmDialog = false }, - approveUser () { + approveUser() { if (this.shouldConfirmApprove) { this.showApproveConfirmDialog() } else { this.doApprove() } }, - doApprove () { - this.$emit('interacted') + doApprove() { this.$store.state.api.backendInteractor.approveUser({ id: this.user.id }) this.$store.dispatch('removeFollowRequest', this.user) - this.$store.dispatch('markSingleNotificationAsSeen', { id: this.notification.id }) + this.$store.dispatch('markSingleNotificationAsSeen', { + id: this.notification.id, + }) this.$store.dispatch('updateNotification', { id: this.notification.id, - updater: notification => { + updater: (notification) => { notification.type = 'follow' - } + }, }) this.hideApproveConfirmDialog() }, - denyUser () { + denyUser() { if (this.shouldConfirmDeny) { this.showDenyConfirmDialog() } else { this.doDeny() } }, - doDeny () { - this.$emit('interacted') - this.$store.state.api.backendInteractor.denyUser({ id: this.user.id }) + doDeny() { + this.$store.state.api.backendInteractor + .denyUser({ id: this.user.id }) .then(() => { - this.$store.dispatch('dismissNotificationLocal', { id: this.notification.id }) + this.$store.dispatch('dismissNotificationLocal', { + id: this.notification.id, + }) this.$store.dispatch('removeFollowRequest', this.user) }) this.hideDenyConfirmDialog() - } + }, }, computed: { - userClass () { + userClass() { return highlightClass(this.notification.from_profile) }, - userStyle () { - const highlight = this.$store.getters.mergedConfig.highlight - const user = this.notification.from_profile - return highlightStyle(highlight[user.screen_name]) + userStyle() { + const user = this.notification.from_profile.screen_name + return highlightStyle(useUserHighlightStore().get(user)) }, - user () { + expandable() { + return new Set(['like', 'pleroma:emoji_reaction', 'repeat', 'poll']).has( + this.notification.type, + ) + }, + user() { return this.$store.getters.findUser(this.notification.from_profile.id) }, - userProfileLink () { + userProfileLink() { return this.generateUserProfileLink(this.user) }, - targetUser () { + targetUser() { return this.$store.getters.findUser(this.notification.target.id) }, - targetUserProfileLink () { + targetUserProfileLink() { return this.generateUserProfileLink(this.targetUser) }, - needMute () { + needMute() { return this.$store.getters.relationship(this.user.id).muting }, - isStatusNotification () { + isStatusNotification() { return isStatusNotification(this.notification.type) }, - mergedConfig () { - return this.$store.getters.mergedConfig + mergedConfig() { + return useMergedConfigStore().mergedConfig }, - shouldConfirmApprove () { + allowNonSquareEmoji() { + return this.mergedConfig.nonSquareEmoji + }, + shouldConfirmApprove() { return this.mergedConfig.modalOnApproveFollow }, - shouldConfirmDeny () { + shouldConfirmDeny() { return this.mergedConfig.modalOnDenyFollow }, ...mapState({ - currentUser: state => state.users.currentUser - }) - } + currentUser: (state) => state.users.currentUser, + }), + }, } export default Notification diff --git a/src/components/notification/notification.scss b/src/components/notification/notification.scss index e8895ce59..934d3e58d 100644 --- a/src/components/notification/notification.scss +++ b/src/components/notification/notification.scss @@ -1,10 +1,15 @@ // TODO Copypaste from Status, should unify it somehow + .Notification { border-bottom: 1px solid; border-color: var(--border); overflow-wrap: break-word; text-wrap: pretty; + .status-content { + cursor: pointer; + } + &.Status { /* stylelint-disable-next-line declaration-no-important */ background-color: transparent !important; diff --git a/src/components/notification/notification.style.js b/src/components/notification/notification.style.js index c6d317d1c..49e28cf2e 100644 --- a/src/components/notification/notification.style.js +++ b/src/components/notification/notification.style.js @@ -6,13 +6,8 @@ export default { 'Link', 'Icon', 'Border', - 'Button', - 'ButtonUnstyled', - 'RichContent', - 'Input', 'Avatar', - 'Attachment', - 'PollGraph' + 'PollGraph', ], - defaultRules: [] + defaultRules: [], } diff --git a/src/components/notification/notification.vue b/src/components/notification/notification.vue index 0930e0990..fbe45eceb 100644 --- a/src/components/notification/notification.vue +++ b/src/components/notification/notification.vue @@ -1,17 +1,26 @@ @@ -61,18 +59,14 @@ --> + + diff --git a/src/components/settings_modal/admin_tabs/instance_tab.js b/src/components/settings_modal/admin_tabs/instance_tab.js index b07bafe8f..67d04c303 100644 --- a/src/components/settings_modal/admin_tabs/instance_tab.js +++ b/src/components/settings_modal/admin_tabs/instance_tab.js @@ -1,25 +1,22 @@ +import { get } from 'lodash' + +import AttachmentSetting from '../helpers/attachment_setting.vue' import BooleanSetting from '../helpers/boolean_setting.vue' import ChoiceSetting from '../helpers/choice_setting.vue' -import IntegerSetting from '../helpers/integer_setting.vue' -import StringSetting from '../helpers/string_setting.vue' +import ColorSetting from '../helpers/color_setting.vue' import GroupSetting from '../helpers/group_setting.vue' -import AttachmentSetting from '../helpers/attachment_setting.vue' - +import IntegerSetting from '../helpers/integer_setting.vue' +import ListSetting from '../helpers/list_setting.vue' +import MapSetting from '../helpers/map_setting.vue' +import PWAManifestIconsSetting from '../helpers/pwa_manifest_icons_setting.vue' import SharedComputedObject from '../helpers/shared_computed_object.js' -import { library } from '@fortawesome/fontawesome-svg-core' -import { - faGlobe -} from '@fortawesome/free-solid-svg-icons' - -library.add( - faGlobe -) +import StringSetting from '../helpers/string_setting.vue' const InstanceTab = { - provide () { + provide() { return { defaultDraftMode: true, - defaultSource: 'admin' + defaultSource: 'admin', } }, components: { @@ -27,12 +24,45 @@ const InstanceTab = { ChoiceSetting, IntegerSetting, StringSetting, + ColorSetting, AttachmentSetting, - GroupSetting + ListSetting, + PWAManifestIconsSetting, + MapSetting, + GroupSetting, }, computed: { - ...SharedComputedObject() - } + ...SharedComputedObject(), + providersOptions() { + const desc = get(this.$store.state.adminSettings.descriptions, [ + ':pleroma', + 'Pleroma.Web.Metadata', + ':providers', + ]) + return new Set( + desc.suggestions.map((option) => ({ + label: option.replace('Pleroma.Web.Metadata.Providers.', ''), + value: option, + })), + ) + }, + limitLocalContentOptions() { + const desc = get(this.$store.state.adminSettings.descriptions, [ + ':pleroma', + ':instance', + ':limit_to_local_content', + ]) + return new Set( + desc.suggestions.map((option) => ({ + label: + option !== 'false' + ? this.$t('admin_dash.instance.' + option) + : this.$t('general.no'), + value: option, + })), + ) + }, + }, } export default InstanceTab diff --git a/src/components/settings_modal/admin_tabs/instance_tab.vue b/src/components/settings_modal/admin_tabs/instance_tab.vue index 32e8df259..144958fa4 100644 --- a/src/components/settings_modal/admin_tabs/instance_tab.vue +++ b/src/components/settings_modal/admin_tabs/instance_tab.vue @@ -1,17 +1,13 @@