diff --git a/.browserslistrc b/.browserslistrc new file mode 100644 index 000000000..df2d47160 --- /dev/null +++ b/.browserslistrc @@ -0,0 +1,7 @@ +>0.2% +not op_mini all +Safari > 15 +Firefox >= 115 +Firefox ESR +Android > 4 +not dead 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/.eslintignore b/.eslintignore deleted file mode 100644 index 34af3774f..000000000 --- a/.eslintignore +++ /dev/null @@ -1,2 +0,0 @@ -build/*.js -config/*.js diff --git a/.eslintrc.js b/.eslintrc.js deleted file mode 100644 index 361cff5f2..000000000 --- a/.eslintrc.js +++ /dev/null @@ -1,27 +0,0 @@ -module.exports = { - root: true, - parserOptions: { - parser: '@babel/eslint-parser', - sourceType: 'module' - }, - // https://github.com/feross/standard/blob/master/RULES.md#javascript-standard-style - extends: [ - 'standard', - 'plugin:vue/recommended' - ], - // required to lint *.vue files - plugins: [ - 'vue' - ], - // add your custom rules here - rules: { - // allow paren-less arrow functions - 'arrow-parens': 0, - // allow async-await - 'generator-star-spacing': 0, - // allow debugger during development - 'no-debugger': process.env.NODE_ENV === 'production' ? 2 : 0, - 'vue/require-prop-types': 0, - 'vue/multi-word-component-names': 0 - } -} 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/.gitattributes b/.gitattributes index c5b9ea10e..1bea4dc8f 100644 --- a/.gitattributes +++ b/.gitattributes @@ -1 +1 @@ -/build/webpack.prod.conf.js export-subst +/build/commit_hash.js export-subst diff --git a/.gitignore b/.gitignore index 0d5befd28..c4a96ee1e 100644 --- a/.gitignore +++ b/.gitignore @@ -4,8 +4,12 @@ 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 -static/emoji.json +src/assets/emoji.json logs/ +__screenshots__/ diff --git a/.gitlab-ci.yml b/.gitlab-ci.yml index 25e499c4c..247218091 100644 --- a/.gitlab-ci.yml +++ b/.gitlab-ci.yml @@ -1,7 +1,7 @@ # This file is a template, and might need editing before it works on your project. # Official framework image. Look for the different tagged releases at: # https://hub.docker.com/r/library/node/tags/ -image: node:16 +image: node:20 stages: - check-changelog @@ -34,33 +34,180 @@ check-changelog: - apk add git - sh ./tools/check-changelog -lint: +lint-eslint: stage: lint script: - yarn - - npm run lint - - npm run 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 tags: - amd64 + - himem variables: APT_CACHE_DIR: apt-cache script: - mkdir -pv $APT_CACHE_DIR && apt-get -qq update - - apt install firefox-esr -y --no-install-recommends - - firefox --version - yarn - - yarn unit + - yarn playwright install firefox + - yarn playwright install-deps + - yarn unit-ci + artifacts: + # When the test fails, upload screenshots for better context on why it fails + paths: + - 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: - amd64 + - himem script: - yarn - - npm run build + - yarn build artifacts: paths: - dist/ 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/.node-version b/.node-version index 5397c87fa..5bd681170 100644 --- a/.node-version +++ b/.node-version @@ -1 +1 @@ -16.18.1 +20.19.0 diff --git a/.stylelintrc.json b/.stylelintrc.json index d6689cc01..afdfd5f5b 100644 --- a/.stylelintrc.json +++ b/.stylelintrc.json @@ -1,6 +1,5 @@ { "extends": [ - "stylelint-rscss/config", "stylelint-config-standard", "stylelint-config-recommended-scss", "stylelint-config-html", @@ -8,20 +7,13 @@ ], "rules": { "declaration-no-important": true, - "rscss/no-descendant-combinator": false, - "rscss/class-format": [ - false, - { - "component": "pascal-case", - "variant": "^-[a-z]\\w+", - "element": "^[a-z]\\w+" - } - ], "selector-class-pattern": null, "import-notation": null, "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..af0bb98e3 --- /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:20-alpine + commands: + - apk add --no-cache zip git + - yarn --frozen-lockfile + - yarn build + - if [ "${CI_PIPELINE_EVENT}" = "push" ] || [ "${CI_PIPELINE_EVENT}" = "manual" ]; then zip -9qr ${CI_REPO_DEFAULT_BRANCH}.zip dist/; fi + + upload-artifacts: + image: docker.io/woodpeckercommunity/plugin-gitea-package:0.5.0 + when: + - event: push + branch: ${CI_REPO_DEFAULT_BRANCH} + - event: manual + branch: ${CI_REPO_DEFAULT_BRANCH} + settings: + user: + from_secret: pleroma-ci-user + password: + from_secret: pleroma-ci-password + update: true + owner: 'pleroma' + package_name: 'pleroma-fe-builds' + package_version: ${CI_REPO_DEFAULT_BRANCH} + file_source: ./${CI_REPO_DEFAULT_BRANCH}.zip + file_name: latest.zip diff --git a/.woodpecker/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..257887338 --- /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:20-alpine + commands: + - yarn --frozen-lockfile + + eslint: + image: *node-image + depends_on: install-depends + commands: + - yarn ci-eslint + + biome: + image: *node-image + depends_on: install-depends + commands: + - yarn ci-biome + + stylelint: + image: *node-image + depends_on: install-depends + commands: + - yarn ci-stylelint 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 9844319e3..1eb5a9cb4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,6 +3,144 @@ 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 +- BREAKING: static/emoji.json is replaced with a properly hashed path under static/js in the production build, meaning server admins cannot provide their own set of unicode emojis by overriding this file (custom (image-based) emojis not affected) +- Speed up initial boot. +- Updated our build system to support browsers: + Safari >= 15 + Firefox >= 115 + Android > 4 + no Opera Mini support + no IE support + no "dead" (unmaintained) browsers support + +This does not guarantee that browsers will or will not work. + +- Use /api/v1/accounts/:id/follow for account subscriptions instead of the deprecated routes +- Modal layout for mobile has new layout to make it easy to use +- Better display of mute reason on posts +- Simplify the OAuth client_name to 'PleromaFE' +- Partially migrated from vuex to pinia +- Authenticate and subscribe to streaming after connection +- Tabs now have indentation for better visibility of which tab is currently active +- Upgraded Vue to version 3.5 + +### Added +- Support bookmark folders +- Some new default color schemes +- Added support for fetching /{resource}.custom.ext to allow adding instance-specific themes without altering sourcetree +- Post actions customization +- 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. +- 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 +- Make UserLink wrappable + +### Fixed +- Fixed occasional overflows in emoji picker and made header scrollable +- Updated shadow editor, hopefully fixed long-standing bugs, added ability to specify shadow's name. +- Checkbox vertical alignment +- Check for canvas extract permission when initializing favicon service +- Fix some of the color manipulation functions +- Fix draft saving when auto-save is off +- Switch from class hack to normalButton attribute for emoji count popover +- Fix emoji inconsistencies in notifications, +- Fix some emoji not scaling with interface +- Make sure hover style is also applied to :focus-visible +- Improved ToS and registration +- Fix small markup inconsistencies +- Fixed modals buttons overflow +- Fix whitespaces for multiple status mute reasons, display bot status reason +- Create an OAuth app only when needed +- Fix CSS compatibility issues in style_setter.js for older browsers like Palemoon +- Proper sticky header for conversations on user page +- Add text label for more actions button in post status form +- Reply-or-quote buttons now take less space +- Allow repeats of own posts with private scopes +- Bookmarks visible again on mobile +- Remove focusability on hidden popover in subject input +- Show only month and day instead of weird "day, hour" format. + +### Removed +- BREAKING: drop support for browsers that do not support ` diff --git a/package.json b/package.json index 45b046603..c51d98b81 100644 --- a/package.json +++ b/package.json @@ -1,136 +1,129 @@ { "name": "pleroma_fe", - "version": "2.7.1", + "version": "2.10.1", "description": "Pleroma frontend, the default frontend of Pleroma social network server", - "author": "Pleroma contributors ", + "author": "Pleroma contributors ", "private": false, "scripts": { - "dev": "node build/dev-server.js", - "build": "node build/build.js", - "unit": "karma start test/unit/karma.conf.js --single-run", - "unit:watch": "karma start test/unit/karma.conf.js --single-run=false", - "e2e": "node test/e2e/runner.js", - "test": "npm run unit && npm run e2e", - "stylelint": "npx stylelint '**/*.scss' '**/*.vue'", - "lint": "eslint --ext .js,.vue src test/unit/specs test/e2e/specs", - "lint-fix": "eslint --fix --ext .js,.vue src test/unit/specs test/e2e/specs" + "dev": "node build/update-emoji.js && vite dev", + "build": "node build/update-emoji.js && vite build", + "unit": "node build/update-emoji.js && vitest --run", + "unit-ci": "node build/update-emoji.js && vitest --run --browser.headless", + "unit:watch": "node build/update-emoji.js && vitest", + "e2e:pw": "playwright test --config test/e2e-playwright/playwright.config.mjs", + "e2e": "sh ./tools/e2e/run.sh", + "test": "yarn run unit && yarn run e2e", + "ci-biome": "yarn exec biome check", + "ci-eslint": "yarn exec eslint", + "ci-stylelint": "yarn exec stylelint '**/*.scss' '**/*.vue'", + "lint": "yarn ci-biome; yarn ci-eslint; yarn ci-stylelint", + "lint-fix": "yarn exec eslint -- --fix; yarn exec stylelint '**/*.scss' '**/*.vue' --fix; biome check --write" }, "dependencies": { - "@babel/runtime": "7.21.5", + "@babel/runtime": "7.28.4", "@chenfengyuan/vue-qrcode": "2.0.0", - "@fortawesome/fontawesome-svg-core": "6.4.0", - "@fortawesome/free-regular-svg-icons": "6.4.0", - "@fortawesome/free-solid-svg-icons": "6.4.0", - "@fortawesome/vue-fontawesome": "3.0.3", - "@kazvmoe-infra/pinch-zoom-element": "1.2.0", + "@fortawesome/fontawesome-svg-core": "7.1.0", + "@fortawesome/free-regular-svg-icons": "7.1.0", + "@fortawesome/free-solid-svg-icons": "7.1.0", + "@fortawesome/vue-fontawesome": "3.1.2", + "@kazvmoe-infra/pinch-zoom-element": "1.3.0", "@kazvmoe-infra/unicode-emoji-json": "0.4.0", - "@ruffle-rs/ruffle": "0.1.0-nightly.2024.3.17", + "@ruffle-rs/ruffle": "0.1.0-nightly.2025.6.22", "@vuelidate/core": "2.0.3", "@vuelidate/validators": "2.0.4", + "@web3-storage/parse-link-header": "^3.1.0", "body-scroll-lock": "3.1.5", "chromatism": "3.0.0", "click-outside-vue3": "4.0.1", - "cropperjs": "1.5.13", + "cropperjs": "2.0.1", "escape-html": "1.0.3", + "globals": "^16.0.0", "hash-sum": "^2.0.0", "js-cookie": "3.0.5", "localforage": "1.10.0", "parse-link-header": "2.0.0", - "phoenix": "1.7.7", - "punycode.js": "2.3.0", - "qrcode": "1.5.3", + "phoenix": "1.8.1", + "pinia": "^3.0.4", + "punycode.js": "2.3.1", + "qrcode": "1.5.4", "querystring-es3": "0.2.1", - "url": "0.11.0", + "url": "0.11.4", "utf8": "3.0.0", - "vue": "3.2.45", - "vue-i18n": "9.2.2", - "vue-router": "4.1.6", - "vue-template-compiler": "2.7.14", + "uuid": "11.1.0", + "vue": "3.5.22", + "vue-i18n": "11", + "vue-router": "4.6.4", "vue-virtual-scroller": "^2.0.0-beta.7", "vuex": "4.1.0" }, "devDependencies": { - "@babel/core": "7.21.8", - "@babel/eslint-parser": "7.21.8", - "@babel/plugin-transform-runtime": "7.21.4", - "@babel/preset-env": "7.21.5", - "@babel/register": "7.21.0", - "@intlify/vue-i18n-loader": "5.0.1", + "@babel/core": "7.28.5", + "@babel/eslint-parser": "7.28.5", + "@babel/plugin-transform-runtime": "7.28.5", + "@babel/preset-env": "7.28.5", + "@babel/register": "7.28.3", + "@biomejs/biome": "2.3.11", + "@pinia/testing": "1.0.3", "@ungap/event-target": "0.2.4", + "@vitejs/devtools": "^0.3.1", + "@vitejs/plugin-vue": "^6.0.7", + "@vitejs/plugin-vue-jsx": "^5.1.5", + "@vitest/browser": "^3.0.7", + "@vitest/ui": "^3.0.7", "@vue/babel-helper-vue-jsx-merge-props": "1.4.0", - "@vue/babel-plugin-jsx": "1.2.1", - "@vue/compiler-sfc": "3.2.45", - "@vue/test-utils": "2.2.8", - "autoprefixer": "10.4.19", - "babel-loader": "9.1.3", + "@vue/babel-plugin-jsx": "1.5.0", + "@vue/compiler-sfc": "3.5.22", + "@vue/test-utils": "2.4.6", + "autoprefixer": "10.4.21", "babel-plugin-lodash": "3.3.4", - "chai": "4.3.7", - "chalk": "1.1.3", - "chromedriver": "108.0.0", + "chai": "5.3.3", + "chalk": "5.6.2", + "chromedriver": "135.0.4", "connect-history-api-fallback": "2.0.0", - "copy-webpack-plugin": "11.0.0", - "cross-spawn": "7.0.3", - "css-loader": "6.10.0", - "css-minimizer-webpack-plugin": "4.2.2", + "cross-spawn": "7.0.6", "custom-event-polyfill": "1.0.7", - "eslint": "8.33.0", - "eslint-config-standard": "17.0.0", + "eslint": "9.39.2", + "eslint-config-standard": "17.1.0", "eslint-formatter-friendly": "7.0.0", - "eslint-plugin-import": "2.27.5", - "eslint-plugin-n": "15.6.1", - "eslint-plugin-promise": "6.1.1", - "eslint-plugin-vue": "9.9.0", - "eslint-webpack-plugin": "3.2.0", + "eslint-plugin-import": "2.32.0", + "eslint-plugin-n": "17.23.1", + "eslint-plugin-promise": "7.2.1", + "eslint-plugin-vue": "10.6.2", "eventsource-polyfill": "0.9.6", - "express": "4.18.2", - "function-bind": "1.1.1", - "html-webpack-plugin": "5.5.1", - "http-proxy-middleware": "2.0.6", - "iso-639-1": "2.1.15", - "json-loader": "0.5.7", - "karma": "6.4.2", - "karma-coverage": "2.2.0", - "karma-firefox-launcher": "2.1.2", - "karma-mocha": "2.0.1", - "karma-mocha-reporter": "2.2.5", - "karma-sinon-chai": "2.0.2", - "karma-sourcemap-loader": "0.3.8", - "karma-spec-reporter": "0.0.36", - "karma-webpack": "5.0.0", + "express": "5.1.0", + "function-bind": "1.1.2", + "http-proxy-middleware": "3.0.5", + "iso-639-1": "3.1.5", "lodash": "4.17.21", - "mini-css-extract-plugin": "2.7.6", - "mocha": "10.2.0", - "nightwatch": "2.6.25", - "opn": "5.5.0", - "ora": "0.4.1", - "postcss": "8.4.23", + "msw": "2.10.5", + "nightwatch": "3.12.2", + "oxc": "^1.0.1", + "playwright": "1.57.0", + "postcss": "8.5.6", "postcss-html": "^1.5.0", - "postcss-loader": "7.0.2", "postcss-scss": "^4.0.6", - "sass": "1.60.0", - "sass-loader": "13.2.2", - "selenium-server": "2.53.1", - "semver": "7.3.8", - "serviceworker-webpack5-plugin": "2.0.0", - "shelljs": "0.8.5", - "sinon": "15.0.4", - "sinon-chai": "3.7.0", - "stylelint": "14.16.1", + "sass-embedded": "^1.100.0", + "selenium-server": "3.141.59", + "semver": "7.7.3", + "serve-static": "2.2.0", + "shelljs": "0.10.0", + "sinon": "20.0.0", + "sinon-chai": "4.0.1", + "stylelint": "16.25.0", "stylelint-config-html": "^1.1.0", - "stylelint-config-recommended-scss": "^8.0.0", - "stylelint-config-recommended-vue": "^1.4.0", - "stylelint-config-standard": "29.0.0", - "stylelint-rscss": "0.4.0", - "stylelint-webpack-plugin": "^3.3.0", - "vue-loader": "17.0.1", - "vue-style-loader": "4.1.3", - "webpack": "5.75.0", - "webpack-dev-middleware": "3.7.3", - "webpack-hot-middleware": "2.25.3", - "webpack-merge": "0.20.0" + "stylelint-config-recommended": "^16.0.0", + "stylelint-config-recommended-scss": "^14.0.0", + "stylelint-config-recommended-vue": "^1.6.0", + "stylelint-config-standard": "38.0.0", + "vite": "^8.0.0", + "vite-plugin-eslint2": "^5.1.0", + "vite-plugin-stylelint": "^6.1.0", + "vitest": "^3.0.7", + "vue-eslint-parser": "10.2.0" }, + "type": "module", "engines": { - "node": ">= 16.0.0", - "npm": ">= 3.0.0" - } + "node": ">= 16.0.0" + }, + "packageManager": "yarn@1.22.22+sha512.a6b2f7906b721bba3d67d4aff083df04dad64c399707841b7acf00f6b133b7ac24255f2652fa22ae3534329dc6180534e98d17432037ff6fd140556e2bb3137e" } diff --git a/postcss.config.js b/postcss.config.js index 88752c6cb..b7fc12838 100644 --- a/postcss.config.js +++ b/postcss.config.js @@ -1,5 +1,5 @@ -module.exports = { - plugins: [ - require('autoprefixer') - ] +import autoprefixer from 'autoprefixer' + +export default { + plugins: [autoprefixer], } diff --git a/public/static/.gitignore b/public/static/.gitignore new file mode 100644 index 000000000..3332292a1 --- /dev/null +++ b/public/static/.gitignore @@ -0,0 +1 @@ +*.custom.* diff --git a/public/static/.gitkeep b/public/static/.gitkeep new file mode 100644 index 000000000..e69de29bb diff --git a/public/static/aurora_borealis.jpg b/public/static/aurora_borealis.jpg new file mode 100644 index 000000000..b6a0daf91 Binary files /dev/null and b/public/static/aurora_borealis.jpg differ diff --git a/static/config.json b/public/static/config.json similarity index 72% rename from static/config.json rename to public/static/config.json index 25a2541cf..8a78284a3 100644 --- a/static/config.json +++ b/public/static/config.json @@ -1,6 +1,6 @@ { - "alwaysShowSubjectInput": false, - "background": "/static/sigsegv_s.png", + "alwaysShowSubjectInput": true, + "background": "/static/aurora_borealis.jpg", "collapseMessageWithSubject": false, "greentext": false, "hideFilteredStatuses": false, @@ -19,13 +19,13 @@ "postContentType": "text/plain", "redirectRootLogin": "/main/friends", "redirectRootNoLogin": "/main/all", - "scopeCopy": false, + "scopeCopy": true, "showFeaturesPanel": true, "showInstanceSpecificPanel": false, "sidebarRight": false, - "subjectLineBehavior": "noop", - "theme": "sigsegv", - "webPushNotifications": false, - "greentext": true, - "mentionLinkShowYous": true + "subjectLineBehavior": "email", + "theme": null, + "style": null, + "palette": null, + "webPushNotifications": false } diff --git a/static/logo.svg b/public/static/logo.svg similarity index 100% rename from static/logo.svg rename to public/static/logo.svg diff --git a/public/static/palettes/index.json b/public/static/palettes/index.json new file mode 100644 index 000000000..3c4e37e44 --- /dev/null +++ b/public/static/palettes/index.json @@ -0,0 +1,135 @@ +{ + "pleroma-dark": [ + "Pleroma Dark", + "#121a24", + "#182230", + "#b9b9ba", + "#d8a070", + "#d31014", + "#0fa00f", + "#0095ff", + "#ffa500" + ], + "pleroma-light": [ + "Pleroma Light", + "#f2f4f6", + "#dbe0e8", + "#304055", + "#f86f0f", + "#d31014", + "#0fa00f", + "#0095ff", + "#ffa500" + ], + "classic-dark": { + "name": "Classic Dark", + "bg": "#161c20", + "fg": "#282e32", + "text": "#b9b9b9", + "link": "#baaa9c", + "cRed": "#d31014", + "cGreen": "#0fa00f", + "cBlue": "#0095ff", + "cOrange": "#ffa500" + }, + "bird": [ + "Bird", + "#f8fafd", + "#e6ecf0", + "#14171a", + "#0084b8", + "#e0245e", + "#17bf63", + "#1b95e0", + "#fab81e" + ], + "pleroma-amoled": [ + "Pleroma Dark AMOLED", + "#000000", + "#111111", + "#b0b0b1", + "#d8a070", + "#aa0000", + "#0fa00f", + "#0095ff", + "#d59500" + ], + "tomorrow-night": { + "name": "Tomorrow Night", + "bg": "#1d1f21", + "fg": "#373b41", + "link": "#81a2be", + "text": "#c5c8c6", + "cRed": "#cc6666", + "cBlue": "#8abeb7", + "cGreen": "#b5bd68", + "cOrange": "#de935f" + }, + "dracula": { + "name": "Dracula", + "bg": "#282A36", + "fg": "#44475A", + "link": "#BC92F9", + "text": "#f8f8f2", + "cRed": "#FF5555", + "cBlue": "#8BE9FD", + "cGreen": "#50FA7B", + "cOrange": "#FFB86C" + }, + "ir-black": [ + "Ir Black", + "#000000", + "#242422", + "#b5b3aa", + "#ff6c60", + "#FF6C60", + "#A8FF60", + "#96CBFE", + "#FFFFB6" + ], + "monokai": [ + "Monokai", + "#272822", + "#383830", + "#f8f8f2", + "#f92672", + "#F92672", + "#a6e22e", + "#66d9ef", + "#f4bf75" + ], + "purple-stream": { + "name": "Purple stream", + "bg": "#17171A", + "fg": "#450F92", + "link": "#8769B4", + "text": "#C0C0C5", + "cRed": "#EB0300", + "cBlue": "#4656FF", + "cGreen": "#B0E020", + "cOrange": "#FF9046" + }, + "feud": { + "name": "Feud", + "bg": "#323337", + "fg": "#1D1E21", + "link": "#18A0E3", + "accent": "#6671E2", + "text": "#DBDDE0", + "cRed": "#E05053", + "cBlue": "#6671E2", + "cGreen": "#3A8D5D", + "cOrange": "#DCAA45" + }, + "constabulary": { + "name": "Constabulary", + "bg": "#FFFFFF", + "fg": "#3B5897", + "link": "#28487C", + "text": "#333333", + "cRed": "#FA3C4C", + "cBlue": "#0083FF", + "cGreen": "#44BDC6", + "cOrange": "#FFC200" + } +} diff --git a/static/pleromatan_apology.png b/public/static/pleromatan_apology.png similarity index 100% rename from static/pleromatan_apology.png rename to public/static/pleromatan_apology.png diff --git a/static/pleromatan_apology_fox.png b/public/static/pleromatan_apology_fox.png similarity index 100% rename from static/pleromatan_apology_fox.png rename to public/static/pleromatan_apology_fox.png diff --git a/public/static/pleromatan_apology_fox_small.webp b/public/static/pleromatan_apology_fox_small.webp new file mode 100644 index 000000000..eacbf3cbf Binary files /dev/null and b/public/static/pleromatan_apology_fox_small.webp differ diff --git a/public/static/pleromatan_apology_small.webp b/public/static/pleromatan_apology_small.webp new file mode 100644 index 000000000..e27e06d06 Binary files /dev/null and b/public/static/pleromatan_apology_small.webp differ diff --git a/static/pleromatan_orz.png b/public/static/pleromatan_orz.png similarity index 100% rename from static/pleromatan_orz.png rename to public/static/pleromatan_orz.png diff --git a/static/pleromatan_orz_fox.png b/public/static/pleromatan_orz_fox.png similarity index 100% rename from static/pleromatan_orz_fox.png rename to public/static/pleromatan_orz_fox.png diff --git a/public/static/splash.css b/public/static/splash.css new file mode 100644 index 000000000..caf57dacb --- /dev/null +++ b/public/static/splash.css @@ -0,0 +1,133 @@ +body { + margin: 0; + padding: 0; +} + +#splash { + --scale: 1; + + width: 100vw; + height: 100vh; + display: grid; + grid-template-rows: auto; + grid-template-columns: auto; + align-content: center; + place-items: center; + flex-direction: column; + background: #0f161e; + font-family: sans-serif; + color: #b9b9ba; + position: absolute; + z-index: 9999; + font-size: calc(1vw + 1vh + 1vmin); + opacity: 1; + transition: opacity 500ms ease-out 2s; +} + +#splash.hidden, +#splash.initial-hidden { + opacity: 0; +} + +#splash-credit { + position: absolute; + font-size: 1em; + bottom: 1em; + right: 1em; +} + +#splash-container { + align-items: center; +} + +#mascot-container { + display: flex; + align-items: flex-end; + justify-content: center; + perspective: 60em; + perspective-origin: 0 -15em; + transform-style: preserve-3d; +} + +#mascot { + width: calc(10em * var(--scale)); + height: calc(10em * var(--scale)); + object-fit: contain; + object-position: bottom; + transform: translateZ(-2em); +} + +#throbber { + display: grid; + width: calc(5em * 0.5 * var(--scale)); + height: calc(8em * 0.5 * var(--scale)); + margin-left: 4.1em; + z-index: 2; + grid-template-rows: repeat(8, 1fr); + grid-template-columns: repeat(5, 1fr); + grid-template-areas: + "P P . L L" + "P P . L L" + "P P . L L" + "P P . L L" + "P P . . ." + "P P . . ." + "P P . E E" + "P P . E E"; + + --logoChunkSize: calc(2em * 0.5 * var(--scale)); +} + +.chunk { + background-color: #e2b188; + box-shadow: 0.01em 0.01em 0.1em 0 #e2b188; +} + +#chunk-P { + grid-area: P; + border-top-left-radius: calc(var(--logoChunkSize) / 2); +} + +#chunk-L { + grid-area: L; + border-bottom-right-radius: calc(var(--logoChunkSize) / 2); +} + +#chunk-E { + grid-area: E; + border-bottom-right-radius: calc(var(--logoChunkSize) / 2); +} + +#status { + margin-top: 1em; + line-height: 2; + width: 100%; + text-align: center; +} + +#statusError { + display: none; + margin-top: 1em; + font-size: calc(1vw + 1vh + 1vmin); + line-height: 2; + width: 100%; + text-align: center; +} + +#statusStack { + display: none; + margin-top: 1em; + font-size: calc((1vw + 1vh + 1vmin) / 2.5); + width: calc(100vw - 5em); + padding: 1em; + text-overflow: ellipsis; + overflow-x: hidden; + text-align: left; + line-height: 2; +} + +@media (prefers-reduced-motion) { + #throbber { + animation: none !important; + } +} diff --git a/public/static/styles.json b/public/static/styles.json new file mode 100644 index 000000000..2f836a47a --- /dev/null +++ b/public/static/styles.json @@ -0,0 +1,11 @@ +{ + "pleroma-dark": "/static/themes/pleroma-dark.json", + "pleroma-light": "/static/themes/pleroma-light.json", + "redmond-xx": "/static/themes/redmond-xx.json", + "redmond-xx-se": "/static/themes/redmond-xx-se.json", + "redmond-xxi": "/static/themes/redmond-xxi.json", + "breezy-dark": "/static/themes/breezy-dark.json", + "breezy-light": "/static/themes/breezy-light.json", + "mammal": "/static/themes/mammal.json", + "paper": "/static/themes/paper.json" +} diff --git a/public/static/styles/Breezy DX.iss b/public/static/styles/Breezy DX.iss new file mode 100644 index 000000000..69cc3befe --- /dev/null +++ b/public/static/styles/Breezy DX.iss @@ -0,0 +1,102 @@ +@meta { + name: Breezy DX; + author: HJ; + license: WTFPL; + website: ebin.club; +} + +@palette.Dark { + bg: #292C32; + fg: #292C32; + text: #ffffff; + link: #1CA4F3; + accent: #1CA4F3; + cRed: #f41a51; + cBlue: #1CA4F3; + cGreen: #1af46e; + cOrange: #f4af1a; +} + +@palette.Light { + bg: #EFF0F2; + fg: #EFF0F2; + text: #1B1F22; + underlay: #5d6086; + accent: #1CA4F3; + cBlue: #1CA4F3; + cRed: #f41a51; + cGreen: #0b6a30; + cOrange: #f4af1a; + border: #d8e6f9; + link: #1CA4F3; +} + +@palette.Panda { + bg: #EFF0F2; + fg: #292C32; + text: #1B1F22; + link: #1CA4F3; + accent: #1CA4F3; + cRed: #f41a51; + cBlue: #1CA4F3; + cGreen: #0b6a30; + cOrange: #f4af1a; +} + +Root { + --badgeNotification: color | --cRed; + --buttonDefaultHoverGlow: shadow | inset 0 0 0 1 --accent / 1; + --buttonDefaultFocusGlow: shadow | inset 0 0 0 1 --accent / 1; + --buttonDefaultShadow: shadow | inset 0 0 0 1 --text / 0.35, 0 5 5 -5 #000000 / 0.35; + --buttonDefaultBevel: shadow | inset 0 14 14 -14 #FFFFFF / 0.1; + --buttonPressedBevel: shadow | inset 0 -20 20 -20 #000000 / 0.05; + --defaultInputBevel: shadow | inset 0 0 0 1 --text / 0.35; + --defaultInputHoverGlow: shadow | 0 0 0 1 --accent / 1; + --defaultInputFocusGlow: shadow | 0 0 0 1 --link / 1; +} + +Button { + background: --parent; +} + +Button:disabled { + shadow: --buttonDefaultBevel, --buttonDefaultShadow +} + +Button:hover { + background: --inheritedBackground; + shadow: --buttonDefaultHoverGlow, --buttonDefaultBevel, --buttonDefaultShadow +} + +Button:toggled { + background: $blend(--inheritedBackground 0.3 --accent) +} + +Button:pressed { + background: $blend(--inheritedBackground 0.8 --accent) +} + +Button:pressed:toggled { + background: $blend(--inheritedBackground 0.2 --accent) +} + +Button:toggled:hover { + background: $blend(--inheritedBackground 0.3 --accent) +} + +Input { + shadow: --defaultInputBevel; + background: $mod(--bg -10); +} + +PanelHeader { + shadow: inset 0 30 30 -30 #ffffff / 0.25 +} + +Tab:hover { + shadow: --buttonDefaultHoverGlow, --buttonDefaultBevel, --buttonDefaultShadow +} + +Tab { + background: --bg; +} diff --git a/public/static/styles/Redmond DX.iss b/public/static/styles/Redmond DX.iss new file mode 100644 index 000000000..919be056b --- /dev/null +++ b/public/static/styles/Redmond DX.iss @@ -0,0 +1,201 @@ +@meta { + name: Redmond DX; + author: HJ; + license: WTFPL; + website: ebin.club; +} + +@palette.Modern { + bg: #D3CFC7; + fg: #092369; + text: #000000; + link: #0000FF; + accent: #A5C9F0; + cRed: #FF3000; + cBlue: #009EFF; + cGreen: #309E00; + cOrange: #FFCE00; +} + +@palette.Classic { + bg: #BFBFBF; + fg: #000180; + text: #000000; + link: #0000FF; + accent: #A5C9F0; + cRed: #FF0000; + cBlue: #2E2ECE; + cGreen: #007E00; + cOrange: #CE8F5F; +} + +@palette.Vapor { + bg: #F0ADCD; + fg: #bca4ee; + text: #602040; + link: #064745; + accent: #9DF7C8; + cRed: #86004a; + cBlue: #0e5663; + cGreen: #0a8b51; + cOrange: #787424; +} + +Root { + --gradientColor: color | --accent; + --inputColor: color | #FFFFFF; + --bevelLight: color | $brightness(--bg 50); + --bevelDark: color | $brightness(--bg -20); + --bevelExtraDark: color | #404040; + --buttonDefaultBevel: shadow | $borderSide(--bevelExtraDark bottom-right 1 1), $borderSide(--bevelLight top-left 1 1), $borderSide(--bevelDark bottom-right 1 2); + --buttonPressedFocusedBevel: shadow | inset 0 0 0 1 #000000 / 1 #Outer , inset 0 0 0 2 --bevelExtraDark / 1 #inner; + --buttonPressedBevel: shadow | $borderSide(--bevelDark top-left 1 1), $borderSide(--bevelLight bottom-right 1 1), $borderSide(--bevelExtraDark top-left 1 2); + --defaultInputBevel: shadow | $borderSide(--bevelDark top-left 1 1), $borderSide(--bevelLight bottom-right 1 1), $borderSide(--bevelExtraDark top-left 1 2), $borderSide(--bg bottom-right 1 2); +} + +Button:toggled { + background: --bg; + shadow: --buttonPressedBevel +} + +Button:focused { + shadow: --buttonDefaultBevel, 0 0 0 1 #000000 / 1 +} + +Button:pressed { + shadow: --buttonPressedBevel +} + +Button:hover { + shadow: --buttonDefaultBevel; + background: --bg +} + +Button { + shadow: --buttonDefaultBevel; + background: --bg; + roundness: 0 +} + +Button:pressed:hover { + shadow: --buttonPressedBevel +} + +Button:hover:pressed:focused { + shadow: --buttonPressedFocusedBevel +} + +Button:pressed:focused { + shadow: --buttonPressedFocusedBevel +} + +Button:toggled:pressed { + shadow: --buttonPressedFocusedBevel +} + +Input { + background: $boost(--bg 20); + shadow: --defaultInputBevel; + roundness: 0 +} + +Input:focused { + shadow: inset 0 0 0 1 #000000 / 1, --defaultInputBevel +} + +Input:focused:hover { + shadow: --defaultInputBevel +} + +Input:focused:hover:disabled { + shadow: --defaultInputBevel +} + +Input:hover { + shadow: --defaultInputBevel +} + +Input:disabled { + shadow: --defaultInputBevel +} + +Panel { + shadow: --buttonDefaultBevel; + roundness: 0 +} + +PanelHeader { + shadow: inset -1100 0 1000 -1000 --gradientColor / 1 #Gradient ; + background: --fg +} + +PanelHeader ButtonUnstyled Icon { + textColor: --text; + textAuto: 'no-preserve' +} + +PanelHeader Button Icon { + textColor: --text; + textAuto: 'no-preserve' +} + +PanelHeader Button Text { + textColor: --text; + textAuto: 'no-preserve' +} + +Tab:hover { + background: --bg; + shadow: --buttonDefaultBevel +} + +Tab:active { + background: --bg +} + +Tab:active:hover { + background: --bg; + shadow: --defaultButtonBevel +} + +Tab:active:hover:disabled { + background: --bg +} + +Tab:hover:disabled { + background: --bg +} + +Tab:disabled { + background: --bg +} + +Tab { + background: --bg; + shadow: --buttonDefaultBevel +} + +Tab:hover:active { + shadow: --buttonDefaultBevel +} + +TopBar Link { + textColor: #ffffff +} + +MenuItem:hover { + background: --fg +} + +MenuItem:active { + background: --fg +} + +MenuItem:active:hover { + background: --fg +} + +Popover { + shadow: --buttonDefaultBevel, 5 5 0 0 #000000 / 0.2; + roundness: 0 +} diff --git a/public/static/styles/index.json b/public/static/styles/index.json new file mode 100644 index 000000000..6c1dd1676 --- /dev/null +++ b/public/static/styles/index.json @@ -0,0 +1,4 @@ +{ + "RedmondDX": "/static/styles/Redmond DX.iss", + "BreezyDX": "/static/styles/Breezy DX.iss" +} diff --git a/public/static/terms-of-service.html b/public/static/terms-of-service.html new file mode 100644 index 000000000..2b7bf7697 --- /dev/null +++ b/public/static/terms-of-service.html @@ -0,0 +1,9 @@ +

Terms of Service

+ +

This is the default placeholder ToS. You should copy it over to your static folder and edit it to fit the needs of your instance.

+ +

To do so, place a file at "/instance/static/static/terms-of-service.html" in your + Pleroma install containing the real ToS for your instance.

+

See the Pleroma documentation for more information.

+
+ diff --git a/static/themes/breezy-dark.json b/public/static/themes/breezy-dark.json similarity index 100% rename from static/themes/breezy-dark.json rename to public/static/themes/breezy-dark.json diff --git a/static/themes/breezy-light.json b/public/static/themes/breezy-light.json similarity index 100% rename from static/themes/breezy-light.json rename to public/static/themes/breezy-light.json diff --git a/static/themes/mammal.json b/public/static/themes/mammal.json similarity index 100% rename from static/themes/mammal.json rename to public/static/themes/mammal.json diff --git a/static/themes/paper.json b/public/static/themes/paper.json similarity index 100% rename from static/themes/paper.json rename to public/static/themes/paper.json diff --git a/static/themes/pleroma-dark.json b/public/static/themes/pleroma-dark.json similarity index 100% rename from static/themes/pleroma-dark.json rename to public/static/themes/pleroma-dark.json diff --git a/static/themes/pleroma-light.json b/public/static/themes/pleroma-light.json similarity index 100% rename from static/themes/pleroma-light.json rename to public/static/themes/pleroma-light.json diff --git a/static/themes/redmond-xx-se.json b/public/static/themes/redmond-xx-se.json similarity index 100% rename from static/themes/redmond-xx-se.json rename to public/static/themes/redmond-xx-se.json diff --git a/static/themes/redmond-xx.json b/public/static/themes/redmond-xx.json similarity index 100% rename from static/themes/redmond-xx.json rename to public/static/themes/redmond-xx.json diff --git a/static/themes/redmond-xxi.json b/public/static/themes/redmond-xxi.json similarity index 100% rename from static/themes/redmond-xxi.json rename to public/static/themes/redmond-xxi.json diff --git a/renovate.json b/renovate.json index 39a2b6e9a..4bd832f5f 100644 --- a/renovate.json +++ b/renovate.json @@ -1,6 +1,4 @@ { "$schema": "https://docs.renovatebot.com/renovate-schema.json", - "extends": [ - "config:base" - ] + "extends": ["config:base"] } diff --git a/src/App.js b/src/App.js index d2dd35c7c..ea2271a75 100644 --- a/src/App.js +++ b/src/App.js @@ -1,153 +1,307 @@ -import UserPanel from './components/user_panel/user_panel.vue' -import NavPanel from './components/nav_panel/nav_panel.vue' -import InstanceSpecificPanel from './components/instance_specific_panel/instance_specific_panel.vue' -import FeaturesPanel from './components/features_panel/features_panel.vue' -import WhoToFollowPanel from './components/who_to_follow_panel/who_to_follow_panel.vue' -import ShoutPanel from './components/shout_panel/shout_panel.vue' -import MediaModal from './components/media_modal/media_modal.vue' -import SideDrawer from './components/side_drawer/side_drawer.vue' -import MobilePostStatusButton from './components/mobile_post_status_button/mobile_post_status_button.vue' -import MobileNav from './components/mobile_nav/mobile_nav.vue' -import DesktopNav from './components/desktop_nav/desktop_nav.vue' -import UserReportingModal from './components/user_reporting_modal/user_reporting_modal.vue' -import EditStatusModal from './components/edit_status_modal/edit_status_modal.vue' -import PostStatusModal from './components/post_status_modal/post_status_modal.vue' -import StatusHistoryModal from './components/status_history_modal/status_history_modal.vue' -import GlobalNoticeList from './components/global_notice_list/global_notice_list.vue' -import { windowWidth, windowHeight } from './services/window_utils/window_utils' -import { mapGetters } from 'vuex' +import { throttle } from 'lodash' +import { mapState } from 'pinia' import { defineAsyncComponent } from 'vue' +import DesktopNav from 'src/components/desktop_nav/desktop_nav.vue' +import FeaturesPanel from 'src/components/features_panel/features_panel.vue' +import GlobalNoticeList from 'src/components/global_notice_list/global_notice_list.vue' +import InstanceSpecificPanel from 'src/components/instance_specific_panel/instance_specific_panel.vue' +import MobileNav from 'src/components/mobile_nav/mobile_nav.vue' +import MobilePostStatusButton from 'src/components/mobile_post_status_button/mobile_post_status_button.vue' +import NavPanel from 'src/components/nav_panel/nav_panel.vue' +import UserPanel from 'src/components/user_panel/user_panel.vue' +import { getOrCreateServiceWorker } from './services/sw/sw' +import { windowHeight, windowWidth } from './services/window_utils/window_utils' + +import { useEmojiStore } from 'src/stores/emoji.js' +import { useI18nStore } from 'src/stores/i18n.js' +import { useInstanceStore } from 'src/stores/instance.js' +import { useInstanceCapabilitiesStore } from 'src/stores/instance_capabilities.js' +import { useInterfaceStore } from 'src/stores/interface.js' +import { useMergedConfigStore } from 'src/stores/merged_config.js' +import { useShoutStore } from 'src/stores/shout.js' + +import messages from 'src/i18n/messages' +import localeService from 'src/services/locale/locale.service.js' + +// Helper to unwrap reactive proxies +window.toValue = (x) => JSON.parse(JSON.stringify(x)) + export default { name: 'app', components: { UserPanel, NavPanel, - Notifications: defineAsyncComponent(() => import('./components/notifications/notifications.vue')), + Notifications: defineAsyncComponent( + () => import('src/components/notifications/notifications.vue'), + ), InstanceSpecificPanel, FeaturesPanel, - WhoToFollowPanel, - ShoutPanel, - MediaModal, - SideDrawer, + WhoToFollowPanel: defineAsyncComponent( + () => + import('src/components/who_to_follow_panel/who_to_follow_panel.vue'), + ), + ShoutPanel: defineAsyncComponent( + () => import('src/components/shout_panel/shout_panel.vue'), + ), + MediaModal: defineAsyncComponent( + () => import('src/components/media_modal/media_modal.vue'), + ), MobilePostStatusButton, MobileNav, DesktopNav, - SettingsModal: defineAsyncComponent(() => import('./components/settings_modal/settings_modal.vue')), - UpdateNotification: defineAsyncComponent(() => import('./components/update_notification/update_notification.vue')), - UserReportingModal, - PostStatusModal, - EditStatusModal, - StatusHistoryModal, - GlobalNoticeList + SettingsModal: defineAsyncComponent( + () => import('src/components/settings_modal/settings_modal.vue'), + ), + UpdateNotification: defineAsyncComponent( + () => + import('src/components/update_notification/update_notification.vue'), + ), + PostStatusModal: defineAsyncComponent( + () => import('src/components/post_status_modal/post_status_modal.vue'), + ), + UserReportingModal: defineAsyncComponent( + () => + import('src/components/user_reporting_modal/user_reporting_modal.vue'), + ), + EditStatusModal: defineAsyncComponent( + () => import('src/components/edit_status_modal/edit_status_modal.vue'), + ), + StatusHistoryModal: defineAsyncComponent( + () => + import('src/components/status_history_modal/status_history_modal.vue'), + ), + GlobalNoticeList, }, data: () => ({ - mobileActivePanel: 'timeline' + mobileActivePanel: 'timeline', }), + provide() { + return { + allowNonSquareEmoji: useMergedConfigStore().mergedConfig.nonSquareEmoji, + } + }, watch: { - themeApplied (value) { + themeApplied() { this.removeSplash() - } + }, + currentTheme() { + this.setThemeBodyClass() + }, + layoutType() { + document.getElementById('modal').classList = ['-' + this.layoutType] + }, }, - created () { + created() { // Load the locale from the storage - const val = this.$store.getters.mergedConfig.interfaceLanguage - this.$store.dispatch('setOption', { name: 'interfaceLanguage', value: val }) - window.addEventListener('resize', this.updateMobileState) + const value = useMergedConfigStore().mergedConfig.interfaceLanguage + useI18nStore().setLanguage(value) + useEmojiStore().loadUnicodeEmojiData(value) + + document.getElementById('modal').classList = ['-' + this.layoutType] + + // Create bound handlers + this.updateScrollState = throttle(this.scrollHandler, 200) + this.updateMobileState = throttle(this.resizeHandler, 200) }, - mounted () { - if (this.$store.state.interface.themeApplied) { + mounted() { + window.addEventListener('resize', this.updateMobileState) + this.scrollParent.addEventListener('scroll', this.updateScrollState) + + if (this.themeApplied) { + this.setThemeBodyClass() this.removeSplash() } + getOrCreateServiceWorker() }, - unmounted () { + unmounted() { window.removeEventListener('resize', this.updateMobileState) + this.scrollParent.removeEventListener('scroll', this.updateScrollState) }, computed: { - themeApplied () { - return this.$store.state.interface.themeApplied + currentTheme() { + if (this.styleDataUsed) { + const styleMeta = this.styleDataUsed.find( + (x) => x.component === '@meta', + ) + + if (styleMeta !== undefined) { + return styleMeta.directives.name.replaceAll(' ', '-').toLowerCase() + } + } + + return 'stock' }, - classes () { + layoutModalClass() { + return '-' + this.layoutType + }, + classes() { return [ { '-reverse': this.reverseLayout, '-no-sticky-headers': this.noSticky, - '-has-new-post-button': this.newPostButtonShown + '-has-new-post-button': this.newPostButtonShown, }, - '-' + this.layoutType + '-' + this.layoutType, ] }, - navClasses () { - const { navbarColumnStretch } = this.$store.getters.mergedConfig + navClasses() { + const { navbarColumnStretch } = useMergedConfigStore().mergedConfig return [ '-' + this.layoutType, - ...(navbarColumnStretch ? ['-column-stretch'] : []) + ...(navbarColumnStretch ? ['-column-stretch'] : []), ] }, - currentUser () { return this.$store.state.users.currentUser }, - userBackground () { return this.currentUser.background_image }, - instanceBackground () { - return this.mergedConfig.hideInstanceWallpaper - ? null - : this.$store.state.instance.background + currentUser() { + return this.$store.state.users.currentUser }, - background () { return this.userBackground || this.instanceBackground }, - bgStyle () { + userBackground() { + return this.currentUser.background_image + }, + foreignProfileBackground() { + return ( + useMergedConfigStore().mergedConfig.allowForeignUserBackground && + useInterfaceStore().foreignProfileBackground + ) + }, + instanceBackground() { + return useMergedConfigStore().mergedConfig.hideInstanceWallpaper + ? null + : this.instanceBackgroundUrl + }, + background() { + return ( + this.foreignProfileBackground || + this.userBackground || + this.instanceBackground + ) + }, + bgStyle() { if (this.background) { return { - '--body-background-image': `url(${this.background})` + '--body-background-image': `url(${this.background})`, } } }, - shout () { return this.$store.state.shout.joined }, - suggestionsEnabled () { return this.$store.state.instance.suggestionsEnabled }, - showInstanceSpecificPanel () { - return this.$store.state.instance.showInstanceSpecificPanel && - !this.$store.getters.mergedConfig.hideISP && - this.$store.state.instance.instanceSpecificPanelContent + shoutJoined() { + return useShoutStore().joined }, - isChats () { + isChats() { return this.$route.name === 'chat' || this.$route.name === 'chats' }, - isListEdit () { + isListEdit() { return this.$route.name === 'lists-edit' }, - newPostButtonShown () { + newPostButtonShown() { if (this.isChats) return false if (this.isListEdit) return false - return this.$store.getters.mergedConfig.alwaysShowNewPostButton || this.layoutType === 'mobile' + return ( + useMergedConfigStore().mergedConfig.alwaysShowNewPostButton || + this.layoutType === 'mobile' + ) }, - showFeaturesPanel () { return this.$store.state.instance.showFeaturesPanel }, - editingAvailable () { return this.$store.state.instance.editingAvailable }, - shoutboxPosition () { - return this.$store.getters.mergedConfig.alwaysShowNewPostButton || false + shoutboxPosition() { + return ( + useMergedConfigStore().mergedConfig.alwaysShowNewPostButton || false + ) }, - hideShoutbox () { - return this.$store.getters.mergedConfig.hideShoutbox + hideShoutbox() { + return useMergedConfigStore().mergedConfig.hideShoutbox }, - layoutType () { return this.$store.state.interface.layoutType }, - privateMode () { return this.$store.state.instance.private }, - reverseLayout () { - const { thirdColumnMode, sidebarRight: reverseSetting } = this.$store.getters.mergedConfig + reverseLayout() { + const { thirdColumnMode, sidebarRight: reverseSetting } = + useMergedConfigStore().mergedConfig if (this.layoutType !== 'wide') { return reverseSetting } else { - return thirdColumnMode === 'notifications' ? reverseSetting : !reverseSetting + return thirdColumnMode === 'notifications' + ? reverseSetting + : !reverseSetting } }, - noSticky () { return this.$store.getters.mergedConfig.disableStickyHeaders }, - showScrollbars () { return this.$store.getters.mergedConfig.showScrollbars }, - ...mapGetters(['mergedConfig']) + noSticky() { + return useMergedConfigStore().mergedConfig.disableStickyHeaders + }, + showScrollbars() { + return useMergedConfigStore().mergedConfig.showScrollbars + }, + scrollParent() { + return window /* this.$refs.appContentRef */ + }, + showInstanceSpecificPanel() { + return ( + this.instanceSpecificPanelPresent && + !useMergedConfigStore().mergedConfig.hideISP + ) + }, + ...mapState(useMergedConfigStore, ['mergedConfig']), + ...mapState(useInterfaceStore, [ + 'themeApplied', + 'styleDataUsed', + 'layoutType', + ]), + ...mapState(useInstanceStore, ['styleDataUsed']), + ...mapState(useInstanceCapabilitiesStore, [ + 'suggestionsEnabled', + 'editingAvailable', + ]), + ...mapState(useInstanceStore, { + instanceBackgroundUrl: (store) => store.instanceIdentity.background, + showFeaturesPanel: (store) => store.instanceIdentity.showFeaturesPanel, + instanceSpecificPanelPresent: (store) => + store.instanceIdentity.showInstanceSpecificPanel && + store.instanceIdentity.instanceSpecificPanelContent, + }), }, methods: { - updateMobileState () { - this.$store.dispatch('setLayoutWidth', windowWidth()) - this.$store.dispatch('setLayoutHeight', windowHeight()) + resizeHandler() { + useInterfaceStore().setLayoutWidth(windowWidth()) + useInterfaceStore().setLayoutHeight(windowHeight()) }, - removeSplash () { - document.querySelector('#status').textContent = this.$t('splash.fun_' + Math.ceil(Math.random() * 4)) - document.querySelector('#splash').classList.add('hidden') + scrollHandler() { + const scrollPosition = + this.scrollParent === window + ? window.scrollY + : this.scrollParent.scrollTop + + if (scrollPosition != 0) { + this.$refs.appContentRef.classList.add(['-scrolled']) + } else { + this.$refs.appContentRef.classList.remove(['-scrolled']) + } + }, + setThemeBodyClass() { + const themeName = this.currentTheme + const classList = Array.from(document.body.classList) + const oldTheme = classList.filter((c) => c.startsWith('theme-')) + + if (themeName !== null && themeName !== '') { + const newTheme = `theme-${themeName.toLowerCase()}` + + // remove old theme reference if there are any + if (oldTheme.length) { + document.body.classList.replace(oldTheme[0], newTheme) + } else { + document.body.classList.add(newTheme) + } + } else { + // remove theme reference if non-V3 theme is used + document.body.classList.remove(...oldTheme) + } + }, + removeSplash() { + document.querySelector('#status').textContent = this.$t( + 'splash.fun_' + Math.ceil(Math.random() * 4), + ) + const splashscreenRoot = document.querySelector('#splash') + splashscreenRoot.addEventListener('transitionend', () => { + splashscreenRoot.remove() + }) + setTimeout(() => { + splashscreenRoot.remove() // forcibly remove it, should fix my plasma browser widget t. HJ + }, 600) + splashscreenRoot.classList.add('hidden') document.querySelector('#app').classList.remove('hidden') - } - } + }, + }, } diff --git a/src/App.scss b/src/App.scss index f52ba06b9..9618a2ac4 100644 --- a/src/App.scss +++ b/src/App.scss @@ -1,6 +1,9 @@ // stylelint-disable rscss/class-format /* stylelint-disable no-descending-specificity */ -@import "./panel"; +@use "panel"; + +@import '@fortawesome/fontawesome-svg-core/styles.css'; +@import '@kazvmoe-infra/pinch-zoom-element/dist/pinch-zoom.css'; :root { --status-margin: 0.75em; @@ -18,7 +21,7 @@ } html { - font-size: var(--textSize, 14px); + font-size: var(--textSize, 1rem); --navbar-height: var(--navbarSize, 3.5rem); --emoji-size: var(--emojiSize, 32px); @@ -30,12 +33,12 @@ body { font-family: sans-serif; font-family: var(--font); margin: 0; + padding: 0; color: var(--text); -webkit-font-smoothing: antialiased; -moz-osx-font-smoothing: grayscale; overscroll-behavior-y: none; - overflow-x: clip; - overflow-y: scroll; + overflow: clip scroll; &.hidden { display: none; @@ -47,7 +50,7 @@ body { // have a cursor/pointer to operate them @media (any-pointer: fine) { * { - scrollbar-color: var(--fg) transparent; + scrollbar-color: var(--icon) transparent; &::-webkit-scrollbar { background: transparent; @@ -127,7 +130,7 @@ body { } // Body should have background to scrollbar otherwise it will use white (body color?) html { - scrollbar-color: var(--fg) var(--wallpaper); + scrollbar-color: var(--icon) var(--wallpaper); background: var(--wallpaper); } } @@ -197,6 +200,7 @@ nav { background-color: var(--wallpaper); background-image: var(--body-background-image); background-position: 50%; + transition: background-image 1s; } .underlay { @@ -224,9 +228,8 @@ nav { grid-template-rows: 1fr; box-sizing: border-box; margin: 0 auto; - align-content: flex-start; + place-content: flex-start center; flex-wrap: wrap; - justify-content: center; min-height: 100vh; overflow-x: clip; @@ -262,8 +265,7 @@ nav { position: sticky; top: var(--navbar-height); max-height: calc(100vh - var(--navbar-height)); - overflow-y: auto; - overflow-x: hidden; + overflow: hidden auto; margin-left: calc(var(--___paddingIncrease) * -1); padding-left: calc(var(--___paddingIncrease) + var(--___columnMargin) / 2); @@ -381,6 +383,10 @@ nav { font-family: sans-serif; font-family: var(--font); + &.-transparent { + backdrop-filter: blur(0.125em) contrast(60%); + } + &::-moz-focus-inner { border: none; } @@ -388,6 +394,36 @@ nav { &:disabled { cursor: not-allowed; } + + &:active { + transform: translate(1px, 1px); + } +} + +.menu-item { + line-height: var(--__line-height); + font-family: inherit; + font-weight: 400; + font-size: 100%; + cursor: pointer; + + a, + button:not(.button-default) { + color: var(--text); + font-size: 100%; + text-align: initial; + padding: 0; + background: none; + border: none; + outline: none; + display: inline; + font-family: inherit; + line-height: unset; + } + + &.disabled { + cursor: not-allowed; + } } .menu-item, @@ -397,76 +433,17 @@ nav { border: none; outline: none; text-align: initial; - font-size: inherit; - font-family: inherit; - font-weight: 400; - cursor: pointer; color: inherit; clear: both; position: relative; white-space: nowrap; - border-color: var(--border); - border-style: solid; - border-width: 0; - border-top-width: 1px; width: 100%; - line-height: var(--__line-height); padding: var(--__vertical-gap) var(--__horizontal-gap); background: transparent; --__line-height: 1.5em; --__horizontal-gap: 0.75em; --__vertical-gap: 0.5em; - - &.-non-interactive { - cursor: auto; - } - - &.-active, - &:hover { - border-top-width: 1px; - border-bottom-width: 1px; - } - - &.-active + &, - &:hover + & { - border-top-width: 0; - } - - &:hover + .menu-item-collapsible:not(.-expanded) + &, - &.-active + .menu-item-collapsible:not(.-expanded) + & { - border-top-width: 0; - } - - &[aria-expanded="true"] { - border-bottom-width: 1px; - } - - a, - button:not(.button-default) { - text-align: initial; - padding: 0; - background: none; - border: none; - outline: none; - display: inline; - font-size: 100%; - font-family: inherit; - line-height: unset; - color: var(--text); - } - - &:first-child { - border-top-right-radius: var(--roundness); - border-top-left-radius: var(--roundness); - border-top-width: 0; - } - - &:last-child { - border-bottom-right-radius: var(--roundness); - border-bottom-left-radius: var(--roundness); - border-bottom-width: 0; - } } .button-unstyled { @@ -490,6 +467,12 @@ nav { } } +label { + &.-disabled { + color: var(--textFaint); + } +} + input, textarea { border: none; @@ -506,6 +489,10 @@ textarea { height: unset; } + &::placeholder { + color: var(--textFaint) + } + --_padding: 0.5em; border: none; @@ -526,6 +513,10 @@ textarea { &[disabled="disabled"], &.disabled { cursor: not-allowed; + color: var(--textFaint); + + /* stylelint-disable-next-line declaration-no-important */ + background-color: transparent !important; } &[type="range"] { @@ -551,6 +542,8 @@ textarea { & + label::before { opacity: 0.5; } + + background-color: var(--background); } + label::before { @@ -650,7 +643,8 @@ option { list-style: none; display: grid; grid-auto-flow: row dense; - grid-template-columns: 1fr 1fr; + grid-template-columns: repeat(auto-fit, minmax(20em, 1fr)); + grid-gap: 0.5em; li { border: 1px solid var(--border); @@ -660,11 +654,6 @@ option { } } -.btn-block { - display: block; - width: 100%; -} - .btn-group { position: relative; display: inline-flex; @@ -676,7 +665,6 @@ option { --_roundness-right: 0; position: relative; - flex: 1 1 auto; } > *:first-child, @@ -723,17 +711,15 @@ option { } &.-dot { - min-height: 8px; - max-height: 8px; - min-width: 8px; - max-width: 8px; - padding: 0; + min-height: 0.6em; + max-height: 0.6em; + min-width: 0.6em; + max-width: 0.6em; + left: calc(50% + 0.5em); + top: calc(50% - 1em); line-height: 0; - font-size: 0; - left: calc(50% - 4px); - top: calc(50% - 4px); - margin-left: 6px; - margin-top: -6px; + padding: 0; + margin: 0; } &.-counter { @@ -755,6 +741,19 @@ option { padding: 0 0.25em; border-radius: var(--roundness); border: 1px solid var(--border); + + &.-dismissible { + display: flex; + padding-left: 0.5em; + margin: 0; + align-items: baseline; + line-height: 2; + + span { + display: block; + flex: 1 0 auto; + } + } } .faint { @@ -764,21 +763,20 @@ option { color: var(--text); } -.visibility-notice { - padding: 0.5em; - border: 1px solid var(--textFaint); - border-radius: var(--roundness); -} - .notice-dismissible { - padding-right: 4rem; - position: relative; + display: flex; + padding: 0.75em 1em; + align-items: baseline; + line-height: 1.5; + + p, + span { + display: block; + flex: 1 1 auto; + margin: 0; + } .dismiss { - position: absolute; - top: 0; - right: 0; - padding: 0.5em; color: inherit; } } @@ -814,7 +812,7 @@ option { .login-hint { text-align: center; - @media all and (min-width: 801px) { + @media all and (width >= 801px) { display: none; } @@ -836,7 +834,7 @@ option { flex: 1; } -@media all and (max-width: 800px) { +@media all and (width <= 800px) { .mobile-hidden { display: none; } @@ -917,12 +915,7 @@ option { #splash { pointer-events: none; - transition: opacity 2s; - opacity: 1; - - &.hidden { - opacity: 0; - } + // transition: opacity 0.5s; #status { &.css-ok { @@ -946,7 +939,7 @@ option { &.dead { animation-name: dead; - animation-duration: 2s; + animation-duration: 0.5s; animation-iteration-count: 1; transform: rotateX(90deg) rotateY(0) rotateZ(-45deg); } @@ -1061,7 +1054,7 @@ option { scale: 1.0063 0.9938; translate: 0 -10%; transform: rotateZ(var(--defaultZ)); - animation-timing-function: ease-in-ou; + animation-timing-function: ease-in-out; } 90% { @@ -1080,3 +1073,8 @@ option { } } } + +@property --shadow { + syntax: "*"; + inherits: false; +} diff --git a/src/App.vue b/src/App.vue index 9d7ad9129..c1a7199d7 100644 --- a/src/App.vue +++ b/src/App.vue @@ -1,6 +1,6 @@ diff --git a/src/components/alert.style.js b/src/components/alert.style.js index 19bd4bbbf..6876faca8 100644 --- a/src/components/alert.style.js +++ b/src/components/alert.style.js @@ -1,51 +1,58 @@ export default { name: 'Alert', selector: '.alert', - validInnerComponents: [ - 'Text', - 'Icon', - 'Link', - 'Border', - 'ButtonUnstyled' - ], + validInnerComponents: ['Text', 'Icon', 'Link', 'Border', 'ButtonUnstyled'], variants: { normal: '.neutral', + info: '.info', error: '.error', warning: '.warning', - success: '.success' + success: '.success', + }, + editor: { + border: 1, + aspect: '3 / 1', }, defaultRules: [ { directives: { background: '--text', opacity: 0.5, - blur: '9px' - } + blur: '9px', + }, }, { parent: { - component: 'Alert' + component: 'Alert', }, component: 'Border', - textColor: '--parent' + directives: { + textColor: '--parent', + }, }, { variant: 'error', directives: { - background: '--cRed' - } + background: '--cRed', + }, }, { variant: 'warning', directives: { - background: '--cOrange' - } + background: '--cOrange', + }, }, { variant: 'success', directives: { - background: '--cGreen' - } - } - ] + background: '--cGreen', + }, + }, + { + variant: 'info', + directives: { + background: '--cBlue', + }, + }, + ], } diff --git a/src/components/announcement/announcement.js b/src/components/announcement/announcement.js index 30254926a..ee427533f 100644 --- a/src/components/announcement/announcement.js +++ b/src/components/announcement/announcement.js @@ -1,108 +1,126 @@ import { mapState } from 'vuex' -import AnnouncementEditor from '../announcement_editor/announcement_editor.vue' -import RichContent from '../rich_content/rich_content.jsx' + +import AnnouncementEditor from 'src/components/announcement_editor/announcement_editor.vue' import localeService from '../../services/locale/locale.service.js' +import { useAnnouncementsStore } from 'src/stores/announcements.js' + const Announcement = { components: { AnnouncementEditor, - 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.has('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 this.$store.dispatch('markAnnouncementAsRead', this.announcement.id) + return useAnnouncementsStore().markAnnouncementAsRead( + this.announcement.id, + ) } }, - deleteAnnouncement () { - return this.$store.dispatch('deleteAnnouncement', this.announcement.id) + 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 () { - this.$store.dispatch('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..c816083a4 100644 --- a/src/components/announcement_editor/announcement_editor.js +++ b/src/components/announcement_editor/announcement_editor.js @@ -1,13 +1,13 @@ -import Checkbox from '../checkbox/checkbox.vue' +import Checkbox from 'src/components/checkbox/checkbox.vue' const AnnouncementEditor = { components: { - Checkbox + Checkbox, }, props: { announcement: Object, - disabled: Boolean - } + disabled: Boolean, + }, } export default AnnouncementEditor diff --git a/src/components/announcement_editor/announcement_editor.vue b/src/components/announcement_editor/announcement_editor.vue index c0a3c30ac..f1b016183 100644 --- a/src/components/announcement_editor/announcement_editor.vue +++ b/src/components/announcement_editor/announcement_editor.vue @@ -34,8 +34,9 @@ id="announcement-all-day" v-model="announcement.allDay" :disabled="disabled" - /> - + > + {{ $t('announcements.all_day_prompt') }} + @@ -55,7 +56,7 @@ .post-textarea { resize: vertical; height: 10em; - overflow: none; + overflow: visible; box-sizing: content-box; } } diff --git a/src/components/announcements_page/announcements_page.js b/src/components/announcements_page/announcements_page.js index 8d1204d4c..3c73198c5 100644 --- a/src/components/announcements_page/announcements_page.js +++ b/src/components/announcements_page/announcements_page.js @@ -1,58 +1,67 @@ import { mapState } from 'vuex' -import Announcement from '../announcement/announcement.vue' -import AnnouncementEditor from '../announcement_editor/announcement_editor.vue' + +import Announcement from 'src/components/announcement/announcement.vue' +import AnnouncementEditor from 'src/components/announcement_editor/announcement_editor.vue' + +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 () { - this.$store.dispatch('fetchAnnouncements') + mounted() { + useAnnouncementsStore().fetchAnnouncements() }, computed: { ...mapState({ - currentUser: state => state.users.currentUser + currentUser: (state) => state.users.currentUser, }), - announcements () { - return this.$store.state.announcements.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 - this.$store.dispatch('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/announcements_page/announcements_page.vue b/src/components/announcements_page/announcements_page.vue index 25f830ffb..44e2cf46a 100644 --- a/src/components/announcements_page/announcements_page.vue +++ b/src/components/announcements_page/announcements_page.vue @@ -1,9 +1,9 @@ diff --git a/src/components/border.style.js b/src/components/border.style.js index a87ee9c87..e7cc31c57 100644 --- a/src/components/border.style.js +++ b/src/components/border.style.js @@ -5,9 +5,9 @@ export default { defaultRules: [ { directives: { - textColor: '$mod(--parent, 10)', - textAuto: 'no-auto' - } - } - ] + textColor: '$mod(--parent 10)', + textAuto: 'no-auto', + }, + }, + ], } diff --git a/src/components/bubble_timeline/bubble_timeline.js b/src/components/bubble_timeline/bubble_timeline.js new file mode 100644 index 000000000..fcbc31ad2 --- /dev/null +++ b/src/components/bubble_timeline/bubble_timeline.js @@ -0,0 +1,20 @@ +import Timeline from 'src/components/timeline/timeline.vue' + +const BubbleTimeline = { + components: { + Timeline, + }, + computed: { + timeline() { + return this.$store.state.statuses.timelines.bubble + }, + }, + created() { + this.$store.dispatch('startFetchingTimeline', { timeline: 'bubble' }) + }, + unmounted() { + this.$store.dispatch('stopFetchingTimeline', 'bubble') + }, +} + +export default BubbleTimeline diff --git a/src/components/bubble_timeline/bubble_timeline.vue b/src/components/bubble_timeline/bubble_timeline.vue new file mode 100644 index 000000000..4aefa2729 --- /dev/null +++ b/src/components/bubble_timeline/bubble_timeline.vue @@ -0,0 +1,9 @@ + + + diff --git a/src/components/button.style.js b/src/components/button.style.js index 6fec67a0b..1644accc4 100644 --- a/src/components/button.style.js +++ b/src/components/button.style.js @@ -9,93 +9,167 @@ export default { // However, cascading still works, so resulting state will be result of merging of all relevant states/variants // normal: '' // normal state is implicitly added, it is always included toggled: '.toggled', - pressed: ':active', - hover: ':hover:not(:disabled)', focused: ':focus-within', - disabled: ':disabled' + pressed: ':active', + hover: ':is(:hover, :focus-visible):not(: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', + }, // 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: [ { component: 'Root', directives: { - '--defaultButtonHoverGlow': 'shadow | 0 0 4 --text', - '--defaultButtonShadow': 'shadow | 0 0 2 #000000', - '--defaultButtonBevel': 'shadow | $borderSide(#FFFFFF, top, 0.2) | $borderSide(#000000, bottom, 0.2)', - '--pressedButtonBevel': 'shadow | $borderSide(#FFFFFF, bottom, 0.2)| $borderSide(#000000, top, 0.2)' - } + '--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)', + }, }, { // component: 'Button', // no need to specify components every time unless you're specifying how other component should look // like within it directives: { background: '--fg', - shadow: ['--defaultButtonShadow', '--defaultButtonBevel'], - roundness: 3 - } + shadow: ['--buttonDefaultShadow', '--buttonDefaultBevel'], + roundness: 3, + }, }, { - state: ['hover'], + variant: 'danger', directives: { - shadow: ['--defaultButtonHoverGlow', '--defaultButtonBevel'] - } + background: '$blend(--cRed 0.25 --inheritedBackground)', + }, }, { - state: ['pressed'], + variant: 'transparent', directives: { - shadow: ['--defaultButtonShadow', '--pressedButtonBevel'] - } - }, - { - state: ['hover', 'pressed'], - directives: { - shadow: ['--defaultButtonHoverGlow', '--pressedButtonBevel'] - } - }, - { - state: ['toggled'], - directives: { - background: '--inheritedBackground,-14.2', - shadow: ['--defaultButtonShadow', '--pressedButtonBevel'] - } - }, - { - state: ['toggled', 'hover'], - directives: { - background: '--inheritedBackground,-14.2', - shadow: ['--defaultButtonHoverGlow', '--pressedButtonBevel'] - } - }, - { - state: ['disabled'], - directives: { - background: '$blend(--inheritedBackground, 0.25, --parent)', - shadow: ['--defaultButtonBevel'] - } + opacity: 0.5, + }, }, { component: 'Text', parent: { component: 'Button', - state: ['disabled'] + variant: 'transparent', + }, + directives: { + textColor: '--text', + }, + }, + { + component: 'Icon', + parent: { + component: 'Button', + 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 65b5c57bf..c35fb8f69 100644 --- a/src/components/button_unstyled.style.js +++ b/src/components/button_unstyled.style.js @@ -1,96 +1,92 @@ export default { name: 'ButtonUnstyled', selector: '.button-unstyled', + notEditable: true, + transparent: true, states: { toggled: '.toggled', disabled: ':disabled', - hover: ':hover:not(:disabled)', - focused: ':focus-within' + hover: ':is(:hover, :focus-visible):not(:disabled)', + focused: ':focus-within:not(:is(:focus-visible))', }, - validInnerComponents: [ - 'Text', - 'Icon', - 'Badge' - ], + validInnerComponents: ['Text', 'Link', 'Icon', 'Badge'], defaultRules: [ { directives: { - background: '#ffffff', - opacity: 0, - 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 79f24771a..7428c0dc7 100644 --- a/src/components/chat/chat.js +++ b/src/components/chat/chat.js @@ -1,23 +1,27 @@ -import _ from 'lodash' -import { WSConnectionStatus } from '../../services/api/api.service.js' +import { throttle } from 'lodash' +import { mapState as mapPiniaState } from 'pinia' import { mapGetters, mapState } from 'vuex' -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 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' -library.add( - faChevronDown, - faChevronLeft -) +import ChatMessage from 'src/components/chat_message/chat_message.vue' +import ChatTitle from 'src/components/chat_title/chat_title.vue' +import PostStatusForm from 'src/components/post_status_form/post_status_form.vue' +import { WSConnectionStatus } from '../../services/api/api.service.js' +import chatService from '../../services/chat_service/chat_service.js' +import { buildFakeMessage } from '../../services/chat_utils/chat_utils.js' +import { promiseInterval } from '../../services/promise_interval/promise_interval.js' +import { + getNewTopPosition, + getScrollPosition, + isBottomedOut, + isScrollable, +} from './chat_layout_utils.js' + +import { useInterfaceStore } from 'src/stores/interface.js' + +import { 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 @@ -29,76 +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', + }), ...mapState({ - backendInteractor: state => state.api.backendInteractor, - mastoUserSocketStatus: state => state.api.mastoUserSocketStatus, - mobileLayout: state => state.interface.layoutType === 'mobile', - 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) @@ -111,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 }) @@ -135,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) { @@ -156,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 () { + handleScroll: throttle(function () { this.lastScrollPosition = getScrollPosition() - if (!this.currentChat) { return } + if (!this.currentChat) { + return + } if (this.reachedTop()) { this.fetchChat({ maxId: this.currentChatMessageService.minId }) @@ -209,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) { @@ -232,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 @@ -267,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 @@ -281,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]) { @@ -297,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 6efe576b8..cedbdce69 100644 --- a/src/components/chat/chat.vue +++ b/src/components/chat/chat.vue @@ -73,9 +73,11 @@ :disable-notice="true" :disable-lock-warning="true" :disable-polls="true" + :disable-quotes="true" :disable-sensitivity-checkbox="true" :disable-submit="errorLoadingChat || !currentChat" :disable-preview="true" + :disable-draft="true" :optimistic-posting="true" :post-handler="sendMessage" :submit-on-enter="!mobileLayout" @@ -94,6 +96,4 @@ - + + + + + diff --git a/src/components/color_input/color_input.scss b/src/components/color_input/color_input.scss index b0fc879f7..fad2bfca7 100644 --- a/src/components/color_input/color_input.scss +++ b/src/components/color_input/color_input.scss @@ -1,12 +1,28 @@ .color-input { display: inline-flex; + flex-wrap: wrap; + max-width: 10em; + + &.-compact { + max-width: none; + } + + .label { + flex: 1 1 auto; + grid-area: label; + } + + .opt { + grid-area: checkbox; + margin-right: 0.5em; + } &-field.input { - display: inline-flex; - flex: 0 0 0; - max-width: 9em; + flex: 1 1 10em; + max-width: 10em; + grid-area: input; + display: flex; align-items: stretch; - padding: 0.2em 8px; input { color: var(--text); @@ -25,6 +41,7 @@ .nativeColor { cursor: pointer; flex: 0 0 auto; + padding: 0; input { appearance: none; @@ -41,10 +58,10 @@ .invalidIndicator, .transparentIndicator { flex: 0 0 2em; - margin: 0 0.5em; + margin: 0.2em 0.5em; min-width: 2em; align-self: stretch; - min-height: 1.5em; + min-height: 1.1em; border-radius: var(--roundness); } @@ -81,9 +98,17 @@ border-bottom-right-radius: var(--roundness); } } - } - .label { - flex: 1 1 auto; + &.disabled, + &:disabled { + .nativeColor input, + .computedIndicator, + .validIndicator, + .invalidIndicator, + .transparentIndicator { + /* stylelint-disable-next-line declaration-no-important */ + opacity: 0.25 !important; + } + } } } diff --git a/src/components/color_input/color_input.vue b/src/components/color_input/color_input.vue index 66ee9d53a..208bdf2dc 100644 --- a/src/components/color_input/color_input.vue +++ b/src/components/color_input/color_input.vue @@ -1,29 +1,34 @@ diff --git a/src/components/component_preview/component_preview.js b/src/components/component_preview/component_preview.js new file mode 100644 index 000000000..1d54f58de --- /dev/null +++ b/src/components/component_preview/component_preview.js @@ -0,0 +1,87 @@ +import Checkbox from 'src/components/checkbox/checkbox.vue' +import ColorInput from 'src/components/color_input/color_input.vue' + +import genRandomSeed from 'src/services/random_seed/random_seed.service.js' +import { + adoptStyleSheets, + createStyleSheet, +} from 'src/services/style_setter/style_setter.js' + +export default { + components: { + Checkbox, + ColorInput, + }, + props: [ + 'shadow', + 'shadowControl', + 'previewClass', + 'previewStyle', + 'previewCss', + 'disabled', + 'invalid', + 'noColorControl', + ], + emits: ['update:shadow'], + data() { + return { + colorOverride: undefined, + lightGrid: false, + zoom: 100, + randomSeed: genRandomSeed(), + } + }, + mounted() { + this.update() + }, + computed: { + hideControls() { + return typeof this.shadow === 'string' + }, + }, + watch: { + previewCss() { + this.update() + }, + previewStyle() { + this.update() + }, + zoom() { + this.update() + }, + }, + methods: { + updateProperty(axis, value) { + this.$emit('update:shadow', { axis, value: Number(value) }) + }, + update() { + const sheet = createStyleSheet('style-component-preview', 90) + + sheet.clear() + + const result = [this.previewCss] + if (this.colorOverride) result.push(`--background: ${this.colorOverride}`) + + const styleRule = [ + '#component-preview-', + this.randomSeed, + ' {\n', + '.preview-block {\n', + `zoom: ${this.zoom / 100};`, + this.previewStyle, + '\n}', + '\n}', + ].join('') + + sheet.addRule(styleRule) + sheet.addRule( + ['#component-preview-', this.randomSeed, ' {\n', ...result, '\n}'].join( + '', + ), + ) + + sheet.ready = true + adoptStyleSheets() + }, + }, +} diff --git a/src/components/component_preview/component_preview.scss b/src/components/component_preview/component_preview.scss new file mode 100644 index 000000000..bb83b7908 --- /dev/null +++ b/src/components/component_preview/component_preview.scss @@ -0,0 +1,151 @@ +.ComponentPreview { + display: grid; + grid-template-columns: 1em 1fr 1fr 1em; + grid-template-rows: 2em 1fr 1fr 1fr 1em 2em max-content; + grid-template-areas: + "header header header header " + "preview preview preview y-slide" + "preview preview preview y-slide" + "preview preview preview y-slide" + "x-slide x-slide x-slide . " + "x-num x-num y-num y-num " + "assists assists assists assists"; + grid-gap: 0.5em; + + &:not(.-shadow-controls) { + grid-template-areas: + "header header header header " + "preview preview preview y-slide" + "preview preview preview y-slide" + "preview preview preview y-slide" + "assists assists assists assists"; + grid-template-rows: 2em 1fr 1fr 1fr max-content; + } + + .header { + grid-area: header; + place-self: baseline center; + line-height: 2; + } + + .invalid-container { + position: absolute; + inset: 0; + display: grid; + place-items: center center; + background-color: rgb(100 0 0 / 50%); + + .alert { + padding: 0.5em 1em; + } + } + + .assists { + grid-area: assists; + display: grid; + grid-auto-flow: row; + grid-auto-rows: 2em; + grid-gap: 0.5em; + } + + .input-light-grid { + justify-self: center; + } + + .input-number { + min-width: 2em; + } + + .x-shift-number { + grid-area: x-num; + justify-self: right; + } + + .y-shift-number { + grid-area: y-num; + justify-self: left; + } + + .x-shift-number, + .y-shift-number { + input { + max-width: 4em; + } + } + + .x-shift-slider { + grid-area: x-slide; + height: auto; + align-self: start; + min-width: 10em; + } + + .y-shift-slider { + grid-area: y-slide; + writing-mode: vertical-lr; + justify-self: left; + min-height: 10em; + } + + .x-shift-slider, + .y-shift-slider { + padding: 0; + } + + .preview-window { + --__grid-color1: rgb(102 102 102); + --__grid-color2: rgb(153 153 153); + --__grid-color1-disabled: rgb(102 102 102 / 20%); + --__grid-color2-disabled: rgb(153 153 153 / 20%); + + &.-light-grid { + --__grid-color1: rgb(205 205 205); + --__grid-color2: rgb(255 255 255); + --__grid-color1-disabled: rgb(205 205 205 / 20%); + --__grid-color2-disabled: rgb(255 255 255 / 20%); + } + + position: relative; + grid-area: preview; + aspect-ratio: 1; + display: flex; + align-items: center; + justify-content: center; + min-width: 10em; + min-height: 10em; + background-color: var(--__grid-color2); + background-image: + linear-gradient(45deg, var(--__grid-color1) 25%, transparent 25%), + linear-gradient(-45deg, var(--__grid-color1) 25%, transparent 25%), + linear-gradient(45deg, transparent 75%, var(--__grid-color1) 75%), + linear-gradient(-45deg, transparent 75%, var(--__grid-color1) 75%); + background-size: 20px 20px; + background-position: 0 0, 0 10px, 10px -10px, -10px 0; + border-radius: var(--roundness); + + &.disabled { + background-color: var(--__grid-color2-disabled); + background-image: + linear-gradient(45deg, var(--__grid-color1-disabled) 25%, transparent 25%), + linear-gradient(-45deg, var(--__grid-color1-disabled) 25%, transparent 25%), + linear-gradient(45deg, transparent 75%, var(--__grid-color1-disabled) 75%), + linear-gradient(-45deg, transparent 75%, var(--__grid-color1-disabled) 75%); + } + + .preview-block { + background: var(--background, var(--bg)); + display: flex; + justify-content: center; + align-items: center; + min-width: 33%; + min-height: 33%; + max-width: 80%; + max-height: 80%; + border-width: 0; + border-style: solid; + border-color: var(--border); + border-radius: var(--roundness); + box-shadow: var(--shadow); + } + } +} diff --git a/src/components/component_preview/component_preview.vue b/src/components/component_preview/component_preview.vue new file mode 100644 index 000000000..0a0f9541e --- /dev/null +++ b/src/components/component_preview/component_preview.vue @@ -0,0 +1,115 @@ + + + + diff --git a/src/components/confirm_modal/mute_confirm.js b/src/components/confirm_modal/mute_confirm.js new file mode 100644 index 000000000..c2f5ff888 --- /dev/null +++ b/src/components/confirm_modal/mute_confirm.js @@ -0,0 +1,92 @@ +import { mapState } from 'pinia' +import { defineAsyncComponent } from 'vue' + +import Select from 'src/components/select/select.vue' + +import { useMergedConfigStore } from 'src/stores/merged_config.js' + +export default { + props: ['type', 'user', 'status'], + emits: ['hide', 'show', 'muted'], + data: () => ({ + showing: false, + }), + components: { + ConfirmModal: defineAsyncComponent( + () => import('src/components/confirm_modal/confirm_modal.vue'), + ), + + Select, + }, + computed: { + domain() { + return this.user.fqn.split('@')[1] + }, + keypath() { + if (this.type === 'domain') { + return 'user_card.mute_domain_confirm' + } else if (this.type === 'conversation') { + return 'user_card.mute_conversation_confirm' + } + }, + conversationIsMuted() { + return this.status.conversation_muted + }, + domainIsMuted() { + return new Set(this.$store.state.users.currentUser.domainMutes).has( + this.domain, + ) + }, + shouldConfirm() { + switch (this.type) { + case 'domain': { + return this.mergedConfig.modalOnMuteDomain + } + default: { + // conversation + return this.mergedConfig.modalOnMuteConversation + } + } + }, + ...mapState(useMergedConfigStore, ['mergedConfig']), + }, + methods: { + optionallyPrompt() { + if (this.shouldConfirm) { + this.show() + } else { + this.doMute() + } + }, + show() { + this.showing = true + this.$emit('show') + }, + hide() { + this.showing = false + this.$emit('hide') + }, + doMute() { + switch (this.type) { + case 'domain': { + if (!this.domainIsMuted) { + this.$store.dispatch('muteDomain', this.domain) + } else { + this.$store.dispatch('unmuteDomain', this.domain) + } + break + } + case 'conversation': { + if (!this.conversationIsMuted) { + this.$store.dispatch('muteConversation', { id: this.status.id }) + } else { + this.$store.dispatch('unmuteConversation', { id: this.status.id }) + } + break + } + } + this.$emit('muted') + this.hide() + }, + }, +} diff --git a/src/components/confirm_modal/mute_confirm.vue b/src/components/confirm_modal/mute_confirm.vue new file mode 100644 index 000000000..108a72477 --- /dev/null +++ b/src/components/confirm_modal/mute_confirm.vue @@ -0,0 +1,31 @@ + + + @@ -87,8 +132,7 @@ export default { .contrast-ratio { display: flex; justify-content: flex-end; - margin-top: -4px; - margin-bottom: 5px; + align-items: baseline; .label { margin-right: 1em; @@ -96,7 +140,6 @@ export default { .rating { display: inline-block; - text-align: center; margin-left: 0.5em; } } diff --git a/src/components/conversation-page/conversation-page.js b/src/components/conversation-page/conversation-page.js index 8f996be12..84494332d 100644 --- a/src/components/conversation-page/conversation-page.js +++ b/src/components/conversation-page/conversation-page.js @@ -1,14 +1,14 @@ -import Conversation from '../conversation/conversation.vue' +import Conversation from 'src/components/conversation/conversation.vue' const conversationPage = { components: { - Conversation + Conversation, }, computed: { - statusId () { + statusId() { return this.$route.params.id - } - } + }, + }, } export default conversationPage diff --git a/src/components/conversation/conversation.js b/src/components/conversation/conversation.js index a94d2130d..da15c79d6 100644 --- a/src/components/conversation/conversation.js +++ b/src/components/conversation/conversation.js @@ -1,23 +1,23 @@ -import { reduce, filter, findIndex, clone, get } from 'lodash' -import Status from '../status/status.vue' -import ThreadTree from '../thread_tree/thread_tree.vue' +import { clone, filter, findIndex, get, reduce } from 'lodash' +import { mapState as mapPiniaState } from 'pinia' +import { mapState } from 'vuex' + +import QuickFilterSettings from 'src/components/quick_filter_settings/quick_filter_settings.vue' +import QuickViewSettings from 'src/components/quick_view_settings/quick_view_settings.vue' +import ThreadTree from 'src/components/thread_tree/thread_tree.vue' import { WSConnectionStatus } from '../../services/api/api.service.js' -import { mapGetters, mapState } from 'vuex' -import QuickFilterSettings from '../quick_filter_settings/quick_filter_settings.vue' -import QuickViewSettings from '../quick_view_settings/quick_view_settings.vue' + +import { useInterfaceStore } from 'src/stores/interface' +import { useMergedConfigStore } from 'src/stores/merged_config.js' import { library } from '@fortawesome/fontawesome-svg-core' import { faAngleDoubleDown, faAngleDoubleLeft, - faChevronLeft + faChevronLeft, } from '@fortawesome/free-solid-svg-icons' -library.add( - faAngleDoubleDown, - faAngleDoubleLeft, - faChevronLeft -) +library.add(faAngleDoubleDown, faAngleDoubleLeft, faChevronLeft) const sortById = (a, b) => { const idA = a.type === 'retweet' ? a.retweeted_status.id : a.id @@ -41,23 +41,25 @@ const sortAndFilterConversation = (conversation, statusoid) => { if (statusoid.type === 'retweet') { conversation = filter( conversation, - (status) => (status.type === 'retweet' || status.id !== statusoid.retweeted_status.id) + (status) => + status.type === 'retweet' || + status.id !== statusoid.retweeted_status.id, ) } else { conversation = filter(conversation, (status) => status.type !== 'retweet') } - return conversation.filter(_ => _).sort(sortById) + return conversation.filter((_) => _).sort(sortById) } const conversation = { - data () { + data() { return { highlight: null, expanded: false, threadDisplayStatusObject: {}, // id => 'showing' | 'hidden' statusContentPropertiesObject: {}, inlineDivePosition: null, - loadStatusError: null + loadStatusError: null, } }, props: [ @@ -67,76 +69,80 @@ const conversation = { 'pinnedStatusIdsObject', 'inProfile', 'profileUserId', - 'virtualHidden' + 'virtualHidden', ], - created () { + created() { if (this.isPage) { this.fetchConversation() } }, computed: { - maxDepthToShowByDefault () { + maxDepthToShowByDefault() { // maxDepthInThread = max number of depths that is *visible* // since our depth starts with 0 and "showing" means "showing children" // there is a -2 here - const maxDepth = this.$store.getters.mergedConfig.maxDepthInThread - 2 + const maxDepth = this.mergedConfig.maxDepthInThread - 2 return maxDepth >= 1 ? maxDepth : 1 }, - streamingEnabled () { - return this.mergedConfig.useStreamingApi && this.mastoUserSocketStatus === WSConnectionStatus.JOINED + streamingEnabled() { + return ( + this.mergedConfig.useStreamingApi && + this.mastoUserSocketStatus === WSConnectionStatus.JOINED + ) }, - displayStyle () { - return this.$store.getters.mergedConfig.conversationDisplay + displayStyle() { + return this.mergedConfig.conversationDisplay }, - isTreeView () { + isTreeView() { return !this.isLinearView }, - treeViewIsSimple () { - return !this.$store.getters.mergedConfig.conversationTreeAdvanced + treeViewIsSimple() { + return !this.mergedConfig.conversationTreeAdvanced }, - isLinearView () { + isLinearView() { return this.displayStyle === 'linear' }, - shouldFadeAncestors () { - return this.$store.getters.mergedConfig.conversationTreeFadeAncestors + shouldFadeAncestors() { + return this.mergedConfig.conversationTreeFadeAncestors }, - otherRepliesButtonPosition () { - return this.$store.getters.mergedConfig.conversationOtherRepliesButton + otherRepliesButtonPosition() { + return this.mergedConfig.conversationOtherRepliesButton }, - showOtherRepliesButtonBelowStatus () { + showOtherRepliesButtonBelowStatus() { return this.otherRepliesButtonPosition === 'below' }, - showOtherRepliesButtonInsideStatus () { + showOtherRepliesButtonInsideStatus() { return this.otherRepliesButtonPosition === 'inside' }, - suspendable () { + suspendable() { if (this.isTreeView) { - return Object.entries(this.statusContentProperties) - .every(([k, prop]) => !prop.replying && prop.mediaPlaying.length === 0) + return Object.entries(this.statusContentProperties).every( + ([, prop]) => !prop.replying && prop.mediaPlaying.length === 0, + ) } if (this.$refs.statusComponent && this.$refs.statusComponent[0]) { - return this.$refs.statusComponent.every(s => s.suspendable) + return this.$refs.statusComponent.every((s) => s.suspendable) } else { return true } }, - hideStatus () { + hideStatus() { return this.virtualHidden && this.suspendable }, - status () { + status() { return this.$store.state.statuses.allStatusesObject[this.statusId] }, - originalStatusId () { + originalStatusId() { if (this.status.retweeted_status) { return this.status.retweeted_status.id } else { return this.statusId } }, - conversationId () { + conversationId() { return this.getConversationId(this.statusId) }, - conversation () { + conversation() { if (!this.status) { return [] } @@ -145,7 +151,9 @@ const conversation = { return [this.status] } - const conversation = clone(this.$store.state.statuses.conversationsObject[this.conversationId]) + const conversation = clone( + this.$store.state.statuses.conversationsObject[this.conversationId], + ) const statusIndex = findIndex(conversation, { id: this.originalStatusId }) if (statusIndex !== -1) { conversation[statusIndex] = this.status @@ -153,147 +161,188 @@ const conversation = { return sortAndFilterConversation(conversation, this.status) }, - statusMap () { + statusMap() { return this.conversation.reduce((res, s) => { res[s.id] = s return res }, {}) }, - threadTree () { - const reverseLookupTable = this.conversation.reduce((table, status, index) => { - table[status.id] = index - return table - }, {}) + threadTree() { + const reverseLookupTable = this.conversation.reduce( + (table, status, index) => { + table[status.id] = index + return table + }, + {}, + ) - const threads = this.conversation.reduce((a, cur) => { - const id = cur.id - a.forest[id] = this.getReplies(id) - .map(s => s.id) + const threads = this.conversation.reduce( + (a, cur) => { + const id = cur.id + a.forest[id] = this.getReplies(id).map((s) => s.id) - return a - }, { - forest: {} - }) + return a + }, + { + forest: {}, + }, + ) - const walk = (forest, topLevel, depth = 0, processed = {}) => topLevel.map(id => { - if (processed[id]) { - return [] - } + const walk = (forest, topLevel, depth = 0, processed = {}) => + topLevel + .map((id) => { + if (processed[id]) { + return [] + } - processed[id] = true - return [{ - status: this.conversation[reverseLookupTable[id]], - id, - depth - }, walk(forest, forest[id], depth + 1, processed)].reduce((a, b) => a.concat(b), []) - }).reduce((a, b) => a.concat(b), []) + processed[id] = true + return [ + { + status: this.conversation[reverseLookupTable[id]], + id, + depth, + }, + walk(forest, forest[id], depth + 1, processed), + ].reduce((a, b) => a.concat(b), []) + }) + .reduce((a, b) => a.concat(b), []) - const linearized = walk(threads.forest, this.topLevel.map(k => k.id)) + const linearized = walk( + threads.forest, + this.topLevel.map((k) => k.id), + ) return linearized }, - replyIds () { - return this.conversation.map(k => k.id) + replyIds() { + return this.conversation + .map((k) => k.id) .reduce((res, id) => { - res[id] = (this.replies[id] || []).map(k => k.id) + res[id] = (this.replies[id] || []).map((k) => k.id) return res }, {}) }, - totalReplyCount () { + totalReplyCount() { const sizes = {} const subTreeSizeFor = (id) => { if (sizes[id]) { return sizes[id] } - sizes[id] = 1 + this.replyIds[id].map(cid => subTreeSizeFor(cid)).reduce((a, b) => a + b, 0) + sizes[id] = + 1 + + this.replyIds[id] + .map((cid) => subTreeSizeFor(cid)) + .reduce((a, b) => a + b, 0) return sizes[id] } - this.conversation.map(k => k.id).map(subTreeSizeFor) + this.conversation.map((k) => k.id).map(subTreeSizeFor) return Object.keys(sizes).reduce((res, id) => { res[id] = sizes[id] - 1 // exclude itself return res }, {}) }, - totalReplyDepth () { + totalReplyDepth() { const depths = {} const subTreeDepthFor = (id) => { if (depths[id]) { return depths[id] } - depths[id] = 1 + this.replyIds[id].map(cid => subTreeDepthFor(cid)).reduce((a, b) => a > b ? a : b, 0) + depths[id] = + 1 + + this.replyIds[id] + .map((cid) => subTreeDepthFor(cid)) + .reduce((a, b) => (a > b ? a : b), 0) return depths[id] } - this.conversation.map(k => k.id).map(subTreeDepthFor) + this.conversation.map((k) => k.id).map(subTreeDepthFor) return Object.keys(depths).reduce((res, id) => { res[id] = depths[id] - 1 // exclude itself return res }, {}) }, - depths () { + depths() { return this.threadTree.reduce((a, k) => { a[k.id] = k.depth return a }, {}) }, - topLevel () { - const topLevel = this.conversation.reduce((tl, cur) => - tl.filter(k => this.getReplies(cur.id).map(v => v.id).indexOf(k.id) === -1), this.conversation) + topLevel() { + const topLevel = this.conversation.reduce( + (tl, cur) => + tl.filter( + (k) => + this.getReplies(cur.id) + .map((v) => v.id) + .indexOf(k.id) === -1, + ), + this.conversation, + ) return topLevel }, - otherTopLevelCount () { + otherTopLevelCount() { return this.topLevel.length - 1 }, - showingTopLevel () { + showingTopLevel() { if (this.canDive && this.diveRoot) { return [this.statusMap[this.diveRoot]] } return this.topLevel }, - diveRoot () { + diveRoot() { const statusId = this.inlineDivePosition || this.statusId const isTopLevel = !this.parentOf(statusId) return isTopLevel ? null : statusId }, - diveDepth () { + diveDepth() { return this.canDive && this.diveRoot ? this.depths[this.diveRoot] : 0 }, - diveMode () { + diveMode() { return this.canDive && !!this.diveRoot }, - shouldShowAllConversationButton () { + shouldShowAllConversationButton() { // The "show all conversation" button tells the user that there exist // other toplevel statuses, so do not show it if there is only a single root - return this.isTreeView && this.isExpanded && this.diveMode && this.topLevel.length > 1 + return ( + this.isTreeView && + this.isExpanded && + this.diveMode && + this.topLevel.length > 1 + ) }, - shouldShowAncestors () { - return this.isTreeView && this.isExpanded && this.ancestorsOf(this.diveRoot).length + shouldShowAncestors() { + return ( + this.isTreeView && + this.isExpanded && + this.ancestorsOf(this.diveRoot).length + ) }, - replies () { + replies() { let i = 1 - // eslint-disable-next-line camelcase - return reduce(this.conversation, (result, { id, in_reply_to_status_id }) => { - /* eslint-disable camelcase */ - const irid = in_reply_to_status_id - /* eslint-enable camelcase */ - if (irid) { - result[irid] = result[irid] || [] - result[irid].push({ - name: `#${i}`, - id - }) - } - i++ - return result - }, {}) + + return reduce( + this.conversation, + (result, { id, in_reply_to_status_id: irid }) => { + if (irid) { + result[irid] = result[irid] || [] + result[irid].push({ + name: `#${i}`, + id, + }) + } + i++ + return result + }, + {}, + ) }, - isExpanded () { + isExpanded() { return !!(this.expanded || this.isPage) }, - hiddenStyle () { + hiddenStyle() { const height = (this.status && this.status.virtualHeight) || '120px' return this.virtualHidden ? { height } : {} }, - threadDisplayStatus () { + threadDisplayStatus() { return this.conversation.reduce((a, k) => { const id = k.id const depth = this.depths[id] @@ -301,7 +350,7 @@ const conversation = { if (this.threadDisplayStatusObject[id]) { return this.threadDisplayStatusObject[id] } - if ((depth - this.diveDepth) <= this.maxDepthToShowByDefault) { + if (depth - this.diveDepth <= this.maxDepthToShowByDefault) { return 'showing' } else { return 'hidden' @@ -312,7 +361,7 @@ const conversation = { return a }, {}) }, - statusContentProperties () { + statusContentProperties() { return this.conversation.reduce((a, k) => { const id = k.id const props = (() => { @@ -321,13 +370,13 @@ const conversation = { expandingSubject: false, showingLongSubject: false, isReplying: false, - mediaPlaying: [] + mediaPlaying: [], } if (this.statusContentPropertiesObject[id]) { return { ...def, - ...this.statusContentPropertiesObject[id] + ...this.statusContentPropertiesObject[id], } } return def @@ -337,56 +386,58 @@ const conversation = { return a }, {}) }, - canDive () { + canDive() { return this.isTreeView && this.isExpanded }, - focused () { - return (id) => { - return (this.isExpanded) && id === this.highlight - } - }, - maybeHighlight () { + maybeHighlight() { return this.isExpanded ? this.highlight : null }, - ...mapGetters(['mergedConfig']), + ...mapPiniaState(useMergedConfigStore, ['mergedConfig']), ...mapState({ - mastoUserSocketStatus: state => state.api.mastoUserSocketStatus - }) + mastoUserSocketStatus: (state) => state.api.mastoUserSocketStatus, + }), + ...mapPiniaState(useInterfaceStore, { + mobileLayout: (store) => store.layoutType === 'mobile', + }), }, components: { - Status, ThreadTree, QuickFilterSettings, - QuickViewSettings + QuickViewSettings, }, watch: { - statusId (newVal, oldVal) { + statusId(newVal, oldVal) { const newConversationId = this.getConversationId(newVal) const oldConversationId = this.getConversationId(oldVal) - if (newConversationId && oldConversationId && newConversationId === oldConversationId) { + if ( + newConversationId && + oldConversationId && + newConversationId === oldConversationId + ) { this.setHighlight(this.originalStatusId) } else { this.fetchConversation() } }, - expanded (value) { + expanded(value) { if (value) { this.fetchConversation() } else { this.resetDisplayState() } }, - virtualHidden (value) { - this.$store.dispatch( - 'setVirtualHeight', - { statusId: this.statusId, height: `${this.$el.clientHeight}px` } - ) - } + virtualHidden() { + this.$store.dispatch('setVirtualHeight', { + statusId: this.statusId, + height: `${this.$el.clientHeight}px`, + }) + }, }, methods: { - fetchConversation () { + fetchConversation() { if (this.status) { - this.$store.state.api.backendInteractor.fetchConversation({ id: this.statusId }) + this.$store.state.api.backendInteractor + .fetchConversation({ id: this.statusId }) .then(({ ancestors, descendants }) => { this.$store.dispatch('addNewStatuses', { statuses: ancestors }) this.$store.dispatch('addNewStatuses', { statuses: descendants }) @@ -394,7 +445,8 @@ const conversation = { }) } else { this.loadStatusError = null - this.$store.state.api.backendInteractor.fetchStatus({ id: this.statusId }) + this.$store.state.api.backendInteractor + .fetchStatus({ id: this.statusId }) .then((status) => { this.$store.dispatch('addNewStatuses', { statuses: [status] }) this.fetchConversation() @@ -404,13 +456,16 @@ const conversation = { }) } }, - getReplies (id) { + isFocused(id) { + return this.isExpanded && id === this.highlight + }, + getReplies(id) { return this.replies[id] || [] }, - getHighlight () { + getHighlight() { return this.isExpanded ? this.highlight : null }, - setHighlight (id) { + setHighlight(id) { if (!id) return this.highlight = id @@ -421,44 +476,54 @@ const conversation = { this.$store.dispatch('fetchFavsAndRepeats', id) this.$store.dispatch('fetchEmojiReactionsBy', id) }, - toggleExpanded () { + toggleExpanded() { this.expanded = !this.expanded }, - getConversationId (statusId) { + getConversationId(statusId) { const status = this.$store.state.statuses.allStatusesObject[statusId] - return get(status, 'retweeted_status.statusnet_conversation_id', get(status, 'statusnet_conversation_id')) + return get( + status, + 'retweeted_status.statusnet_conversation_id', + get(status, 'statusnet_conversation_id'), + ) }, - setThreadDisplay (id, nextStatus) { + setThreadDisplay(id, nextStatus) { this.threadDisplayStatusObject = { ...this.threadDisplayStatusObject, - [id]: nextStatus + [id]: nextStatus, } }, - toggleThreadDisplay (id) { + toggleThreadDisplay(id) { const curStatus = this.threadDisplayStatus[id] const nextStatus = curStatus === 'showing' ? 'hidden' : 'showing' this.setThreadDisplay(id, nextStatus) }, - setThreadDisplayRecursively (id, nextStatus) { + setThreadDisplayRecursively(id, nextStatus) { this.setThreadDisplay(id, nextStatus) - this.getReplies(id).map(k => k.id).map(id => this.setThreadDisplayRecursively(id, nextStatus)) + this.getReplies(id) + .map((k) => k.id) + .map((id) => this.setThreadDisplayRecursively(id, nextStatus)) }, - showThreadRecursively (id) { + showThreadRecursively(id) { this.setThreadDisplayRecursively(id, 'showing') }, - setStatusContentProperty (id, name, value) { + setStatusContentProperty(id, name, value) { this.statusContentPropertiesObject = { ...this.statusContentPropertiesObject, [id]: { ...this.statusContentPropertiesObject[id], - [name]: value - } + [name]: value, + }, } }, - toggleStatusContentProperty (id, name) { - this.setStatusContentProperty(id, name, !this.statusContentProperties[id][name]) + toggleStatusContentProperty(id, name) { + this.setStatusContentProperty( + id, + name, + !this.statusContentProperties[id][name], + ) }, - leastVisibleAncestor (id) { + leastVisibleAncestor(id) { let cur = id let parent = this.parentOf(cur) while (cur) { @@ -472,18 +537,20 @@ const conversation = { // nothing found, fall back to toplevel return this.topLevel[0] ? this.topLevel[0].id : undefined }, - diveIntoStatus (id, preventScroll) { + diveIntoStatus(id) { this.tryScrollTo(id) }, - diveToTopLevel () { - this.tryScrollTo(this.topLevelAncestorOrSelfId(this.diveRoot) || this.topLevel[0].id) + diveToTopLevel() { + this.tryScrollTo( + this.topLevelAncestorOrSelfId(this.diveRoot) || this.topLevel[0].id, + ) }, // only used when we are not on a page - undive () { + undive() { this.inlineDivePosition = null this.setHighlight(this.statusId) }, - tryScrollTo (id) { + tryScrollTo(id) { if (!id) { return } @@ -512,13 +579,13 @@ const conversation = { this.setHighlight(id) }) }, - goToCurrent () { + goToCurrent() { this.tryScrollTo(this.diveRoot || this.topLevel[0].id) }, - statusById (id) { + statusById(id) { return this.statusMap[id] }, - parentOf (id) { + parentOf(id) { const status = this.statusById(id) if (!status) { return undefined @@ -529,11 +596,11 @@ const conversation = { } return parentId }, - parentOrSelf (id) { + parentOrSelf(id) { return this.parentOf(id) || id }, // Ancestors of some status, from top to bottom - ancestorsOf (id) { + ancestorsOf(id) { const ancestors = [] let cur = this.parentOf(id) while (cur) { @@ -542,7 +609,7 @@ const conversation = { } return ancestors }, - topLevelAncestorOrSelfId (id) { + topLevelAncestorOrSelfId(id) { let cur = id let parent = this.parentOf(id) while (parent) { @@ -551,11 +618,11 @@ const conversation = { } return cur }, - resetDisplayState () { + resetDisplayState() { this.undive() this.threadDisplayStatusObject = {} - } - } + }, + }, } export default conversation diff --git a/src/components/conversation/conversation.vue b/src/components/conversation/conversation.vue index 526de5c30..8d4734083 100644 --- a/src/components/conversation/conversation.vue +++ b/src/components/conversation/conversation.vue @@ -9,7 +9,9 @@ v-if="isExpanded" class="panel-heading conversation-heading -sticky" > - {{ $t('timeline.conversation') }} +

+ {{ $t('timeline.conversation') }} +

- - - + - {{ $t('login.logout_confirm') }} - + diff --git a/src/components/dialog_modal/dialog_modal.js b/src/components/dialog_modal/dialog_modal.js index f14e3fe9a..083f7a21d 100644 --- a/src/components/dialog_modal/dialog_modal.js +++ b/src/components/dialog_modal/dialog_modal.js @@ -1,14 +1,23 @@ +import { useMergedConfigStore } from 'src/stores/merged_config.js' + const DialogModal = { props: { darkOverlay: { default: true, - type: Boolean + type: Boolean, }, onCancel: { - default: () => {}, - type: Function - } - } + default: () => { + /* no-op */ + }, + type: Function, + }, + }, + computed: { + mobileCenter() { + return useMergedConfigStore().mergedConfig.modalMobileCenter + }, + }, } export default DialogModal diff --git a/src/components/dialog_modal/dialog_modal.vue b/src/components/dialog_modal/dialog_modal.vue index 3bceff229..2b9c7a5d8 100644 --- a/src/components/dialog_modal/dialog_modal.vue +++ b/src/components/dialog_modal/dialog_modal.vue @@ -1,6 +1,7 @@